What Is D-118? When Your Search Can Match but Cannot Rank

A note from the operator. I run Netrun Systems. We keep an agent memory store — a few thousand documents of meeting records, decision logs, incident write-ups and engineering notes — that our AI agents query before they answer questions about our own history. The bug below is one I would have misdiagnosed, and did, for a while. Netrun's CTO agent found the real cause and rebuilt the retrieval path; I reviewed and approved it. We are writing it up because if your corpus contains identifiers of any kind, you probably have this bug right now and your search looks like it is working. — Daniel Garza
The symptom
The most common question anyone asks our memory store is some variant of "what is D-118?"
D-118 is a decision identifier. We number our decisions, and our agents look them up constantly — before planning a sprint, before contradicting a prior commitment, before telling me something is settled when it isn't. The answer lives in exactly two documents: decision_register.md, which defines it, and Boardroom_TODO.md, which tracks it.
Ask the store "what is D-118" and it returned an unrelated status report from five months earlier.
Not an error. Not an empty result. A confident, well-formed, completely useless answer, with the right documents nowhere in the top ten.
The wrong first diagnosis
The obvious read is that the embedding model is too small. We were running a 384-dimension model, and 384 is modest. The fix would be to upgrade to a bigger model, re-embed the corpus, and move on.
That read is wrong, and it is expensive. Re-embedding a corpus is not free in time or money, and it would not have fixed this.
Here is what sent us the other way: the documents were being found. Both of them. They were in the result set. They were just ranked below a document that had nothing to do with the question.
That is a different bug. Matching and ranking are separate stages, and they fail separately. We had spent our attention on the stage that was working.
The real diagnosis
PostgreSQL full-text search does not treat D-118 as one thing. Run the tokenizer and you can watch it come apart:
select to_tsvector('english', 'D-118');
-- '-118':2 'd':1
Two tokens. The hyphen is a separator, so the identifier becomes the letter d followed by the number -118. Our query builder uses websearch_to_tsquery, which turns the search into a phrase lookup:
select websearch_to_tsquery('english', 'D-118');
-- 'd' <-> '-118'
"The token d, immediately followed by the token -118." That is a reasonable thing for Postgres to do, and it mostly works. It produced two separate problems.
Problem one: a recall gap nobody would notice
Measured on our actual corpus:
| Count | |
|---|---|
Chunks matching the phrase query 'd' <-> '-118' |
31 |
Chunks containing the literal string D-118 |
38 |
Seven chunks — 18% — contain the identifier and are invisible to full-text search entirely. They never enter the candidate set, so no amount of ranking work will recover them. A search that silently drops a fifth of its true matches still feels like it works, because you only ever see what it returned.
Problem two: the letter d is one of the commonest tokens in English
This is the part that buries the right answer, and it is worth reproducing because it is counterintuitive. Among the 31 chunks that do match, ts_rank scores them by the weighted frequency of the matched lexemes — and one of those lexemes is d.
Two documents. The first is genuinely about the decision. The second mentions it once in passing and then contains a great many bare d tokens, which is not an artificial scenario in a corpus full of drive letters, grades, initials and version markers:
with docs(label, body) as (values
('about_D118_short',
'Decision D-118 ratified the services-funded posture.'),
('mentions_D118_d_dense',
'D-118. ' || repeat('d drive d day d class d minus d grade d flat ', 30))
)
select label,
ts_rank(to_tsvector('english', body),
websearch_to_tsquery('english','D-118')) as rank_default
from docs order by rank_default desc;
label | rank_default
-----------------------+--------------
mentions_D118_d_dense | 0.4524412
about_D118_short | 0.0991032
The document that merely name-drops the identifier scores 4.6 times higher than the document that answers the question. Every additional d anywhere in a document inflates its relevance to a search for D-118.
And the obvious mitigation does not rescue it. Passing 1 as the normalization flag divides the rank by document length, which is the standard remedy for long-document bias:
label | rank_norm1
-----------------------+------------
mentions_D118_d_dense | 0.05320443
about_D118_short | 0.03126359
Closer, still inverted. Length was never the real variable; token commonness was.
And the vector leg fails independently
Meanwhile the semantic leg is no help, for an unrelated reason: a general-purpose embedding model has no useful representation of a rare literal. D-118 is not a word, carries no distributional meaning, and sits wherever the tokenizer happens to put it. Vector search is good at paraphrase and weak at exact tokens — which is the correct trade for prose and the wrong one for a decision ID.
So both legs failed the same query for two completely different reasons. That coincidence is why the bug survived as long as it did: whichever leg we examined, the other one looked like it should have covered us.
Matching was never the problem. Ranking was.
The fix: a third leg that only knows how to be literal
We added an exact-match leg. It is the least clever component in the system:
select id from memory_chunks
where content ILIKE '%D-118%'
order by (occurrence count) desc
ILIKE with leading and trailing wildcards, backed by a trigram index so it stays fast, ranked by how many times the literal appears. No stemming, no tokenizer, no embedding. On the query that started this, it puts decision_register.md and Boardroom_TODO.md first, which are the right answers.
It only runs on query terms that look like identifiers — a letter run touching digits (D-118, PR57, v23, sub_1TTDJ2), a bare hex SHA, or a dotted or slashed path-like token. On an ordinary prose question it matches nothing, and a leg that matches nothing costs nothing. The exact leg is free except when it is the only thing that works.
Fuse ranks, never scores
Three legs now return three ordered lists, and they have to be combined. We use Reciprocal Rank Fusion, which assigns each result K / (K + rank) per leg and sums across legs, with K = 60.
The important property is that RRF combines ranks and never raw scores. Cosine distance, ts_rank output and an occurrence count share no scale whatsoever — one is bounded near zero to one, one is an arbitrary positive float, one is an unbounded integer. Normalizing them into a common range is guesswork, and the guess silently stops being right the moment a distribution shifts. Ranks are the only thing the three legs genuinely have in common.
That choice pays an unplanned dividend. With K = 60, a single leg ranking a chunk first contributes 60/61 = 0.984. So a fused score above 1.0 can only mean two or more legs independently put that chunk first — a free confidence signal that falls out of the arithmetic rather than being designed in. When two retrieval methods with nothing in common agree on the top result, that result is almost always right.
We also added an --explain flag that prints which legs matched each hit, as [ex+kw] or [vec]. It is the fastest way to tell a ranking problem from a matching problem, which is precisely the distinction we got wrong at the start.
An aside, because it cost us an afternoon
While testing this, output started truncating after the first result. It looked exactly like a retrieval bug: the query ran, one hit came back, the rest vanished.
It was the Windows console. The corpus contains emoji and box-drawing characters, the console defaulted to cp1252, and printing a character it could not encode raised UnicodeEncodeError mid-write. The retrieval was perfect; the terminal was eating it.
The fix is three lines reconfiguring the streams to UTF-8 with replacement. We mention it because the failure signature — "returns one result instead of ten" — points straight at the search layer and belongs to the display layer. When a symptom appears at a boundary, check both sides of it before forming a theory.
What to take away
If your corpus contains identifiers — ticket numbers, decision IDs, commit SHAs, hostnames, invoice numbers, SKUs, version tags, error codes — then:
Run your tokenizer on your identifier format before you trust your search.
select to_tsvector('english', 'YOUR-ID-123')takes two seconds and tells you whether the thing your users search for most survives as a unit. Hyphens, underscores, dots and slashes are all separators, and what comes out the other side may be a very common token plus a fragment.Count your true matches against your returned matches. Ours were 38 and 31. That ratio is the single most useful number in this whole investigation, and nothing in the system would have shown it to us. A search that drops 18% of its hits looks healthy from the outside.
Separate "did it match" from "did it rank." These fail independently and have different fixes. Upgrading your embedding model does nothing for a ranking inversion, and no reranker recovers a document that was never retrieved.
Add a literal leg. It is cheap, it needs no model, it is trivially debuggable, and it is the only component that treats an identifier as the atomic thing your users believe it is. Gate it on identifier-shaped terms and it stays out of the way.
Fuse on ranks. Any scheme that normalizes incommensurable scores into one number is carrying an assumption about distributions that you will not notice breaking.
Keep a way to ask which leg answered. Hybrid search without per-leg attribution is a system you can only debug by guessing.
The broader point is the one that cost us the most. We had a search system that returned results for every query, never errored, and was wrong about the single most common question it was asked. Nothing in its behaviour said "broken." It said "here you go," ten times a day, with a confident irrelevant document on top.
An information retrieval system does not fail loudly. It fails plausibly. The only defence is to pick the question you ask it most and check the answer by hand.
Netrun Systems builds and operates agent-orchestrated infrastructure. We publish our own failure modes, with the measurements, because the useful part of a post-mortem is the diagnosis that was wrong first.