The thing that clicked today: you cannot merge two rankings by comparing their scores when the scores are measured in different units, and you cannot let a new best-effort feature become the reason an old reliable one returns an error.
Prospect is my local-first job-application tracker. It already had keyword search over captured listings and, since the week before, an embedding for each listing (a vector that captures what the posting is about). Two things followed from that: search by meaning, and catching a listing that is really a repost of one already captured.
Built / shipped
Search by meaning, fused with keyword search. The keyword side ranks by a text-relevance score. The vector side ranks by distance between embeddings. Those two numbers are not comparable, so the plan's "merge by rank, like the dedup code does" could not be taken literally. The merge is by position: each result's contribution is one over its rank in each list plus a constant (60), and the contributions are summed. A result that appears in both lists collects both and rises to the top, and a result in only one still gets in. No score from either side is ever compared to a score from the other.
The first synchronous model call in the request path, made safe. Searching by meaning means embedding the query at request time, which calls the local model server. That is the first time a search request depends on a model responding. So: a standalone query-embed function with a 2.5-second abort, a check that the vector has the expected 768 dimensions, and the whole vector branch wrapped so that any failure, timeout, or empty index returns the keyword-only results. It must be impossible for the new branch to turn a working search into a 500.
Semantic repost detection, at capture time. Exact-hash duplicate detection was being defeated by per-render token churn in the page, and the title-overlap check by paraphrase ("Sr." versus "Senior"). A semantic tier over the stored embeddings catches both. It slots into the existing best-effort, write-free, pre-insert check, so capture can never fail because the detector errored. It embeds only when the two cheap deterministic tiers miss, saving the round trip on the common path. And its output is advisory only: a labelled "semantic" candidate with its distance, never an automatic link and never dressed up as an exact match, so a mis-tuned threshold can at worst show a dismissible flag or miss one. It cannot lose data.
The threshold, calibrated on a copy. A scratch clone of the database (made with SQLite's own backup command, never a file copy), a synthetic set of three known reposts (title swap, whitespace and reorder churn, light edits) against four genuinely different postings, run through the real model server. Repost cluster distances ranged 0.059 to 0.176; different-posting distances 0.776 to 0.890; a clean gap of 0.60 between them. The threshold was set at 0.3: roughly 1.7 times the repost maximum and 2.5 times below the different-posting floor.
Problems & fixes
A fusion test that passed for the wrong reason. With only three vector rows in the test corpus, a nearest-20 query returns all of them, so a "keyword-only" claim also appeared in the vector list and the test passed spuriously. Fixed by padding the corpus with twenty decoy embeddings so the nearest-20 actually excludes something.
A read-only verification job that wrote. The job sent to confirm the database was unchanged ran a write-ahead-log checkpoint on the production file, which reads like a diagnostic and is in fact a write: it folded the log into the main file (847 KB to 4.15 MB) and changed the file's hash, producing a phantom "mismatch" against the pre-change hash. No content harm, and the job flagged itself. Two durable fixes: the immutability check now anchors on the exact stored bytes of each snapshot and its write-once content hash, not on a hash of the command-line client's piped output (which silently differs by a trailing newline); and read-only prompts now explicitly forbid the handful of SQLite commands that mutate the file while looking like diagnostics.
The sanity pass four days later. Over the live 14 listings, all 91 pairwise distances were computed: the minimum was 0.5564, leaving 0.256 of headroom above the 0.3 threshold, so zero false-positive risk on that corpus. And the honest part: the live corpus contained no genuine repost pair at all, so the ceiling figure that would confirm the other side of the threshold was reported as undefined rather than fabricated, and the section closed with no code change.
Decisions
Fuse rankings by position, never by comparing raw scores from different systems.
Every new model call in a request path gets a timeout, a shape check, and a degrade path to the old behaviour.
The duplicate detector is advisory and write-free. A human confirms a link; the detector only suggests.
Calibrate on a scratch clone made with the database's own backup command, and keep the incoming row's authoritative embedding in the background queue, so the capture path's only write stays the snapshot insert.
Learned
Two rankings in different units cannot be merged by score. Reciprocal rank fusion was the simple answer, and the test corpus had to be big enough for nearest-neighbour to exclude something before the test meant anything.
"Read-only" has to be spelled out. A checkpoint is a write that looks like a read.
A detector that is allowed to be wrong can ship early. One that auto-links cannot.
Report an undefined number as undefined. The corpus had no repost pair; inventing a ceiling would have been the worst kind of precision.
Still open / next
The capture-time embed should be gated on the same flag that controls the rest of the embedding work, so the feature can be turned off in one place. And the real test of the threshold is the first genuine repost to arrive, which will give the ceiling the live corpus could not.