Every "add vector search" proposal comes with the same promise: users phrase questions their own way, keyword search misses, and embeddings fix it. A feature request on Synthadoc made that case for us (issue #411):
Synthadoc uses BM25 exclusively. For wikis under ~200 pages, BM25 is fast and precise; at 500+ pages, paraphrase and concept queries increasingly miss without a semantic layer.
It also asked us to turn embeddings on by default. That's a big change for a pip-installed tool (more on the cost below), so before writing any retrieval code we built a benchmark to answer three questions:
- Does BM25 really get worse at paraphrased questions as a wiki grows?
- Does the vector search we already ship (off by default) fix it?
- If not, what design would and by how much?
- Yes, keyword search degrades with wiki size — but only for paraphrases. On a 2,000-page wiki, BM25 put the right page in the top 5 for 48% of paraphrased questions, down from 71% at 200 pages. Questions that use the page's own words or an exact name stayed at ~99–100% at every size.
- Our existing "vector re-rank" mode would have made search worse overall. It lifted paraphrases (48% → 60%) but wrecked exact-name lookups (99% → 63%), for a net drop from 84% to 74%.
- Fusing the two rankings fixed paraphrases (48% → 69%) and lifted recall overall, but exact-name lookups still slipped to 94%.
- One more rule closed that gap: when BM25 has a clear winner (its best score at least 5% ahead of the next), keep BM25's top hits on top. Final result on 2,000 pages: paraphrases 48% → 68%, exact names 99% → 98%, page's-own-words 100% → 100%, overall 84% → 90% — and the right page reaches the top 20 for 88% of paraphrases instead of 65%.
- Bonus: the benchmark exposed that BM25 rebuilt its index on every query. Caching it made keyword search 18× faster (P95 362 ms → 20 ms) for every user, embeddings or not.
- It held up on data we never tuned on. On two English corpora with human-written queries and human relevance judgements (science claims, medical questions), semantic search was better on every measure. On Chinese it was worse - our embedding model is English-only - so Chinese, Japanese and Korean pages and questions now stay on keyword search, automatically.
-
So it's now on by default for new wikis (
vector = "auto"): semantic search whenever the add-on is installed, which the Docker image is. - And faster: running the query embedding alongside BM25 instead of after it cut semantic search from 166 ms to 62 ms (P95, 2,000 pages).
- The same scale-up broke our knowledge graph. At 5,000 pages the graph view took 34 seconds to appear and then froze the browser. It now opens on a cluster overview in about 6 seconds and runs at a smooth 60 fps.
Background: how Synthadoc finds pages today
Synthadoc is an open-source tool that compiles your sources — PDFs, web pages, notes — into a Markdown wiki (it works nicely as an Obsidian vault), then answers questions from that wiki with citations. Answer quality depends almost entirely on one step: finding the right pages to hand to the LLM.
Today that step is BM25, the classic keyword-ranking function behind most search engines before neural search. It scores pages by how many of the query's words they contain, weighting rare words more. It's fast, needs no model, and is excellent when the user uses the same words as the page.
We also ship an optional [search] vector = true mode. It takes BM25's top 20 results and re-sorts them by embedding similarity - how close the query and the page are in meaning, according to a small embedding model (BAAI/bge-small-en-v1.5, run locally via fastembed).
Keyword vs. vector, in one example
| Query | BM25 | Embeddings |
|---|---|---|
| "ENIAC vacuum tubes" | ✅ exact words on the page | ✅ |
| "the first electronic machine built to compute artillery tables" | ❌ few shared words | ✅ same meaning |
| "Moore School 1946" | ✅ rare exact tokens | ⚠️ names and numbers carry little "meaning" |
Each approach fails where the other is strong. That's the whole story of this post in one table - but we wanted numbers, not intuition.
Why measure first
Turning embeddings on by default isn't free for a pip-installed tool:
-
+~175 MB of dependencies (
onnxruntimealone is 80 MB) and a 65 MB model download on first start — awkward for offline machines and slim Docker images. - An English-only model, in a project that also serves Chinese, Japanese and Korean users.
- A retrieval change touches every answer the product gives.
If the gain is small, or only shows up on wikis far bigger than our users run, that cost isn't worth paying by default.
How we built the benchmark
We wanted something realistic, reproducible, cheap to run, and small enough to keep in the main repo.
A wiki-like corpus. We pinned 1,985 English Wikipedia articles from 24 overlapping computing categories - history of computing, AI, cryptography, databases, networking, and so on - each at a fixed revision ID, so the text never changes under us. Overlap matters: paraphrase misses happen when many similar pages compete. Each article is converted to a Synthadoc wiki page (lead plus sections, capped at 1,500 words, roughly the size of a compiled wiki page).
Three kinds of question per page. For 500 randomly sampled pages, an LLM (Claude Sonnet 5.5) wrote three questions that page answers:
- lexical - uses the page's own key terms;
- paraphrase - the same need, worded by someone who doesn't know the terminology: no words from the title, and at most two of the page's distinctive terms (checked mechanically, not trusted to the LLM);
- identifier - a short lookup of an exact name, acronym, number or date.
That gave 1,430 questions (some paraphrases failed the check twice and were dropped rather than mislabelled).
A human-written cross-check. LLM-written paraphrases might suit embedding models unfairly well, so we also ran 300 real questions from HotpotQA over its ~3,000 Wikipedia paragraphs.
Wiki sizes. We scored the same questions on nested 200, 500, 1,000 and 1,985-page wikis to test the "it only matters past ~200 pages" claim.
Retrieval only, no LLM in the loop. Each setup is the real Synthadoc search code, called the way the query agent calls it. Scoring a run needs no API key and gives the same numbers every time.
Small in git. Only titles + revision IDs, the frozen questions, HotpotQA question IDs and the results are committed — about 0.5 MB. A script downloads and caches the text (both sources are CC BY-SA).
Metrics. Recall@5 - how often the right page is in the top 5 results we hand to the LLM. We also tracked Recall@10/20, MRR and nDCG (how high it ranks) and P95 latency.
Two bugs the benchmark caught in itself
Worth sharing, because both would have silently produced a wrong conclusion:
- Our "distinctive term" check thought "the" was distinctive. The first generation run threw away 74% of paraphrases. The checker reused our stopword list, which only covers question words ("what", "how") - so "the", "and" and "for" counted as giveaway terms. We switched to corpus-wide rarity: a word only counts if it appears on ≤10% of pages. Accepted paraphrases went from 26% to 87%.
-
Two random samples with the same seed picked disjoint pages. Python's
random.sampleandrandom.shufflewalk the same random stream, so our "which pages get questions" sample and our "which pages form the smaller wiki" shuffle, both seeded with 411, never overlapped. Every smaller wiki scored zero questions. Now they use different seeds, with a test that checks the overlap is proportional.
What we found
1. BM25 is near-perfect on its own words, and steadily worse on paraphrases
Recall@5, BM25 only:
| Wiki size | lexical | identifier | paraphrase |
|---|---|---|---|
| 200 pages | 1.000 | 1.000 | 0.711 |
| 500 pages | 1.000 | 1.000 | 0.647 |
| 1,000 pages | 1.000 | 1.000 | 0.573 |
| 1,985 pages | 0.998 | 0.994 | 0.479 |
The issue was right, and now we know by how much: every doubling of the wiki costs roughly 7–8 points of paraphrase recall. Even Recall@20 for paraphrases falls from 0.80 to 0.65 - a third of those questions never get the right page into the candidate list at all.
2. Our existing vector mode helps paraphrases and breaks exact lookups
Recall@5 on the full 1,985-page wiki:
| Question type | BM25 | BM25 + vector re-rank (today's vector = true) |
|---|---|---|
| lexical | 0.998 | 0.986 |
| identifier | 0.994 | 0.625 |
| paraphrase | 0.479 | 0.595 |
| all | 0.840 | 0.742 |
Three reasons, all visible in the code once the benchmark pointed at them:
- It throws away BM25's order. Re-ranking sorts purely by embedding similarity, so a page that matched "Moore School 1946" exactly can drop below pages that are merely about early computers.
- It only embeds each page's first 512 characters. A name or number further down the page is invisible to the embedding.
- It can only reorder what BM25 already found. Paraphrase Recall@20 is 0.646 with or without it - re-ranking can't rescue a page BM25 missed.
On HotpotQA's human-written questions the re-rank looked great (Recall@5 0.717 → 0.842), but HotpotQA's distractor paragraphs were chosen for word overlap with the question - it's built to be hard for keyword search. On our own kind of content, the picture is very different. This is exactly why we benchmark on our own use case.
It's also the slowest setup: every query reloads every stored embedding from SQLite.
3. Fusion: let each method vote
If BM25 and embeddings each fail where the other is strong, don't make one the judge of the other. Our prototype runs both over the whole wiki, takes the top 20 from each, and merges them with reciprocal rank fusion (RRF): every page scores 1 / (60 + rank) in each list it appears in, and the scores are added. No score calibration - a page near the top of either list surfaces, and a page high in both wins.
That fixed paraphrases (Recall@5 0.479 → 0.694) but still cost exact-name lookups (0.994 → 0.936): an exact BM25 hit now shared the vote with pages that were merely similar in meaning.
4. Knowing when to trust the keywords
Rather than guess at weights, we asked the data: when is BM25's top result right? One signal stood out — how far BM25's best score is ahead of its second. When the top score is at least 1.2× the next:
| Question type | how often that happens | BM25's top result is then correct |
|---|---|---|
| identifier | 88% | 97% |
| lexical | 86% | 100% |
| paraphrase | 12% | 84% |
A dominant keyword match almost always means an exact name or term hit, and it rarely happens for paraphrases. So the final rule is simple: when BM25's best score is at least 1.05× its second, keep BM25's top 3 on top; otherwise let RRF decide. (We also tried simply weighting BM25's vote higher. It backfired: BM25's entries then always outrank the embedding ones, and fusion collapses back into keyword search.)
Recall@5 on the full 1,985-page wiki:
| Question type | BM25 | Vector re-rank | Plain fusion | Fusion + keyword pin |
|---|---|---|---|---|
| lexical | 0.998 | 0.986 | 0.994 | 0.998 |
| identifier | 0.994 | 0.625 | 0.936 | 0.984 |
| paraphrase | 0.479 | 0.595 | 0.694 | 0.676 |
| all | 0.840 | 0.742 | 0.883 | 0.896 |
| MRR (all) | 0.791 | 0.618 | 0.718 | 0.828 |
It's the only setup that beats BM25 on every overall measure, including how high the right page ranks (MRR). And the part re-ranking could never do - paraphrase recall within the top 20, i.e. whether the right page reaches the LLM's candidate list at all:
| Wiki size | BM25 | Vector re-rank | Fusion + keyword pin |
|---|---|---|---|
| 200 pages | 0.800 | 0.800 | 0.956 |
| 500 pages | 0.765 | 0.765 | 0.966 |
| 1,000 pages | 0.720 | 0.720 | 0.920 |
| 1,985 pages | 0.646 | 0.646 | 0.875 |
The gain grows with the wiki — paraphrase Recall@5 improves by 9 points at 200 pages, 11 at 500, 14 at 1,000 and 20 at 1,985 — which is exactly the shape the original issue predicted. On HotpotQA's human-written questions, fusion scores 0.815 at top 5 (BM25: 0.717) and 0.970 at top 20 (BM25: 0.920).
The handful of exact-name questions it still misses are mostly ambiguous labels: "A Mathematical Theory of Communication 1948" is cited on several pages about information theory, but only one is labelled correct.
5. The bug we found in plain sight: BM25 rebuilt its index on every query
BM25 latency grew from 77 ms at 200 pages to 362 ms at 1,985. Profiling one query on the full wiki:
| Step | Time |
|---|---|
| Build the BM25 index | 356 ms |
| Score the query | 3 ms |
Synthadoc cached the tokenised pages but rebuilt the index on every search. Caching the built index (per search scope, cleared whenever a page changes) took BM25's P95 from 362 ms to 20 ms at 1,985 pages with identical results - a speed-up every user gets, whether or not they turn on embeddings.
Fusion's P95 was 122–166 ms, and profiling showed why: the search ran BM25, then embedded the query, so the two costs added up. Embedding the query is about 20 ms on a laptop CPU; comparing it with every page embedding in memory takes under 2 ms. Both steps are independent, and the embedding model releases Python's GIL while it runs, so we now start the query embedding in a worker thread before BM25 runs. Same results, and P95 at 1,985 pages dropped from 166 ms to 62 ms (BM25 alone: 15 ms). We also keep the last 256 query embeddings - an embedding depends only on the text and the model, so a cache entry can never go stale. Either way, that is well under 1% of an answer that spends seconds in the LLM.
6. The "better" embeddings that made things worse
Our prototype embedded only each page's title, tags and first 512 characters, so the obvious next step was section-level embeddings: one per heading, so content deep in a long page is searchable. On its own, the embedding search got clearly better - paraphrase Recall@5 0.616 → 0.669, and exact-name lookups 0.305 → 0.659.
Fused with BM25, it got worse:
| Recall@5, full wiki | One embedding per page | One per section |
|---|---|---|
| paraphrase | 0.676 | 0.613 |
| all | 0.896 | 0.878 |
| time to embed the wiki | 3 minutes | 82 minutes |
The more precise section embeddings mostly found the pages BM25 already found. Fusion gains from the two methods disagreeing - each catching what the other misses — and the coarser page embeddings disagreed with BM25 in more useful places. Twenty times the compute for a worse result was an easy call: we ship one embedding per page.
7. Checking it on data we didn't tune on
Every threshold above - RRF's k, the 1.05× pin, the top 3 - was chosen on our Wikipedia corpus. A fair test needs data the design has never seen, with questions people wrote and relevance people judged. We added three public retrieval sets:
- SciFact - 5,183 scientific abstracts, 300 claims written by experts;
- NFCorpus - 3,633 medical documents, 323 human queries (~38 relevant documents each);
- DuRetrieval - 3,000 Chinese web passages, 300 human queries. Not because we expected it to work, but because "on by default" would include every Chinese, Japanese and Korean user of our Docker image.
| Corpus | BM25 nDCG@10 | Semantic nDCG@10 |
|---|---|---|
| HotpotQA (human questions) | 0.755 | 0.808 |
| SciFact (science) | 0.658 | 0.693 |
| NFCorpus (medical) | 0.305 | 0.344 |
| DuRetrieval (Chinese), first try | 0.853 | 0.780 |
English held up everywhere. Chinese didn't: recall@5 fell from 0.751 to 0.645. That is what an English-only model does with Chinese text - it turns both the question and the pages into embeddings that mostly reflect noise, and fusion dutifully mixes that noise into the results.
The fix didn't need a new model, just two rules applied automatically:
- Per page: a page whose text is mostly Chinese, Japanese or Korean (CJK characters at least 30% of its letters) is not embedded. It stays fully searchable by keyword - BM25 handles Chinese well.
- Per question: a question containing CJK characters is answered by BM25 alone.
A mixed wiki gets semantic search for its English pages and keyword search for the rest. With the rules in place, DuRetrieval scores exactly what BM25 scores, and every English result is unchanged:
A multilingual model could turn that Chinese tie into a win; that's a separate experiment, with its own benchmark run.
Then a hand-built test wiki caught one more problem. We made a small mixed wiki - six pages in Chinese (Turing, the transistor, the von Neumann architecture, the origins of the internet, Chinese character input, China's first computers) and three in English (Grace Hopper, Unix, the Linux kernel) - and asked it questions through the real query path. Startup logged exactly what we wanted:
Vector search: embedding 3 page(s)… (6 mostly Chinese/Japanese/Korean page(s) left to keyword search)
But every Chinese question came back with the English pages. The query agent translates CJK questions into English before searching - a feature built for English wikis asked in Chinese - and it searched only the translation. "Who invented the transistor" can't match a page written in Chinese, so the right page never reached the LLM. The benchmark missed this because it calls search directly, without the agent's translation step.
The fix: search the question as written and its translation, and merge the results. Now each language finds its own pages:
| Question | Page found | How |
|---|---|---|
| 谁发明了晶体管? (Who invented the transistor?) | 晶体管 | keyword search on the Chinese question |
| 五笔字型输入法是谁发明的? (Who invented the Wubi input method?) | 汉字信息处理 | keyword search on the Chinese question |
| 谁发明了第一个编译器? (Who invented the first compiler?) | Grace Hopper | the English translation |
| the woman who found a moth in a machine | Grace Hopper | semantic search |
| Who invented the transistor? | — | not found: English words can't match Chinese text, and Chinese pages have no embeddings |
The last row is the honest limit of an English-only model, and the best case for trying a multilingual one.
Vector vs. our current approach - what changes
| BM25 only (today's default) | Vector re-rank (today's opt-in) | Fusion + keyword pin | |
|---|---|---|---|
| Finds pages by | shared words | shared words, then sorted by meaning | shared words or meaning |
| Paraphrased questions | degrades with size | better order, same misses | 0.48 → 0.68 top-5, 0.65 → 0.88 top-20 |
| Exact names / IDs | excellent | much worse | unchanged in practice (0.994 → 0.984) |
| Overall top-5 recall | 0.840 | 0.742 | 0.896 |
| Extra install | none | fastembed + model | fastembed + model |
The benefit for users is simple: ask in your own words and still get the page that answers you, without losing the precision you have today when you search for a name, a product code or an acronym.
What we shipped (in v1.5.0 release)
Everything above now runs in Synthadoc's real search path, and the benchmark measures the production code, not a prototype - it reproduces the prototype's numbers exactly:
- Fusion with the keyword pin for queries, MCP search and cross-wiki retrieval. Ingest's "which page does this belong to?" step stays on BM25 for now: changing it changes what ingest writes, which this benchmark doesn't measure.
- Embeddings held in memory as one matrix, not reloaded from SQLite per query.
- Fresh embeddings - each page's embedding carries a hash of its text; a background sync re-embeds pages that changed and drops pages that were archived, at startup, after ingest, and after any edit. Until the first sync finishes, search simply stays on BM25.
- Honest gap detection - "the wiki doesn't cover this" is still judged on keyword evidence only, so a page the embeddings merely think is related can't hide a real gap.
- Cross-wiki ranking by rank, not raw score - results from several wikis are interleaved by each wiki's own ranking, because BM25 scores were never comparable between wikis.
- BM25 18× faster for everyone, embeddings or not.
- A Docker image with the model built in, so containers start without a download and work offline.
- Chinese, Japanese and Korean left to keyword search - per page and per question, as above - and a translated CJK question is searched as written too, so pages in either language are found. Nothing to configure.
- Query embedding alongside BM25, plus a small cache of recent query embeddings: 166 ms → 62 ms P95 at 2,000 pages, identical results.
- On by default for new wikis - see below.
Meanwhile, in the knowledge graph
Search wasn't the only thing that had only ever met small wikis. Synthadoc's web UI has a knowledge graph view: every page is a dot, links between pages are lines, and pages are coloured by topic cluster (found with the Louvain community-detection
algorithm). With the benchmark wiki at hand we opened it on bigger wikis, then timed it in a headless browser on generated wikis of up to 5,000 pages.
| Pages | Graph appears | Frame rate | Longest freeze |
|---|---|---|---|
| 500 | 1.7 s | 40 fps | 0.1 s |
| 1,000 | 5.2 s | 12 fps | 0.5 s |
| 2,000 | 10.1 s | 2 fps | 1.5 s |
| 5,000 | 34.2 s | 0 fps | 7.6 s |
Before: all 5,000 pages and 100,801 links drawn at once. Once the layout settles, after about half a minute of a frozen browser, the clusters show as colour clumps. But none of them has a name, the legend just says "Cluster 0" to "Cluster 23", and the individual pages are too small to pick out.
At 5,000 pages the browser had to animate about 115,000 SVG elements, roughly 100,000 of them links. The clumps are pretty, but they don't tell you what the wiki is about. The server was slow too: building the graph response read and parsed every page's YAML front matter with the pure-Python parser.
The fix is the same idea as fusion: show the reader what they can actually use.
- Cluster overview first. Past 300 pages, the graph opens on one bubble per topic cluster. Each bubble is sized by page count and named after its most-connected pages, joined by the strongest links between clusters. Click a bubble to see its pages, with each page's strongest few links; one click takes you back. Smaller wikis look exactly as before.
- A faster server. Front matter is parsed with libyaml's C loader when it's available (about 7× faster, with identical output); links between pages that share a source are found through an index instead of comparing every pair; and whole-wiki scans run off the event loop, so they don't stall other requests.
- Stable colours. Louvain now uses a fixed seed, so clusters keep their colours from one lint run to the next.
| Pages | Graph appears | Frame rate | Longest freeze |
|---|---|---|---|
| 500 | 0.8 s | 60 fps | none |
| 1,000 | 1.3 s | 60 fps | none |
| 2,000 | 2.8 s | 60 fps | none |
| 5,000 | ~6 s | 60 fps | none |
Opening the largest cluster (289 pages) on the 5,000-page wiki takes about 2 seconds, also at 60 fps.
After: the same 5,000 pages as 24 topic clusters. A bubble's size and the number after its name give the cluster's page count. Thicker lines mean more links between two clusters. The list below the graph names every cluster, largest first, and clicking a name opens that cluster. The overview draws about 100 shapes instead of 115,000.
Inside one cluster: 268 pages, drawn with each page's strongest 605 links rather than every link. Clicking a page opens the usual side panel with its type, its total connections (41 here, links to other clusters included) and suggested questions to ask the wiki. "← All clusters" goes back.
The faster YAML parsing helps more than the graph: every feature that reads pages gets it, search included.
On by default — for new wikis
Before starting we set a bar for making semantic search the default:
paraphrase Recall@5 up by at least 15 points, lexical and identifier down by no more than 1, latency within our existing targets. The shipped build clears it on the full wiki: +19.7 for paraphrases, 0 for lexical, −1.0 for identifiers, 62 ms P95. Identifiers sat exactly on the line, which is why we waited for data the design had never seen. The independent check settled it: better on both untuned English corpora, and — once CJK text is left to BM25 - never worse on Chinese.
So new wikis are now created with vector = "auto":
| Installed with | What new wikis get |
|---|---|
pip install synthadoc |
Keyword search, as before — small install, no model |
pip install "synthadoc[vectors]" |
Semantic search |
| The Docker image | Semantic search (the model is built in) |
Existing wikis keep the vector = false already in their config; we never rewrite a user's settings. Changing it to "auto" or true turns semantic search on.
Caveats
- Questions are generated, and each has one labelled page; another page might also answer it. That lowers every setup's scores equally.
- The fusion thresholds were chosen on the Wikipedia corpus; SciFact, NFCorpus and DuRetrieval are the independent check. The DuRetrieval corpus is a 3,000-passage slice, so each query's distractors are other queries' answers.
- We skip the query agent's LLM step that splits complex questions into sub-questions, so absolute numbers are a lower bound; comparisons between setups still hold.
- One embedding model, English only. Only Chinese was measured among CJK languages; Japanese and Korean are handled the same way, unmeasured. A multilingual model is a follow-up.
- The graph timings use generated wikis: the benchmark's articles, repeated past 1,985 pages, with random links inside each category. Real wikis have more structure, so their clusters usually look cleaner than ours.
Try it yourself
The benchmark lives in the repo under
benchmarks/retrieval/:
git clone https://github.com/axoviq-ai/synthadoc.git && cd synthadoc
pip install -e ".[vectors]"
python -m benchmarks.retrieval fetch # once: download + build the wikis (~30 min)
python -m benchmarks.retrieval evaluate --configs bm25,hybrid
python -m benchmarks.retrieval evaluate --corpora scifact,nfcorpus,duretrieval --configs bm25,hybrid
python -m benchmarks.retrieval charts # redraw the charts in this post
No API key needed: scoring runs only the search code, and gives the same numbers every time.
If you run a search or RAG feature, our one piece of advice: benchmark
retrieval on your own kind of content, split by question type. An average would have told us vector re-ranking was a small loss. The per-type split told us why — and what to build instead.
Acknowledgments
A special thank you to [Chaire Zhang] (@clairezhang-228) for her invaluable contributions to this post. Her insights on rigorous work on the benchmark validations made this piece possible.








Top comments (22)
exact name lookups dropping from 99% to 63% under naive vector reranking is the number that should stop most add vector search proposals. we saw the same shape on a docs search, and keeping the keyword winner on top when its lead was clear fixed identifiers without losing the paraphrase gains. our mistake was tuning fusion weights on our own test queries, so offline numbers beat real traffic. how did you pick the 5% lead threshold, tuned on the benchmark or checked on held out queries?
The decision was made to choose 1.05× from our Wikipedia corpus by asking when BM25's top hit makes sense. An exact name is always implied by a clear lead. Although the results are nearly identical, it is not a knife-edge: anything from 1.0 to 1.1 gives nearly identical results in this case.
We then examined three hold-out sets using human-written queries and judgements. According to SciFact and NFCorpus, fusion with the pin is superior to plain fusion. On HotpotQA, plain fusion without the pin performed best (nDCG@10 0.830 vs 0.822). Since HotpotQA's distractors were selected based on word overlap with the question, this is not surprising. This is exactly what makes a keyword winner untrustworthy. So the pin is a net win for our kind of content, not universal.
Real-traffic is our gap as well: all of this is offline, based on queries that are curated or generated. Logging a sample of real questions from users' wikis and adding them to the held-out set is the next step I'd like to take before tuning anything further.
fair, that 0.830 vs 0.822 on HotpotQA is the useful part, since a pin that loses on one set and wins on two is honest evidence instead of a clean story. on the real traffic gap, we sampled about 200 live queries a week and hand labeled only the ones where keyword and vector disagreed on the top hit. that kept labeling small and surfaced the identifier misses fast. are you planning to log just the disagreement cases, or a random sample of everything?
Disagreement cases first. That is a good tip, and it describes how the pin works: only the top hit changes the result. There is no action taken by the pin when BM25 and vector search agree on the top hit.
I would add a small random sample to the top, however. There are instances in which disagreements cannot capture the cases where both methods agree on the same wrong page, and these are the cases in which offline numbers are most effective in hiding.
In contrast to your setup, Synthadoc runs locally on each user's own wiki, so we never see their traffic. The logging of disagreement cases must be opt-in and remain on the user's computer as a local file. Instead of us labeling the pages, the labels would be derived from what users already do in the app, such as which cited pages they open. The held-out set could then be grown by sharing anonymised (question, top hits) pairs. None of this is built yet. Your 200 disagreements per week are a useful benchmark for determining the size of the labeling effort.
the random sample of agreed hits is the right call imo. when retrievers agree on a wrong page nothing flags it, so those are the ones that sit in a dashboard looking healthy for months.
for the local only logging, a cited page the user opens and then immediately backs out of is a decent free negative label, no server traffic needed. would a plain exported file the user can choose to share be enough to grow the held out set, or is that too much friction?
Peter, this is a really thorough piece of work. By running retrieval-only (without LLM in the loop), the results are truly reproducible, and catching the stopword bug and the overlapping random seeds before trusting the numbers demonstrates the right approach.
In the keyword-pin strategy, you let BM25 remain authoritative when it is confident, rather than blending everything into a mushy RRF score. I think that is a much cleaner model.
As I read, I kept thinking about one question: in the case of CJK, you're routing to BM25-only because the embedding model is English-only, and DuRetrieval confirmed that it harms rather than helps. Have you tried a multilingual embedding model (e.g., multilingual-e5-small or paraphrase-multilingual-mpnet-base-v2) to see whether hybrid search becomes viable for CJK, or whether BM25 stays competitive even with a proper cross-lingual model? For a mixed-language wiki, I'd be curious if semantic matching ever outweighs latency costs. I think the team could try these things later in the project.
Thanks for pointing out the retrieval-only setup and the keyword pin. We have not yet tried a multilingual model; it is a logical next step. On DuRetrieval, it brought semantic search back to exactly BM25's numbers, with no regression. A multilingual model is the only way to turn that tie into a win.
The good news is that the experiment is cheap now. DuRetrieval is already in the benchmark, so trying a model like multilingual-e5-small or a multilingual MiniLM is mostly a configuration change plus one embedded run. The questions we would want it to answer are:
The 1.05x clear-winner rule is a neat fix, and I like that you showed why simply weighting BM25 higher collapsed fusion back into keyword search before landing on it.
One query shape I'd want in the benchmark before trusting the rule on real wikis is the mixed one, where someone uses an exact name but asks a paraphrased question about it, like "what did we end up deciding about Postgres for the billing thing". In a notes wiki that name shows up on lots of pages, so BM25 can have a clear winner that is just the page mentioning Postgres most often, the rule pins it into the top 3, and the page that actually holds the decision never gets its vote from the embedding side. When I logged real questions against a notes wiki, that shape was more common than pure paraphrases or pure lookups.
The other thing I noticed is that generated paraphrases tend to be cleaner than what people type. Real questions are shorter, half remembered, and often borrow a word from a neighbouring page rather than using no page words at all, which is harder for BM25 than "no words from the title". Mixing a small set of questions collected from actual use into the held out set moved my rankings more than tuning thresholds did.
Did you check how often the clear-winner rule fires on paraphrase questions that happen to contain a name, and whether it helps or hurts there?
Thanks, this is exactly the case I'd worry about too, and the answer is: not segmented that way. We measured how often the clear-winner signal fires on paraphrases as a whole (at a 1.2× margin it fired on 12% of them, and BM25's top hit was right 84% of the time), but we never split out paraphrases that contain a name. The cost you describe does show up in the aggregate, though: the pin dropped paraphrase recall@5 from 0.694 (plain fusion) to 0.676, while recovering exact-name lookups from 0.936 to 0.984. Your Postgres example is a plausible explanation for some of those 1.8 points.
Two things soften it in practice. The pin only fixes BM25's top 3, so the embedding side's best page can still land on 4 or 5. And the query agent hands over up to 20 pages to the LLM, where the paraphrase recall@20 is 0.875. So the decision page usually still reaches the model; it's ranked lower than it should be.
The point you raise about generated paraphrases being too clean is fair, and we have included it in our caveats. As human-written checks, we used HotpotQA and two BEIR sets, but neither had your "name + half-remembered question" shape. Adding a mixed query type and a small set of real logged questions would be a good next step for the benchmark. Thanks for the push.
Benchmarking before turning embeddings on by default is the part I would point other teams to. The re-rank result is the warning: paraphrase hits went up while exact name lookups fell from 99% to 63%, which would have made search worse for most people while the feature request looked like an obvious win.
The clear winner rule is a sensible way to let BM25 stay in charge when it is confident. Edward Izgorodin's catch on the threshold mismatch shows why the measurement and the shipped rule need to use the same setting, or the numbers describe a system nobody runs.
Paul Irolla's question about pages sent per answer is the one a budget owner would ask. If semantic results let the agent read fewer pages for the same recall, that saving is worth measuring next to answer quality.
The 18x speedup from caching the index is a good reminder that a careful benchmark often pays for itself through the problems it finds along the way.
Thanks, and agreed on all four points.
Edward's catch turned out to matter. Measured at the 1.05× margin we actually ship, BM25's second and third pinned results were almost never the right page. So the next release pins only the top hit: paraphrase recall@5 goes from 68% to 70%, held-out corpora improve slightly, and exact names stay at 98%. We'll add an update to the post when it ships.
On pages per answer, the benchmark already hints at the saving: semantic search's top 5 finds the right page about as often as keyword search's top 20 (0.90 vs 0.89 recall). Whether the agent can read a quarter of the pages without hurting answers is the next thing to measure, with answer quality alongside retrieval, as you say.
The table in section 4 that makes the case for the keyword pin is measured at a different threshold from the one the rule uses. It reports how often BM25's top result is right when its best score is at least 1.2 times the second, while the rule keeps BM25's top three on top from 1.05 times. Between 1.05 and 1.2 the pin fires on more questions than that table describes, and the second and third pinned pages have no precision figure at all. The cost shows in your own results: paraphrase Recall@5 is 0.694 with plain fusion and 0.676 with the pin, so on paraphrases, the class the whole change was built for, the pin gives back a little under two points. Reporting how often the 1.05 rule fires per question type, and how often the second and third pinned pages are the labelled one, would show whether pinning only the top hit keeps the identifier gain at a smaller paraphrase cost.
The second place worth a number is gap detection. Whether the wiki covers a question is still judged on keyword evidence alone, which stops a merely similar page from hiding a real gap, but paraphrased questions are exactly the ones with little keyword evidence. A question that fusion now answers from the right page can still be flagged as a gap. Counting, on the paraphrase set, how often the labelled page is in the top 5 while the gap check fires would put a figure on that trade, and paraphrases are the class the original issue was about.
They are both fair, and I have now measured both on the same set of 1,985 pages.
The pin at 1.05× fires far more than the 1.2× table suggests: on 96% of own-word questions, 98% of exact-name ones, and 53% of paraphrases. Those paraphrases from BM25's top hit are right only 45% of the time. Pinned ranks 2 and 3 are almost never on the labelled page (≤ 6%), so pinning them buys little. Your proposal checks out: pinning only the top hit gives paraphrase recall@5 0.699 (vs 0.676 shipped, 0.694 plain fusion) and 0.901 overall (vs 0.896). The catch is the exact names: 0.978 vs 0.984, which takes them 1.6 points under BM25 and past the bar we set. So it's a trade, and a better one than I'd assumed. Next I want to try pinning the top hit at 1.05× and the top three only at a higher margin.
Gap detection: When semantic search finds the right paraphrase page in the top 5 (292 of 432), the gap check still fires on 26 of them, 8.9% (keyword search: 11 of 207, 5.3%). One caveat: the benchmark runs the check on the raw question, while the product runs it on the LLM's rewritten sub-questions, so treat it as indicative. Still, it's a real cost for exactly the class this was built for. Thanks for pushing on both.
The benchmark called search directly, so it never saw the query agent searching only the English translation of each CJK question. I run my retrieval evals that same direct way because it's fast and gives the same numbers every run. Are you adding a few questions that go through the full query path, or does the hand-built mixed wiki stay as that check?
This is the main lesson we learned from that bug. The direct-call benchmark is still the right tool for comparing ranking setups, for exactly the reasons you give: it's fast and deterministic. The query agent, however, is not able to see anything that the query agent does before searching.
Today the end-to-end check is a unit test rather than a benchmark. A mixed Chinese/English wiki is used for the agent's real search step, with real BM25, and only the LLM calls (translation and question splitting) are stubbed with fixed answers. This keeps the behavior deterministic, and it fails on the old translation-only behavior. As a result of the hand-built wiki, the bug was discovered manually.
What I'd like next is a benchmark mode that runs queries through that same agent path, replaying recorded translations and decompositions. That way the numbers stay reproducible but include everything the agent does to a question. Haven't built it yet. How do you handle LLM-side query rewriting in your evals?
For a wiki, the bill is usually the pages sent per answer, not the search itself. Did the vector run cut the pages sent per question, or just the misses?
Fair points. Misses are what we measure, not costs. It is the same number of candidate pages sent either way (up to 20, then trimmed to a context budget), so semantic search only changes which pages reach the LLM, not how many.
However, your question points to a potential savings we have not yet realized. Semantic search's top 5 (recall 0.896) matches keyword search's top 20 (0.892) on the 1,985-page benchmark. This implies the same coverage with a quarter of the pages per answer. In terms of pool size, we have not changed it, as it is a matter of balancing quality and cost. The end-to-end quality of the answer would need to be measured, not just the retrieval of the answer. It is a good next experiment, and probably the biggest win for most users than the actual misses.
The HotpotQA result being flipped from the other two is a good example of why you need diverse test data. One dataset's assumptions can poison the whole picture.
Thank you, Richard. That is exactly what occurred. In HotpotQA, distractor paragraphs are selected because they share words with the question, so the dataset is designed to make keyword searches difficult. It can be used with any semantic method. The re-rank on HotpotQA increased from 0.717 to 0.842 Recall@5, but on our own wiki-style content, it was not able to recover a single page that BM25 had missed (paraphrase Recall@20 remained at 0.646). We would have shipped the weaker design if HotpotQA had been our only benchmark.
Because of this, the benchmark is built using our own type of content: Wikipedia pages with paraphrased and exact-name questions, at a variety of wiki sizes. Public corpora (HotpotQA, SciFact, NFCorpus, DuRetrieval) serve as cross-checks rather than as deciding factors. Also, the cross-checks were useful: DuRetrieval was the one that demonstrated that English-only embedding made Chinese search worse, which is why CJK queries now only use keyword search.
That is exactly right, and that is why it was not shipped. Reranking sorts BM25's candidates purely by meaning, so a page that matches "Moore School 1946" exactly can drop below pages that are solely concerned with early computers. That is the 99% x 63% you quoted.
Instead, What shipped runs both searches and merges their rankings, keeping BM25's top hit on top whenever there is a clear winner (best score ≥ 1.05× the second). The most popular keyword is almost always the exact name, so exact-name lookups remain at 98%, while paraphrases remain at 48% to 68%. With the keyword search, people who are searching for an exact table or page name are able to find what they are looking for.
@peter-white-1492 Fair, that answers it: the same number of pages go out, only which ones change. Our 0.896 vs 0.892 on the 1,985-page benchmark is retrieval recall, though, not answer quality, so the quarter-of-pages saving stays a hypothesis until the answers are measured.