<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Peter W.</title>
    <description>The latest articles on DEV Community by Peter W. (@peter-white-1492).</description>
    <link>https://dev.to/peter-white-1492</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3900603%2F0cfceee7-8b24-4f9e-8d9c-136a7e08ee72.jpeg</url>
      <title>DEV Community: Peter W.</title>
      <link>https://dev.to/peter-white-1492</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/peter-white-1492"/>
    <language>en</language>
    <item>
      <title>Should our LLM wiki use vector search? We benchmarked it before deciding</title>
      <dc:creator>Peter W.</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:43:24 +0000</pubDate>
      <link>https://dev.to/peter-white-1492/should-our-llm-wiki-use-vector-search-we-benchmarked-it-before-deciding-fkf</link>
      <guid>https://dev.to/peter-white-1492/should-our-llm-wiki-use-vector-search-we-benchmarked-it-before-deciding-fkf</guid>
      <description>&lt;p&gt;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 &lt;a href="https://github.com/axoviq-ai/synthadoc" rel="noopener noreferrer"&gt;Synthadoc&lt;/a&gt; made that case for us (&lt;a href="https://github.com/axoviq-ai/synthadoc/issues/411" rel="noopener noreferrer"&gt;issue #411&lt;/a&gt;):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It also asked us to turn embeddings on &lt;strong&gt;by default&lt;/strong&gt;. 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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does BM25 really get worse at paraphrased questions as a wiki grows?&lt;/li&gt;
&lt;li&gt;Does the vector search we &lt;em&gt;already ship&lt;/em&gt; (off by default) fix it?&lt;/li&gt;
&lt;li&gt;If not, what design would and by how much?&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes, keyword search degrades with wiki size — but only for paraphrases.&lt;/strong&gt;
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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our existing "vector re-rank" mode would have made search worse overall.&lt;/strong&gt; It lifted paraphrases (48% → 60%) but wrecked exact-name lookups (99% → 63%), for a net drop from 84% to 74%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fusing the two rankings fixed paraphrases&lt;/strong&gt; (48% → 69%) and lifted recall overall, but exact-name lookups still slipped to 94%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One more rule closed that gap:&lt;/strong&gt; 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 &lt;strong&gt;48% → 68%&lt;/strong&gt;, exact names 99% → 98%, page's-own-words 100% → 100%, overall &lt;strong&gt;84% → 90%&lt;/strong&gt; — and the right page reaches the top 20 for 88% of paraphrases instead of 65%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonus:&lt;/strong&gt; the benchmark exposed that BM25 rebuilt its index on every query. Caching it made keyword search &lt;strong&gt;18× faster&lt;/strong&gt; (P95 362 ms → 20 ms) for every user, embeddings or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It held up on data we never tuned on.&lt;/strong&gt; 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 &lt;strong&gt;worse&lt;/strong&gt; - our embedding model is English-only - so Chinese, Japanese and Korean pages and questions now stay on keyword search, automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;So it's now on by default for new wikis&lt;/strong&gt; (&lt;code&gt;vector = "auto"&lt;/code&gt;): semantic search whenever the add-on is installed, which the Docker image is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And faster:&lt;/strong&gt; running the query embedding &lt;em&gt;alongside&lt;/em&gt; BM25 instead of after it cut semantic search from 166 ms to 62 ms (P95, 2,000 pages).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same scale-up broke our knowledge graph.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Background: how Synthadoc finds pages today
&lt;/h2&gt;

&lt;p&gt;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: &lt;strong&gt;finding the right pages&lt;/strong&gt; to hand to the LLM.&lt;/p&gt;

&lt;p&gt;Today that step is &lt;a href="https://en.wikipedia.org/wiki/Okapi_BM25" rel="noopener noreferrer"&gt;BM25&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;We also ship an optional &lt;code&gt;[search] vector = true&lt;/code&gt; mode. It takes BM25's top 20 results and re-sorts them by &lt;strong&gt;embedding similarity&lt;/strong&gt; - how close the query and the page are in meaning, according to a small embedding model (&lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt;, run locally via &lt;a href="https://github.com/qdrant/fastembed" rel="noopener noreferrer"&gt;fastembed&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Keyword vs. vector, in one example
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;BM25&lt;/th&gt;
&lt;th&gt;Embeddings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"ENIAC vacuum tubes"&lt;/td&gt;
&lt;td&gt;✅ exact words on the page&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"the first electronic machine built to compute artillery tables"&lt;/td&gt;
&lt;td&gt;❌ few shared words&lt;/td&gt;
&lt;td&gt;✅ same meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Moore School 1946"&lt;/td&gt;
&lt;td&gt;✅ rare exact tokens&lt;/td&gt;
&lt;td&gt;⚠️ names and numbers carry little "meaning"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why measure first
&lt;/h2&gt;

&lt;p&gt;Turning embeddings on by default isn't free for a pip-installed tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;+~175 MB of dependencies&lt;/strong&gt; (&lt;code&gt;onnxruntime&lt;/code&gt; alone is 80 MB) and a &lt;strong&gt;65 MB model download&lt;/strong&gt; on first start — awkward for offline machines and slim Docker images.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;English-only&lt;/strong&gt; model, in a project that also serves Chinese, Japanese and Korean users.&lt;/li&gt;
&lt;li&gt;A retrieval change touches every answer the product gives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we built the benchmark
&lt;/h2&gt;

&lt;p&gt;We wanted something &lt;strong&gt;realistic, reproducible, cheap to run, and small enough to keep in the main repo&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A wiki-like corpus.&lt;/strong&gt; 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 &lt;strong&gt;fixed revision ID&lt;/strong&gt;, 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).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three kinds of question per page.&lt;/strong&gt; For 500 randomly sampled pages, an LLM (Claude Sonnet 5.5) wrote three questions that page answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;lexical&lt;/strong&gt; - uses the page's own key terms;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paraphrase&lt;/strong&gt; - 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);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;identifier&lt;/strong&gt; - a short lookup of an exact name, acronym, number or date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gave 1,430 questions (some paraphrases failed the check twice and were dropped rather than mislabelled).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A human-written cross-check.&lt;/strong&gt; LLM-written paraphrases might suit embedding models unfairly well, so we also ran 300 real questions from &lt;a href="https://hotpotqa.github.io/" rel="noopener noreferrer"&gt;HotpotQA&lt;/a&gt; over its ~3,000 Wikipedia paragraphs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wiki sizes.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval only, no LLM in the loop.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small in git.&lt;/strong&gt; Only titles + revision IDs, the frozen questions, HotpotQA question IDs and the results are committed — about &lt;strong&gt;0.5 MB&lt;/strong&gt;. A script downloads and caches the text (both sources are CC BY-SA).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metrics.&lt;/strong&gt; &lt;em&gt;Recall@5&lt;/em&gt; - 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two bugs the benchmark caught in itself
&lt;/h3&gt;

&lt;p&gt;Worth sharing, because both would have silently produced a wrong conclusion:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Our "distinctive term" check thought "the" was distinctive.&lt;/strong&gt; 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%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two random samples with the same seed picked disjoint pages.&lt;/strong&gt; Python's &lt;code&gt;random.sample&lt;/code&gt; and &lt;code&gt;random.shuffle&lt;/code&gt; walk 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 &lt;strong&gt;zero&lt;/strong&gt; questions. Now they use different seeds, with a test that checks the overlap is proportional.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What we found
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. BM25 is near-perfect on its own words, and steadily worse on paraphrases
&lt;/h3&gt;

&lt;p&gt;Recall@5, BM25 only:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wiki size&lt;/th&gt;
&lt;th&gt;lexical&lt;/th&gt;
&lt;th&gt;identifier&lt;/th&gt;
&lt;th&gt;paraphrase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200 pages&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.711&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 pages&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.647&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000 pages&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.573&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,985 pages&lt;/td&gt;
&lt;td&gt;0.998&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.479&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr0mibg99mc10rnhtfuju.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr0mibg99mc10rnhtfuju.png" alt="Paraphrased-question recall@5 by wiki size, keyword vs semantic search" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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@&lt;strong&gt;20&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Our existing vector mode helps paraphrases and breaks exact lookups
&lt;/h3&gt;

&lt;p&gt;Recall@5 on the full 1,985-page wiki:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question type&lt;/th&gt;
&lt;th&gt;BM25&lt;/th&gt;
&lt;th&gt;BM25 + vector re-rank (today's &lt;code&gt;vector = true&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lexical&lt;/td&gt;
&lt;td&gt;0.998&lt;/td&gt;
&lt;td&gt;0.986&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;identifier&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.625&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase&lt;/td&gt;
&lt;td&gt;0.479&lt;/td&gt;
&lt;td&gt;0.595&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;all&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.840&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.742&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three reasons, all visible in the code once the benchmark pointed at them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It throws away BM25's order.&lt;/strong&gt; Re-ranking sorts purely by embedding similarity, so a page that matched "Moore School 1946" exactly can drop below pages that are merely &lt;em&gt;about&lt;/em&gt; early computers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only embeds each page's first 512 characters.&lt;/strong&gt; A name or number further down the page is invisible to the embedding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can only reorder what BM25 already found.&lt;/strong&gt; Paraphrase Recall@20 is 0.646 with or without it - re-ranking can't rescue a page BM25 missed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On HotpotQA's human-written questions the re-rank looked great (Recall@5 0.717 → 0.842), but HotpotQA's distractor paragraphs were chosen &lt;em&gt;for word overlap with the question&lt;/em&gt; - 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.&lt;/p&gt;

&lt;p&gt;It's also the slowest setup: every query reloads every stored embedding from SQLite.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fusion: let each method vote
&lt;/h3&gt;

&lt;p&gt;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 &lt;a href="https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf" rel="noopener noreferrer"&gt;reciprocal rank fusion&lt;/a&gt; (RRF): every page scores &lt;code&gt;1 / (60 + rank)&lt;/code&gt; in each list it appears in, and the scores are added. No score calibration - a page near the top of &lt;em&gt;either&lt;/em&gt; list surfaces, and a page high in &lt;em&gt;both&lt;/em&gt; wins.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Knowing when to trust the keywords
&lt;/h3&gt;

&lt;p&gt;Rather than guess at weights, we asked the data: &lt;em&gt;when is BM25's top result right?&lt;/em&gt; 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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question type&lt;/th&gt;
&lt;th&gt;how often that happens&lt;/th&gt;
&lt;th&gt;BM25's top result is then correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;identifier&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lexical&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;84%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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: &lt;strong&gt;when BM25's best score is at least 1.05× its second, keep BM25's top 3 on top; otherwise let RRF decide.&lt;/strong&gt; (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.)&lt;/p&gt;

&lt;p&gt;Recall@5 on the full 1,985-page wiki:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question type&lt;/th&gt;
&lt;th&gt;BM25&lt;/th&gt;
&lt;th&gt;Vector re-rank&lt;/th&gt;
&lt;th&gt;Plain fusion&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Fusion + keyword pin&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lexical&lt;/td&gt;
&lt;td&gt;0.998&lt;/td&gt;
&lt;td&gt;0.986&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.998&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;identifier&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;0.625&lt;/td&gt;
&lt;td&gt;0.936&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.984&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase&lt;/td&gt;
&lt;td&gt;0.479&lt;/td&gt;
&lt;td&gt;0.595&lt;/td&gt;
&lt;td&gt;0.694&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.676&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;all&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;td&gt;0.742&lt;/td&gt;
&lt;td&gt;0.883&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.896&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MRR (all)&lt;/td&gt;
&lt;td&gt;0.791&lt;/td&gt;
&lt;td&gt;0.618&lt;/td&gt;
&lt;td&gt;0.718&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.828&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkm6vnjv40ybny1scc4z0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkm6vnjv40ybny1scc4z0.png" alt="Recall@5 by question type for each setup we tried" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 - &lt;strong&gt;paraphrase recall within the top 20&lt;/strong&gt;, i.e. whether the right page reaches the LLM's candidate list at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wiki size&lt;/th&gt;
&lt;th&gt;BM25&lt;/th&gt;
&lt;th&gt;Vector re-rank&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Fusion + keyword pin&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;200 pages&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.956&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 pages&lt;/td&gt;
&lt;td&gt;0.765&lt;/td&gt;
&lt;td&gt;0.765&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.966&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000 pages&lt;/td&gt;
&lt;td&gt;0.720&lt;/td&gt;
&lt;td&gt;0.720&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.920&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,985 pages&lt;/td&gt;
&lt;td&gt;0.646&lt;/td&gt;
&lt;td&gt;0.646&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.875&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The bug we found in plain sight: BM25 rebuilt its index on every query
&lt;/h3&gt;

&lt;p&gt;BM25 latency grew from 77 ms at 200 pages to 362 ms at 1,985. Profiling one query on the full wiki:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build the BM25 index&lt;/td&gt;
&lt;td&gt;356 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score the query&lt;/td&gt;
&lt;td&gt;3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;strong&gt;362 ms to 20 ms&lt;/strong&gt; at 1,985 pages with identical results - a speed-up every user gets, whether or not they turn on embeddings.&lt;/p&gt;

&lt;p&gt;Fusion's P95 was 122–166 ms, and profiling showed why: the search ran BM25, &lt;em&gt;then&lt;/em&gt; 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 &lt;em&gt;before&lt;/em&gt; BM25 runs. Same results, and P95 at 1,985 pages dropped from &lt;strong&gt;166 ms to 62 ms&lt;/strong&gt; (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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftekdv8wynxvto0up6r64.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftekdv8wynxvto0up6r64.png" alt="P95 search latency by wiki size" width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The "better" embeddings that made things worse
&lt;/h3&gt;

&lt;p&gt;Our prototype embedded only each page's title, tags and first 512 characters, so the obvious next step was &lt;strong&gt;section-level embeddings&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;Fused with BM25, it got &lt;strong&gt;worse&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recall@5, full wiki&lt;/th&gt;
&lt;th&gt;One embedding per page&lt;/th&gt;
&lt;th&gt;One per section&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.676&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.613&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.896&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.878&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time to embed the wiki&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;82 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The more precise section embeddings mostly found the pages BM25 already found. Fusion gains from the two methods &lt;em&gt;disagreeing&lt;/em&gt; - 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Checking it on data we didn't tune on
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SciFact&lt;/strong&gt; - 5,183 scientific abstracts, 300 claims written by experts;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NFCorpus&lt;/strong&gt; - 3,633 medical documents, 323 human queries (~38 relevant documents each);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DuRetrieval&lt;/strong&gt; - 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Corpus&lt;/th&gt;
&lt;th&gt;BM25 nDCG@10&lt;/th&gt;
&lt;th&gt;Semantic nDCG@10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HotpotQA (human questions)&lt;/td&gt;
&lt;td&gt;0.755&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.808&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SciFact (science)&lt;/td&gt;
&lt;td&gt;0.658&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.693&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NFCorpus (medical)&lt;/td&gt;
&lt;td&gt;0.305&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.344&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DuRetrieval (Chinese), first try&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.853&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.780&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix didn't need a new model, just two rules applied automatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Per page:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per question:&lt;/strong&gt; a question containing CJK characters is answered by BM25 alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A mixed wiki gets semantic search for its English pages and keyword search for the rest. With the rules in place, DuRetrieval scores &lt;strong&gt;exactly&lt;/strong&gt; what BM25 scores, and every English result is unchanged:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot7jmjooo7xayi809t65.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot7jmjooo7xayi809t65.png" alt="nDCG@10 on every corpus, keyword vs semantic search" width="799" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A multilingual model could turn that Chinese tie into a win; that's a separate experiment, with its own benchmark run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then a hand-built test wiki caught one more problem.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vector search: embedding 3 page(s)… (6 mostly Chinese/Japanese/Korean page(s) left to keyword search)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But every Chinese question came back with the &lt;em&gt;English&lt;/em&gt; pages. The query agent translates CJK questions into English before searching - a feature built for English wikis asked in Chinese - and it searched &lt;strong&gt;only&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The fix: search the question as written &lt;em&gt;and&lt;/em&gt; its translation, and merge the results. Now each language finds its own pages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Page found&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;谁发明了晶体管？ (Who invented the transistor?)&lt;/td&gt;
&lt;td&gt;晶体管&lt;/td&gt;
&lt;td&gt;keyword search on the Chinese question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;五笔字型输入法是谁发明的？ (Who invented the Wubi input method?)&lt;/td&gt;
&lt;td&gt;汉字信息处理&lt;/td&gt;
&lt;td&gt;keyword search on the Chinese question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;谁发明了第一个编译器？ (Who invented the first compiler?)&lt;/td&gt;
&lt;td&gt;Grace Hopper&lt;/td&gt;
&lt;td&gt;the English translation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the woman who found a moth in a machine&lt;/td&gt;
&lt;td&gt;Grace Hopper&lt;/td&gt;
&lt;td&gt;semantic search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who invented the transistor?&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;not found: English words can't match Chinese text, and Chinese pages have no embeddings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the honest limit of an English-only model, and the best case for trying a multilingual one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vector vs. our current approach - what changes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;BM25 only (today's default)&lt;/th&gt;
&lt;th&gt;Vector re-rank (today's opt-in)&lt;/th&gt;
&lt;th&gt;Fusion + keyword pin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Finds pages by&lt;/td&gt;
&lt;td&gt;shared words&lt;/td&gt;
&lt;td&gt;shared words, then sorted by meaning&lt;/td&gt;
&lt;td&gt;shared words &lt;strong&gt;or&lt;/strong&gt; meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paraphrased questions&lt;/td&gt;
&lt;td&gt;degrades with size&lt;/td&gt;
&lt;td&gt;better order, same misses&lt;/td&gt;
&lt;td&gt;0.48 → 0.68 top-5, 0.65 → 0.88 top-20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact names / IDs&lt;/td&gt;
&lt;td&gt;excellent&lt;/td&gt;
&lt;td&gt;much worse&lt;/td&gt;
&lt;td&gt;unchanged in practice (0.994 → 0.984)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overall top-5 recall&lt;/td&gt;
&lt;td&gt;0.840&lt;/td&gt;
&lt;td&gt;0.742&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.896&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra install&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;fastembed + model&lt;/td&gt;
&lt;td&gt;fastembed + model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped (in v1.5.0 release)
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fusion with the keyword pin&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddings held in memory&lt;/strong&gt; as one matrix, not reloaded from SQLite per query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresh embeddings&lt;/strong&gt; - 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest gap detection&lt;/strong&gt; - "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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-wiki ranking by rank, not raw score&lt;/strong&gt; - results from several wikis are interleaved by each wiki's own ranking, because BM25 scores were never comparable between wikis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BM25 18× faster&lt;/strong&gt; for everyone, embeddings or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Docker image with the model built in&lt;/strong&gt;, so containers start without a download and work offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chinese, Japanese and Korean left to keyword search&lt;/strong&gt; - 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query embedding alongside BM25&lt;/strong&gt;, plus a small cache of recent query embeddings: 166 ms → 62 ms P95 at 2,000 pages, identical results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On by default for new wikis&lt;/strong&gt; - see below.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Meanwhile, in the knowledge graph
&lt;/h3&gt;

&lt;p&gt;Search wasn't the only thing that had only ever met small wikis. Synthadoc's web UI has a &lt;strong&gt;knowledge graph view&lt;/strong&gt;: every page is a dot, links between pages are lines, and pages are coloured by topic cluster (found with the &lt;a href="https://en.wikipedia.org/wiki/Louvain_method" rel="noopener noreferrer"&gt;Louvain&lt;/a&gt; community-detection&lt;br&gt;
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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Graph appears&lt;/th&gt;
&lt;th&gt;Frame rate&lt;/th&gt;
&lt;th&gt;Longest freeze&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;1.7 s&lt;/td&gt;
&lt;td&gt;40 fps&lt;/td&gt;
&lt;td&gt;0.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;5.2 s&lt;/td&gt;
&lt;td&gt;12 fps&lt;/td&gt;
&lt;td&gt;0.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;10.1 s&lt;/td&gt;
&lt;td&gt;2 fps&lt;/td&gt;
&lt;td&gt;1.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34.2 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 fps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.6 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frt66e1g7olweix8h9krs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frt66e1g7olweix8h9krs.png" alt="Before: all 5,000 pages drawn at once" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix is the same idea as fusion: show the reader what they can actually use.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cluster overview first.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A faster server.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable colours.&lt;/strong&gt; Louvain now uses a fixed seed, so clusters keep their colours from one lint run to the next.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Graph appears&lt;/th&gt;
&lt;th&gt;Frame rate&lt;/th&gt;
&lt;th&gt;Longest freeze&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;0.8 s&lt;/td&gt;
&lt;td&gt;60 fps&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1.3 s&lt;/td&gt;
&lt;td&gt;60 fps&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;2.8 s&lt;/td&gt;
&lt;td&gt;60 fps&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~6 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60 fps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Opening the largest cluster (289 pages) on the 5,000-page wiki takes about 2 seconds, also at 60 fps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn0h7dw2rqau89xj3uje6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn0h7dw2rqau89xj3uje6.png" alt="Seconds until the knowledge graph appears, before and after" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3pwi259jgv338fjhe2k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3pwi259jgv338fjhe2k.png" alt="After: the same wiki as 24 cluster bubbles" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frrwrpmm92dt10mwzybrn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frrwrpmm92dt10mwzybrn.png" alt="Inside one cluster, with a page selected" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The faster YAML parsing helps more than the graph: every feature that reads pages gets it, search included.&lt;/p&gt;
&lt;h3&gt;
  
  
  On by default — for new wikis
&lt;/h3&gt;

&lt;p&gt;Before starting we set a bar for making semantic search the default:&lt;br&gt;
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: &lt;strong&gt;+19.7&lt;/strong&gt; for paraphrases, &lt;strong&gt;0&lt;/strong&gt; for lexical, &lt;strong&gt;−1.0&lt;/strong&gt; for identifiers, &lt;strong&gt;62 ms&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;So new wikis are now created with &lt;code&gt;vector = "auto"&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Installed with&lt;/th&gt;
&lt;th&gt;What new wikis get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pip install synthadoc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keyword search, as before — small install, no model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pip install "synthadoc[vectors]"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Semantic search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Docker image&lt;/td&gt;
&lt;td&gt;Semantic search (the model is built in)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Existing wikis keep the &lt;code&gt;vector = false&lt;/code&gt; already in their config; we never rewrite a user's settings. Changing it to &lt;code&gt;"auto"&lt;/code&gt; or &lt;code&gt;true&lt;/code&gt; turns semantic search on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Questions are generated, and each has one labelled page; another page might also answer it. That lowers every setup's scores equally.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;The benchmark lives in the repo under&lt;br&gt;
&lt;a href="https://github.com/axoviq-ai/synthadoc/tree/main/benchmarks/retrieval" rel="noopener noreferrer"&gt;&lt;code&gt;benchmarks/retrieval/&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/axoviq-ai/synthadoc.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;synthadoc
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[vectors]"&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; benchmarks.retrieval fetch       &lt;span class="c"&gt;# once: download + build the wikis (~30 min)&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; benchmarks.retrieval evaluate &lt;span class="nt"&gt;--configs&lt;/span&gt; bm25,hybrid
python &lt;span class="nt"&gt;-m&lt;/span&gt; benchmarks.retrieval evaluate &lt;span class="nt"&gt;--corpora&lt;/span&gt; scifact,nfcorpus,duretrieval &lt;span class="nt"&gt;--configs&lt;/span&gt; bm25,hybrid
python &lt;span class="nt"&gt;-m&lt;/span&gt; benchmarks.retrieval charts      &lt;span class="c"&gt;# redraw the charts in this post&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key needed: scoring runs only the search code, and gives the same numbers every time.&lt;/p&gt;

&lt;p&gt;If you run a search or RAG feature, our one piece of advice: &lt;strong&gt;benchmark&lt;br&gt;
retrieval on your own kind of content, split by question type.&lt;/strong&gt; An average would have told us vector re-ranking was a small loss. The per-type split told us &lt;em&gt;why&lt;/em&gt; — and what to build instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Acknowledgments
&lt;/h2&gt;

&lt;p&gt;A special thank you to &lt;strong&gt;[Chaire Zhang]&lt;/strong&gt; (&lt;a href="https://github.com/clairezhang-228" rel="noopener noreferrer"&gt;@clairezhang-228&lt;/a&gt;) for her invaluable contributions to this post. Her insights on rigorous work on the benchmark validations made this piece possible.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>agents</category>
      <category>architecture</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
