Embedding Dilution in Semantic Code Search
The function whose docstring paraphrased my query almost word for word ranked below two hundred look-alikes — because everything else in the chunk averaged it away. Here is the measurement, and the fix it shipped.
I had a query that should have been boring. "Get a single object from the database." In Django, that is QuerySet.get — the method whose entire job is to fetch exactly one row matching your lookup, or raise. Its docstring reads, almost verbatim: "Perform the query and return a single object matching the given keyword arguments." That is not a loose match to my query. It is nearly a paraphrase of it.
The chunk was indexed. I checked. The embedding was computed and stored like every other chunk in the corpus. And when I ran the search, the method was not in the top result, not at rank fifty — it was not in the top two hundred candidates at all. It never made it far enough into the pipeline to be judged. Two hundred other chunks, none of which described getting a single object from the database, beat it into the pool.
That failure bothered me enough to take apart the whole retrieval path and measure where the answer died. This post is the post-mortem. The system under test is vectr, a semantic code-search and working-memory tool I build; the corpus is Django, used purely as a public witness. But the mechanism I found is not specific to code, and it is not specific to my tool. It is a property of how a single embedding vector has to summarize a long, mixed passage — and it quietly limits recall in a lot of retrieval systems that look like they are working. If you have never read the embeddings foundations, my earlier complete guide to text embeddings and RAG is the primer; this is the failure that guide's happy path hides.
The Pipeline and the Query That Broke It
Before the failure makes sense, you need the shape of the pipeline it happened in, because the shape is where the whole story turns. At measurement time, vectr retrieved in the way most production semantic-search systems do — a two-stage funnel.
Stage one is hybrid retrieval. It runs two searches in parallel and merges them. One leg is dense retrieval: encode the query into a vector, encode every chunk into a vector, and rank chunks by cosine similarity. The other leg is BM25, a keyword-scoring function that rewards exact term overlap. Together they produce a candidate pool of the top 200 chunks.
Stage two reranks that pool. A cross-encoder reranker — bge-reranker-base — reads the query alongside each of the 200 pool members and re-scores them properly, followed by a quality pass. That reranker is the smart part of the system. It is also the expensive part, which is exactly why it only ever sees 200 candidates instead of all 40,538.
The reranker, the importance priors, the quality scores — every clever thing downstream operates only on the 200 chunks in the pool. A chunk that is not in the pool is invisible to all of it. So the first question for any retrieval miss is never "why did the reranker score it low." It is "was it even in the pool to be scored." Recall gates everything after it.
Now the pieces that matter for the failure. The dense embedder was snowflake-arctic-embed-m-v1.5 — a general text embedder, not a code-specialized one. Hold onto that; it is not the villain, but it shapes the numbers. The corpus was a Django checkout from June 2026: 4,129 files, 40,538 indexed chunks. And the chunk for QuerySet.get was, by any reasonable standard, ideal. It carried a class marker (QuerySet), the full method signature, and that near-perfect docstring — followed by roughly thirty lines of mechanical implementation.
That last clause is the whole problem in embryo. But to see why, we first have to pin down where the miss happened, because the fix depends entirely on that.
The Miss Is at Pool Entry, Not Ranking
My first instinct was the wrong one, and it is probably yours too: the reranker must have mis-scored it. Bump the reranker, add an importance prior for well-known symbols, tune the quality pass. Every one of those fixes operates on the pool. So I checked the pool directly, leg by leg, and the reranker turned out to be innocent — it never got the chance to be guilty.
The dense leg
For every natural-language phrasing I tried — "get a single object from the database," "fetch one row matching criteria," "retrieve a single record by lookup" — QuerySet.get was absent from the top 200 dense results. Not low-ranked. Absent. The only phrasing that got it into the dense pool at all was a deliberately ORM-flavored control, written in Django's own vocabulary, and even that reached only #123 of 200 — barely inside a pool it should have topped.
The keyword leg
BM25 was all over the place, which is its nature: it lives and dies on exact term overlap. Phrase the query as "return exactly one matching object or raise…" — words that literally appear near the method — and BM25 ranked it #1. Phrase it as "fetch one row matching criteria" and the same method fell to #127. Other phrasings missed entirely. BM25 wasn't a safety net; it was a coin whose bias depended on whether I happened to echo the source text.
The reranker never saw QuerySet.get for the natural-language queries, because QuerySet.get was never in the 200 it was handed. This is the structural point the rest of the post builds on: if the right chunk never enters the pool, nothing downstream can save it. A brilliant reranker on an incomplete pool is a brilliant answer to the wrong question.
This is also the moment most retrieval dashboards lie to you by omission. They display the reranked top-k — the final, polished output — which looks fine because the reranker did a competent job on the pool it received. The miss is one layer up, invisible on that screen. You have to instrument pool entry itself to see it. The demo below traces one chunk's fate through the funnel for three real scenarios from this run, so you can watch exactly which stage it dies at.
Where the chunk dies in the funnel
Pick a query phrasing and follow the QuerySet.get chunk through the pipeline. Each stage lights green if the chunk survives it, amber if it barely scrapes through, red if it dies there. All three scenarios use ranks measured in this run — nothing here is invented.
QuerySet.get chunk under each phrasing. A dash means that leg's rank was not separately recorded for that phrasing. Scenario C is the documented case where BM25 ranked the target #1 yet the dense-dominated fusion dropped it before the returned top-60 — see Section 06.Dilution, Measured
So the dense leg failed to rank a chunk whose docstring paraphrases the query. Why? The lazy answer is "the embedder isn't good enough, throw a bigger model at it." That answer is wrong, and I can show it is wrong with one micro-experiment.
I took the exact same embedder and embedded two things. First, the full chunk: class marker, signature, perfect docstring, plus the ~30 lines of body — the combinator handling, the _chain() call, the select_for_update checks, the NotSupportedError raises. Second, just the signature and docstring alone — call it the "purpose-only" version, the part that says what this is for with none of the machinery that says how it does it. Then I measured cosine similarity from each version to four query phrasings.
| Query | Full chunk | Purpose-only | Delta |
|---|---|---|---|
| get a single object from the database | 0.601 | 0.706 | +0.105 |
| fetch one row matching criteria | 0.511 | 0.606 | +0.095 |
| retrieve a single record by lookup | 0.529 | 0.625 | +0.096 |
| return exactly one matching object or raise… | 0.617 | 0.678 | +0.061 |
Purpose-only is closer to the query on every single phrasing, by +0.06 to +0.10 cosine. Same embedder, same docstring, same query. The only thing I removed was the implementation body — and the chunk got measurably more relevant to the thing it does. The signal that answers the query was in the chunk the whole time. The body was burying it.
An encoder turns a passage into one fixed-size vector. Cosine similarity then compares directions:
cos(q, d) = (q · d) / (‖q‖ · ‖d‖)The catch is d. Whether the model builds it by literally averaging its token vectors (mean pooling) or by a summary token that attends across all tokens, the result is one point that must stand in for the whole passage. For the literal mean-pooling case it is just an average:
d ≈ (1/N) · Σ eᵢA CLS- or attention-pooled encoder weights the tokens unevenly instead of averaging them flat, but the consequence is the same. Add thirty lines of body and you pour in dozens of token vectors pointing toward "loop, chain, raise, check." They pull d toward the body's center of mass and away from the docstring's direction. The docstring's contribution does not vanish — it gets outvoted. That is dilution: not a missing signal, a drowned one.
Now the calibration that turns this from a curiosity into a recall failure. In this embedding space, for the "get a single object" query, the weakest chunk that made it into the 200-deep pool sat at a cosine of about 0.697. Purpose-only scored 0.706 — over the line, into the pool, in front of the reranker. The full chunk scored 0.601 — under the line, out of the pool, invisible. The entire difference between "the reranker gets a shot at the right answer" and "the right answer is never considered" is that +0.10 of cosine the body ate.
The body eats the signal
Pick a query and compare the full-chunk cosine against the purpose-only cosine for the same QuerySet.get. The dashed gold line is the pool-entry floor calibrated on the first query. Watch how much distance the implementation body costs — and where the recovered distance lands relative to the line.
The reflex when recall is bad is to feed the embedder more context — add the class body, the surrounding file, richer metadata. Here that makes it worse. The purpose signal is already present; enriching the chunk only adds more body tokens to average against it. You cannot fix a drowning by adding water. The problem is the pooling of a long, mixed chunk into one vector — so the fix has to change what gets pooled, not what gets added.
Phrasing Doesn't Rescue It — and the Symbol Index Proves Why
There is an obvious objection here: maybe I just phrased the queries badly. Maybe the right words would have pulled the chunk in. So I ran a 60-query sweep — 10 topics, 6 phrasings each — to give phrasing every chance to matter.
It didn't. Rephrasing shuffled which wrong answers came back; it did not surface the right ones. Ask for a "signal dispatcher implementation" and the top results were 1-to-6-line re-export stubs — the little shim modules that just re-expose a name — while the real Signal class, the thing that actually implements dispatch, was absent from the top three. Across the sweep, conceptual queries kept returning wrong or incomplete top-5 sets no matter how I said them. Phrasing is a knob on the query side. Dilution is a problem on the document side. Turning the query knob cannot un-average a document vector.
Then came the control that settled it. vectr also keeps a deterministic symbol graph: a plain lookup from a name to its definition site, no embeddings involved. For every canonical symbol that semantic search had just missed, the deterministic lookup resolved it exactly and instantly — Signal, BaseCache, Query, SQLCompiler, QuerySet.get, each to the correct file and line.
Same corpus, same index build. The symbol table resolves the target perfectly; semantic search cannot find it. That gap is not the parser's fault, not the chunker's fault, not a missing document. The index and the symbol table were correct. The failure is purely in the embedding and search layer. When two views of the same index disagree this cleanly, the broken one tells you exactly where to look.
One more result, and it is the one that made me stop trusting cold semantic search on its own. I ran a round of "famous symbols" — targets every Django developer knows by heart. Cold semantic search was roughly a coin flip even there. get_object_or_404, QuerySet.get, and reverse were all absent from the top-5; ForeignKey came in at #2 and Paginator at #4, each sitting behind look-alikes with more generic wording. If a system cannot reliably surface get_object_or_404, the failure is not exotic. It is the common case wearing a docstring.
Normalized Scores Lie About Confidence
This one is a short aside, but it burned me while I was debugging the above, so it earns its place. While hunting the dilution bug I ran control queries for concepts that do not exist in Django core — CORS handling, for instance, which Django leaves to middleware and third-party packages. A search for something absent should come back empty, or at least visibly unsure.
It came back with five hits scored between 0.77 and 1.0, looking every bit as confident as a real match. Nothing in the corpus answered the query, and the system reported near-certainty anyway.
The reason is a modeling choice that is easy to make and easy to forget. The displayed score was the reranker's output after per-query normalization — rescaled so the best result of this query becomes ≈1.0. That rescaling throws away the only thing you needed: how good the top match is in absolute terms.
A per-query-normalized score can't tell you "nothing here matches," because it is defined to make the best available result look like a perfect one — even when the best available result is garbage. The number describes rank within the query, not relevance to the world. If you surface it as confidence, your UI will radiate certainty at the exact moment it has found nothing.
The practical rule that fell out of this: never show a per-query-normalized score as if it were confidence. Keep a non-normalized signal alongside it — a raw cosine, or a BM25 floor — so the system retains an honest way to say "nothing here is actually close." Recall failures like the dilution one are already invisible enough; a score that reads 0.99 over an empty result set makes them worse, because it converts a silent miss into a confident wrong answer.
The Fix I Shipped: Dual-Vector Indexing
The measurement points at its own fix. If purpose-only embeddings score +0.06 to +0.10 higher — enough, in the calibrated case, to clear the pool floor — then the answer is not to throw away the body vector. It is to also keep a purpose vector, and let a query match whichever one fits it.
That is dual-vector indexing. At index time, store two vectors per symbol: a purpose vector built from the qualified signature and docstring with the body stripped out, alongside the existing full-body vector. At query time, retrieve over both, and blend or take the max of the two similarities for pool entry. Nothing else in the pipeline changes.
A full-body embedding is like shelving a book by blending every word in it into one average color. Two books with very different covers but similar bulk end up the same muddy shade, and you can't find either by its subject. The purpose vector is the printed spine: title and one-line description, nothing else. You keep the whole book on the shelf — you just also write a legible spine, so someone looking for the subject can find it without reading all 300 pages first. Dual-vector indexing shelves every symbol with both: the full text, and a spine.
What I like about this shape is that it does not privilege one kind of query. Intent-shaped queries — "get a single object from the database" — land on the purpose vector, where the docstring is undiluted. Implementation-detail queries — "where is select_for_update checked" — still land on the body vector, because that string only exists in the body. You are not trading one failure mode for its mirror image; you are giving each query the surface it needs.
Two properties make it safe to apply blindly across a whole corpus:
- Undocumented symbols degrade gracefully. No docstring? The purpose vector is just the qualified signature. It never gets worse than the name itself, and the name is often enough.
- It is a uniform structural transform. The same body-stripping rule applies to every symbol, at index time, with no query-side special-casing — no keyword lists, no "if the query looks conceptual, reroute it." That matters to me specifically, because query-side heuristics are the thing I have spent a long time deleting from this system; they are brittle, they compound, and they never generalize. A transform on the index side has none of those failure modes.
It is not free, though, and I would rather name the costs than let you find them. Storing a second vector per symbol roughly doubles the number of vectors in the index — reason enough to confirm dilution is actually your problem before you spend on it. And it does not stand alone: dual-vector composes with structural ranking signals like symbol importance rather than replacing them. A diluted docstring and an under-weighted call graph are different failure classes; fixing one leaves the other exactly where it was.
The honest sequence, stated plainly: I measured the cause first, then shipped the fix. The spike proved the mechanism — purpose-only embeddings clear the pool floor where full-chunk embeddings do not — and that measurement, not a hunch, is why dual-vector indexing shipped in vectr v1.0.0 on 8 July 2026. If someone tells you a retrieval change "should work," ask them for the cosine table. This one had one before a line of the fix was written.
The fusion bug hiding underneath
While validating the direction I tripped over a second, separate problem worth its own paragraph, because it will bite anyone running hybrid retrieval. Remember that BM25 ranked the target #1 for one phrasing. You would assume a #1 in either leg guarantees pool entry. It did not: the fused final top-60 did not contain the target even though BM25 had ranked it first. The fusion was dense-dominated, and the dense leg's absence outvoted the keyword leg's #1.
Hybrid retrieval is supposed to be a safety net: if one leg misses, the other catches. That promise only holds if your fusion actually lets a strong single-leg result survive. A dense-dominated blend can throw away the exact result BM25 nailed. Before you trust hybrid search, feed it a query where you know one leg ranks the answer #1, and confirm the answer is still in the fused output. Mine wasn't.
The Same Dilution Shows Up Far Beyond Code
I found this in code search, but nothing about the mechanism is about code. Dilution appears in any corpus where a document mixes "what this is for" with "how it works" or with plain boilerplate. The purpose is a small fraction of the tokens; the pooled vector drifts toward the bulk; a query written in terms of purpose lands short.
You have almost certainly hit it without naming it:
- API reference pages where a one-line summary sits on top of exhaustive parameter tables and examples. Search for what the endpoint does and the parameter soup dominates the vector.
- Legal clauses buried inside pages of recitals and boilerplate. The operative sentence is three lines; the surrounding scaffolding is three hundred.
- Product descriptions embedded in spec sheets, where the one line a buyer would search for is outweighed by dimensions, SKUs, and compliance notices.
The two mitigations generalize as cleanly as the problem does. First, embed a purpose or summary field separately from the full text, and retrieve over both — the dual-vector idea, minus the word "symbol." A short, curated summary vector per document is often the single highest-leverage change you can make to recall, precisely because it is immune to dilution by construction. Second, and this is the cheaper habit to build: audit recall at the pool level, not just the final ranking. Most RAG dashboards show you the reranked top-k and nothing else, which means a pool-entry miss is completely invisible on the screen you are staring at. The failure that started this whole post would never have shown up on a top-k view. I only found it because I went looking one layer up.
These deltas came from one general text embedder on one corpus. A code-specialized model would move the numbers; the CoIR benchmark evaluates nine retrieval models across ten code datasets and eight tasks and finds even state-of-the-art systems struggle with code retrieval, which is exactly why the embedder is not a detail. But a better embedder does not repeal dilution — it raises the whole curve, floor included, and a long mixed chunk still averages its purpose away. Dual-vector composes with a better model; it does not compete with one.
What To Actually Do With This
Go back to the opening. A function whose docstring paraphrased the query ranked below two hundred chunks that didn't. You now know the chain underneath that sentence. The docstring's signal was real and present. The thirty lines of body around it pulled the pooled vector away — enough to cost about a tenth of a cosine point. That tenth was the difference between clearing the pool floor and never entering the pool at all — between reaching the reranker and never being considered. The reranker never failed, because the reranker never saw it. And a normalized score would have happily reported confidence over whatever wrong answers did make the pool.
If you build retrieval, here is the short version to carry out of this:
- Measure recall at pool entry, not at the reranked top-k. The reranker can only be as good as its pool. The miss that matters most is the one that never reaches the screen you monitor.
- When recall is bad, test purpose-only against full-chunk. Embed a summary or signature alone, measure its cosine to the query, and compare. A large positive delta names your problem: dilution, not a weak model.
- Index a separate purpose vector, and retrieve over both. Keep the full text for detail queries; add a body-stripped summary vector for intent queries. It is a structural transform on the index, so it needs no query-side heuristics to work.
- Confirm your fusion can't drop a leg's #1, and never surface a per-query-normalized score as confidence. Keep a raw, absolute signal for an honest no-match.
The dual-vector fix shipped in vectr v1.0.0 on the strength of the cosine table above rather than a hunch. The boring query that started this — get a single object from the database — is exactly the case it was built to catch: the method whose docstring says precisely that, given a surface where its own body can no longer outvote it. The signal was never missing. It just needed somewhere to be read on its own.
Links & Further Reading
The instrument, the foundations, and the two papers whose abstracts I checked before citing. Every external claim in this post was confirmed against the source it points to; where a source did not confirm a specific number, that number is not stated here.
- vectr — the semantic code-search and working-memory tool used as the system under test, and the instrument that produced these measurements.
- The Complete Guide to Text Embeddings, Vector Databases & LLMs — the primer this post assumes: tokenization, pooling, cosine similarity, and how a RAG pipeline fits together.