feat(search): index prose content for BM25 full-text search - #1882
Merged
Conversation
Closes #518 (Section nodes don't index body text) and #519 (META.yaml / frontmatter description values not indexed). A question asked in words could not reach the nodes that hold the words: Section carried only its heading, a config Module only its path, and both labels were filtered out of BM25 results outright. nodes_fts gains a fifth column, `body`, backfilled from each node's EXISTING properties.docstring. No new column on `nodes`, no new table, and CBM_INDEX_FORMAT_VERSION deliberately does NOT move -- nobody is forced to reindex. A legacy four-column table survives untouched under CREATE VIRTUAL TABLE IF NOT EXISTS: the backfill probes for the column before naming it, and bm25()'s surplus fifth weight is inert on a table that has no fifth column (FTS5 reads a weight only for a column an instance landed in). Such a database keeps opening and searching, just without prose. Two node kinds gain the docstring they lacked: - Markdown section bodies (#518) -- the blocks following a heading, up to the next heading or subsection, collapsed to single spaces and capped at MAX_COMMENT_LEN (500 B) with a UTF-8 backoff that never splits a multi-byte sequence. - Top-level YAML/JSON description / summary / purpose (#519), promoted onto the file's Module node. Section and Module are no longer excluded from BM25 results. Both the ranked query and the mirrored count query were changed together; removing the filter from only one desynchronises results from counts. Column weights are bm25(nodes_fts, 1.0, 1.0, 1.0, 1.0, 0.3): the four identifier columns keep parity with each other and prose sits well below them, so a prose-only hit surfaces but never outranks an identifier match. The FTS backfill is consolidated into one writer, cbm_store_fts_rebuild(), used by the wholesale rebuild AND the delta merge. That second site matters: it had its own hand-written four-column INSERT, which with a fifth column would leave body NULL for every node arriving by delta -- silently, on the warm path most users hit, while a full reindex looked perfect. There is now exactly one place the column list is decided, and a test that fails if the delta path stops writing prose. The backfill guards json_extract with json_valid: malformed properties JSON exists in pre-fix databases and would otherwise abort the whole rebuild. Tests: markdown section bodies (own body, stops at the next heading, UTF-8 safe cap, empty body stays NULL), YAML/JSON description promotion (block scalars, synonym fallback, top-level only, config-only), FTS body indexing and its column isolation, malformed-JSON tolerance, legacy four-column degrade, incremental watermark, BM25 finding Section and Module by prose, results/total consistency, identifier-outranks-prose, and the delta-merge regression. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com> Co-authored-by: ShauryaaSharma <196896785+ShauryaaSharma@users.noreply.github.com> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…atch The #518/#519 tests so far each cover one layer: extraction produces the docstring, the store indexes it into nodes_fts.body, the delta merge writes the same columns, and the query returns Section and Module. Nothing yet proved the layers connect. This indexes a real temporary repository -- a README.md whose section body runs well past the 500-byte cap, plus a META.yaml with a top-level description -- through the full pipeline, then asserts both prose words are matchable in the `body` column. It is also the guard on the size budget. build_def_props drops an oversized field ATOMICALLY, so a section body that did not fit the 2 KB properties buffer would vanish without a trace and every narrower test would still pass. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
…plit
Only `name` goes through cbm_camel_split; qualified_name, label, file_path
and now `body` are indexed verbatim. `body` is natural-language prose, so
splitting it would mangle the very text the column exists to make findable,
and the unicode61 tokenizer already handles prose correctly.
That invariant lived only in the shape of one format string, where the
splitter is substituted into the name position alone. Nothing failed if a
later edit wrapped the body expression too.
cbm_camel_split("getUserById") yields "getUserById get User By Id", so a
split body would additionally match the fragment "User" -- seeding a
camelCase identifier into the prose and asserting the fragment does NOT
match turns the invariant into a test. The mirror assertion, that `name`
DOES match its fragment, pins the other half so the guard cannot be
satisfied by simply indexing nothing.
Verified binding: wrapping the body expression in cbm_camel_split makes
body:User match 1 instead of 0 and the test fails.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
DeusData
force-pushed
the
feat/518-519-bm25-prose
branch
from
August 28, 2026 23:04
56d21fe to
dc23628
Compare
This was referenced Aug 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #518. Closes #519.
Distilled in-house from #617 / #1778, with
Co-authored-by:credit to @ShauryaaSharma, who designed this and carried it for two months. The reason for taking it in-house was scheduling on my side, not the quality of the work — explained on both threads.What
nodes_ftsgains a fifth column,body, backfilled from each node's existingproperties.docstring. Two node kinds that never had a docstring now get one: markdown section bodies (#518) and top-level YAML/JSONdescription/summary/purposepromoted onto the Module node (#519).SectionandModulestop being excluded from BM25 results.No reindex
CBM_INDEX_FORMAT_VERSIONis unchanged.nodes_ftsis contentless — a derived index, not a source of truth — andnodes/edgesgain no table and no column. Legacy 4-column databases keep working: the body column is detected at runtime and queries degrade to "no prose" rather than erroring. There are tests for exactly that.Column weights:
bm25(nodes_fts, 1.0, 1.0, 1.0, 1.0, 0.3)The four identifier columns stay at parity;
bodysits at 0.3.FTS5 computes
f(qi,D) = Σ_c w_c · n(qi,c)— it applies column weights to per-column term frequency before the tf-saturation term. That is what Robertson, Zaragoza & Taylor (CIKM 2004) prescribe for multi-field BM25; the failure mode they warn about is per-field scores combined linearly afterwards, which FTS5 does not do. So weighting here is principled rather than a knob. The field-weighting literature is also consistent that body text takes the lowest field weight (typical title:body 3:1–10:1); 0.3 is the findability-favouring end of that range.Known and accepted: FTS5 uses a single global
|D|/avgdlrather than BM25F's per-field length normalisation, andb=0.75is hardcoded. So populatingbodyshiftsavgdland therefore moves every row's score, including bodiless ones — no weight setting can undo that, becausew_cacts ontf, never on|D|. Measured: cbm's own index movesavgdl22.68 → 28.76 tokens (+26.8%, 29.2% of nodes carry a docstring); a Kotlin corpus moves +4.9%. That works out to roughly +9.5% score for a bodiless row at average length. Only a separate prose table would avoid it, and that machinery was judged not worth it here.The third write site
Main had grown a third
INSERT INTO nodes_ftsatpipeline_delta.c:528that #617 predates. Left alone it would have written four columns into a five-column table, sobodywould land NULL for every node arriving via delta merge — the warm path most users hit — while a full reindex looked perfect.Rather than edit three literals and hope, all write sites now route through one parameterized helper (
store.c:421), and bothbm25()query sites share a singleBM25_WEIGHTSmacro so ranking and reported score cannot desync.Verification
store_search store_nodes store_edges store_bulk store_pragmas mcp pipeline extraction complexity. The 6 skips are pre-existing and identical before and after.test_pipeline.c:12587. The green means the fix works, not that the tests are asleep.properties_jsonsurvived; legacy 4-column table tolerated; andbodyindexed raw, not camelCase-split (cbm_camel_splitapplies tonameonly — splitting prose would mangle it).