feat(search): rank with BM25 over a real lexical index - #162
Conversation
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — 2d51fe90
This PR was opened by the trusted drewstone account.
This approval is provisional and was applied by the local stand-in because the pr-reviewer webhook host is unreachable (2026-08-21). CI on this head is fully green. The full PR reviewer audit re-runs via the resweep when the service returns and will publish findings if it detects issues.
searchKnowledge scored with substring matches against hand-picked weights, with no inverse document frequency, no length normalization, and no term frequency saturation, so a term in every page counted as much as a rare one and a long page outranked a short one by repetition. A new pure lexical index (no dependency, no native module) holds field-boosted term frequencies, document lengths, and the average length; scoreBm25 ranks with the Lucene IDF. searchKnowledge fuses that list with the link graph as before and keeps exact-title, title-contains, and body-contains matches ahead of a bag-of-words match. The previous scorer is deleted, not selectable. tokenizeText and tokenizeQuery move next to the index so indexing and querying share one tokenizer. FileSystemSearchProvider caches the lexical index with the page index and drops both together. Closes #132
Drop the index-shape, per-parameter, and duplicate-layer assertions; keep the inverse-document-frequency discount, length normalization, term-frequency saturation, the exact-title tier, determinism under page reorder, the near-duplicate cluster recall, and the prebuilt-index equivalence with its refusal.
2d51fe9 to
45fac17
Compare
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — 45fac171
This PR was opened by the trusted drewstone account.
This approval is provisional and was applied by the local stand-in because the pr-reviewer webhook host is unreachable (2026-08-21). CI on this head is fully green. The full PR reviewer audit re-runs via the resweep when the service returns and will publish findings if it detects issues.
Why
searchKnowledgeranked withrankByTokens/tokenScore: substringincludesagainst hand-picked weights (200/50/20/5/3/1), no inverse document frequency, no length normalization, no term-frequency saturation. On a store of ~1,100 claim pages a term that appears in almost every page counted as much as a rare one, and a long page outranked a short one by repetition. Question dedup andkb_searchin the consumer both run through this path.What
BM25 replaces the hand-weighted scorer. Nothing selects the old one; it is deleted.
src/lexical-index.ts(no dependency, no native module, importable at the edge):buildKnowledgeLexicalIndex(pages, { tokenize, fieldBoosts })builds an inverted index with field-boosted term frequencies (title 3, path 2, body 1), document lengths, average document length, and document count.scoreBm25(index, tokens, { k1 = 1.2, b = 0.75 })scores with the Lucene IDFln(1 + (N - df + 0.5) / (df + 0.5)), ordered by score then path.tokenizeTextis the token stream;tokenizeQueryis its distinct-token form and moves into the same module, so indexing and querying share one tokenizer and the two vocabularies cannot drift. The split pattern, CJK bigram expansion, and stop list are carried over byte-identically.searchKnowledgekeeps its signature, hit shape,normalizedScore,snippet,reasons, RRF-with-graph fusion, and path tie-break. The lexical list is BM25 ordered inside four phrase tiers (exact title or path > title contains > body contains > bag of words), so an exact lookup cannot be overtaken by term repetition.KNOWLEDGE_SEARCH_RETRIEVER_ID = 'bm25-rrf-v1'is exported and used in the README and receipts-doc examples, so one identity is declared in one place instead of each caller inventing a string.FileSystemSearchProviderbuilds the lexical index once per page index and drops both together onrefresh/invalidate().SearchKnowledgeOptions.lexicalIndexaccepts a prebuilt index for repeated queries and refuses one built from other pages.docs/knowledge-use-receipts.mdexample, CHANGELOG (10.1.0, additive).Simplification
Rule 1 — absence proven before adding
grep -rn "bm25|BM25|idf|IDF|inverted index|avgdl|lexical" src testson origin/main (8a54446) returns no ranking implementation: only a comment inclaim-ledger.ts,benchmarks/adapters.tstokenOverlap(memory-adapter scoring), and the shingle index insiderag-eval/near-duplicates.ts. No BM25 in agent-eval or agent-sdk either. Nothing existed to extend.Proof (local, macOS, node 24.11.1)
pnpm run typecheck(src + contracts): clean.pnpm run lint: 231 files, no findings.src/lexical-index.test.ts,src/search.test.ts,tests/filesystem-search-provider.test.ts,tests/retrieval-eval.test.ts,tests/core.test.ts,tests/rag-eval.test.ts: 66 passed, 0 failed. Existing search/provider/retrieval-eval assertions pass unchanged.pnpm run build,pnpm run api:surface(10 additive exports, none removed),pnpm run check:version-bump: 10.0.0 -> 10.1.0 (minor, additive), rebased onto the receipt-reference release (feat(receipts): reference the visibility snapshot from a retrieval receipt #163).src/lexical-index.test.ts,src/search.test.ts,tests/filesystem-search-provider.test.ts,tests/retrieval-eval.test.ts,tests/core.test.ts,tests/rag-eval.test.ts,src/knowledge-use-receipts.test.ts: 78 passed, 0 failed.pnpm test: 690 passed, 16 skipped, 73 failed. Every failure is environmental on this machine, not from this change: 48 intests/kb-improvement/*(exact knowledge candidate workflows require Linux directory descriptors, identical on a clean origin/main checkout) and 25 intests/version-bump-check.test.ts, which times out at 15s under parallel load and passes in this same worktree with--testTimeout 90000and on a clean origin/main checkout. CI (ubuntu) runs all of them.Closes #132