{"slug": "why-vector-search-misses-exact-terms-and-what-hybrid-search-fixed-measured", "title": "Why vector search misses exact terms, and what hybrid search fixed (measured)", "summary": "A team building in-browser knowledge bases fixed vector search returning wrong exact strings — such as ERR_4512 instead of ERR_4521 — by adding a BM25 keyword index alongside all-MiniLM-L6-v2 (384-d) embeddings in SurrealDB 2.x compiled to WebAssembly on IndexedDB, fusing the two ranked lists with reciprocal-rank fusion at k = 60. Each list fetches fetchK = min(200, max(k, 4k, 20)) candidates, and because fused scores are rank-based, a relevance floor is applied to each hit's cosine similarity, with exact keyword matches (BM25 > 0 and more than half the query terms present) counted as relevant regardless. The team documented two bugs: SurrealDB 2.x's @@ full-text operator is AND-only with no OR form, worked around by giving each of up to 12 query terms its own match reference and summing clamped search::score values, and a filtering bug that made small collections return zero hits.", "body_md": "Why vector search misses exact terms, and what hybrid search fixed (measured)\n\nAsk a vector search for ERR_4521 and you may get ERR_4512. How we added BM25 next to vectors with reciprocal-rank fusion in an in-browser knowledge base, what we measured, a SurrealDB full-text gotcha, and a filtering bug that gave small collections zero hits.\n\nYou search your own docs for an error code and the AI hands you a different one. You ask for the v2.3 release notes and v3.2 shows up. The search is not broken in the usual sense: it is doing exactly what a meaning-based search does.\n\nWe fixed this in our in-browser knowledge bases with hybrid search: BM25 keywords plus vectors, fused with reciprocal-rank fusion. This post covers how it works, the numbers we measured, a database gotcha, and a second bug that made small collections return nothing.\n\nThis is part 2. Part 1, We rebuilt RAG in a browser tab, tells the whole rebuild, starting with the bug where every vector was empty.\n\nWhy vectors blur exact strings\n\nTo a sentence-embedding model, near-identical strings mean near-identical things. That’s exactly what you don’t want for error codes, SKUs, versions, names and IDs. Meaning is the right tool for “how do I get my money back?” and the wrong one for ERR_4521.\n\nThe setup, all on the user’s machine:\n\nEmbeddings: all-MiniLM-L6-v2 (384-d), running in a Web Worker\n\nStore: SurrealDB 2.x compiled to WebAssembly, on IndexedDB (how that works: WebAssembly, explained)\n\nIndexes per chunk table: HNSW (cosine) for vectors, and a BM25 full-text index with a simple analyzer (lowercase, ascii, English snowball stemming)\n\nHow the hybrid query works\n\nThe query runs two searches and fuses their ranked lists:\n\n```\nquery ─┬─ embed ───────────────► vector list (fetchK nearest)\n       └─ keyword terms ───────► BM25 list   (fetchK best)\n                                     │\n                     reciprocal-rank fusion (k = 60)\n                                     │\n                           (optional rerank)\n                                     │\n                                   top k\n```\n\nTwo lists for one question\n\nThe query is split into keyword terms: words with punctuation trimmed, English, French and German stopwords dropped, at most 12 terms. Each list fetches fetchK = min(200, max(k, 4k, 20)) candidates, so fusion has enough overlap to work with.\n\nFused by rank, not by score\n\nEach document scores Σ 1 / (60 + rank) over the lists it appears in, normalised to 0..1. RRF ignores the raw scores, which is the point: BM25 scores and cosine similarities aren’t on the same scale, and RRF never has to compare them.\n\nThen a relevance floor\n\nOnly passages that are actually relevant make the final top k. How that floor works is the subtle part, below.\n\nHere’s that subtlety. Because the fused score is rank-based, you can’t put a “minimum relevance” cut-off on it.\n\nSo we apply the floor to each hit’s cosine similarity (computed from the stored vector for keyword-only hits). A hit also counts as relevant by itself if it’s a real keyword match: BM25 > 0 and more than half of the query terms present. That way an exact code survives even when its embedding scores below the cut-off.\n\nThe SurrealDB gotcha: @@ is AND-only\n\nSurrealDB 2.x’s full-text match operator only matches rows that contain all the tokens, and there’s no OR form. A 6-word question would match almost nothing.\n\nThe workaround: give each term its own match reference and sum the scores.\n\n```\nSELECT id, text, metadata, vector,\n  (math::max([search::score(1) ?? 0, 0])\n + math::max([search::score(2) ?? 0, 0])\n + math::max([search::score(3) ?? 0, 0])) AS bm25\nFROM type::table($table)\nWHERE (text @1@ $t1 OR text @2@ $t2 OR text @3@ $t3)\n  AND knowledge_id = $knowledgeId\nORDER BY bm25 DESC\nLIMIT $k;\n```\n\nSumming is legitimate, because BM25 is itself a sum over terms. A missing term adds 0. A term that appears in almost every row can score below 0, so each score is clamped at 0. A nice side effect: a term with several tokens (ERR_4521, v2.3, e-mail) still needs all of its own tokens, which keeps codes precise.\n\nThe numbers\n\nOur test set: 20 questions over 27 documents, deliberately full of near-duplicate error codes and version families. 12 questions ask for an exact term, 8 are paraphrases. With the real MiniLM model:\n\nVector\n\nHybrid\n\nHybrid + rerank\n\nrecall@1, all\n\n0.90\n\n0.95\n\n0.95\n\nrecall@3 / @5, all\n\n1.00 / 1.00\n\n1.00 / 1.00\n\n1.00 / 1.00\n\nrecall@1, exact terms\n\n0.83\n\n0.92\n\n1.00\n\nrecall@1, paraphrases\n\n1.00\n\n1.00\n\n0.88\n\nTurn the dial to see what each step bought and what it cost, using the recall@1 figures from the table:\n\nWhat we took from it:\n\nHybrid never did worse than vector, and it fixed exact-term misses at rank 1. It’s the default now.\n\nThe cross-encoder rerank (ms-marco-MiniLM-L-6-v2, on device) fixed the last exact-term miss but lost a paraphrase. It’s an opt-in switch (“Rerank on this device”), not the default.\n\nAt @3 everything is already perfect on this set. Rank 1 matters most when only the top few passages fit in a small model’s context.\n\nTo be clear, this is 20 questions, not a benchmark. We kept it because it encodes the failures our users actually hit, and it runs in CI with a synthetic embedder. That synthetic embedder is much weaker (vector 0.35 → hybrid 0.90 at @1), which makes it a good canary when the keyword path regresses.\n\nThe second bug: filtered KNN that returns nothing\n\nAll knowledge bases that use the same embedding model share one table, filtered by knowledge_id. In SurrealDB 2.x, the bare KNN operator <|k|> on an HNSW index runs a top-k over the whole table and filters afterwards.\n\nSo imagine a 60-chunk knowledge base next to a 2,940-chunk one in the same table:\n\nAsk for the top 4, and the global top-4 can be all from the big one. Filter to the small one, and you get 0 hits.\n\nThe fix has three parts:\n\nFilter during the walk. The <|K,ef|> form applies the knowledge_id condition while it traverses the HNSW graph.\n\nOver-fetch.fetchK = min(max(3k, k+8), 200), with the search effort ef scaled to it (64 to 800).\n\nAn exact fallback. If the approximate query errors or returns fewer than k rows, run an exact filter-first scan: WHERE knowledge_id = $kb, cosine similarity, ORDER BY, LIMIT k.\n\nWe measured both paths on 3,000 × 384-d vectors: the approximate query takes about 14 ms, the exact scan 160 to 556 ms depending on knowledge base size.\n\nSo the fallback is a safety net, not the main path. A low ef (40) had poor recall on the big knowledge base, hence the scaling.\n\nTest this in your own stack: create a tiny collection next to a big one in the same index, and query the tiny one. Many vector stores post-filter by default.\n\nTakeaways\n\nThese questions apply to any RAG stack, whatever store you use. If your models run on device as ours do, running an LLM in the browser explains the runtimes.\n\nHave you measured a reranker that hurt? I’d like to know if paraphrases were the casualty for you too.\n\nThe knowledge bases described here are part of AWFlow, a browser extension for workflows and AI agents that runs locally.", "url": "https://wpnews.pro/news/why-vector-search-misses-exact-terms-and-what-hybrid-search-fixed-measured", "canonical_source": "https://awflow.io/blog/why-vector-search-misses-exact-terms", "published_at": "2026-10-15 00:00:00+00:00", "updated_at": "2026-10-21 22:12:53.962299+00:00", "lang": "en", "topics": ["ai-search", "ai-tools", "ai-infrastructure", "mlops"], "entities": ["SurrealDB", "all-MiniLM-L6-v2", "WebAssembly", "IndexedDB", "HNSW", "BM25"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/why-vector-search-misses-exact-terms-and-what-hybrid-search-fixed-measured", "markdown": "https://wpnews.pro/news/why-vector-search-misses-exact-terms-and-what-hybrid-search-fixed-measured.md", "text": "https://wpnews.pro/news/why-vector-search-misses-exact-terms-and-what-hybrid-search-fixed-measured.txt", "jsonld": "https://wpnews.pro/news/why-vector-search-misses-exact-terms-and-what-hybrid-search-fixed-measured.jsonld"}}