feat(search): index markdown section bodies for BM25 full-text search (#518) - #1778
feat(search): index markdown section bodies for BM25 full-text search (#518)#1778ShauryaaSharma wants to merge 7 commits into
Conversation
Section nodes exposed only their heading text to BM25, so search_graph could not match the prose beneath a heading. Index that body so markdown content is searchable. - store: add a `body` column to the nodes_fts FTS5 table; new cbm_store_fts_rebuild() drops+recreates the table (upgrading legacy 4-column databases) and backfills `body` from each node's docstring, guarded by json_valid() against malformed-JSON rows - store: expose CBM_SQL_FTS_BODY_EXPR so every nodes_fts write site feeds `body` through one shared expression - pipeline: the wholesale backfill now delegates to cbm_store_fts_rebuild(); the row-level delta-merge insert writes `body` through the same shared expression, so prose arriving incrementally is searchable too - mcp: stop excluding Section from BM25 results. Section falls in the unboosted ELSE bucket of the label CASE, so code symbols keep their ranking advantage by construction rather than by exclusion - internal/cbm: capture the markdown section body beneath each heading, stopping at the first nested subsection, capped at MAX_COMMENT_LEN with a UTF-8-safe backoff, reusing the existing docstring property - tests: 3 extraction cases + 4 store FTS cases, including a delta-path guard — a four-column INSERT into the five-column table is still valid SQL that silently leaves `body` NULL, so the incremental path needs its own assertion The DROP+recreate opens a brief window, one-time per database during the schema upgrade, where a concurrent bm25_search finds no table and degrades to the regex path. Closes DeusData#518 Refs DeusData#519 (Module description promotion follows in a stacked PR) Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
|
Thanks for opening this — it has been seen, and it is queued. This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence. Current review status: working through a backlog. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
If this fixes a bug, a reproduction we can run is worth more than a description of the symptom. Thanks for contributing, and sorry in advance for the wait. |
Standalone harness measuring what adding `body` to nodes_fts actually costs, so the sizing question on DeusData#518 rests on measurement rather than estimate. The full-index path already did delete-all plus a full re-INSERT before this change, so `body` does not add a pass over the graph — it makes an existing rebuild index more text. The harness therefore isolates the two things that genuinely change: per-row body tokenisation time, and the storage the column adds to the FTS index. Builds the same synthetic corpus three ways and reports the deltas: A 4-column FTS (pre-DeusData#518 baseline) B 5-column FTS, body for every node (what DeusData#518 ships) C 5-column FTS, body only for Section/Module rows (the WHERE lever) Methodology notes, because both mistakes produce confident-looking nonsense: - variants are interleaved (A,B,C, A,B,C, ...) rather than grouped, so drift over the life of the process cannot land entirely on whichever variant runs last. Grouped runs reported variant C as slower than B despite C doing strictly less work. - the minimum is reported, not the mean: backfill time has a hard floor and an unbounded tail, so the minimum is the closest observable approximation of the real work. - run-to-run spread is printed, and a warning fires when noise is large relative to the delta being reported, rather than letting a noisy timing column pass as authoritative. Uses the real CBM_SQL_FTS_BODY_EXPR including the json_valid() guard, and seeds a fixed-width 64-bit PRNG reset per variant so all three variants see a byte-identical corpus. Not a replacement for scripts/benchmark-index.sh, which measures real end-to-end indexing on a real repository; this deliberately strips parsing and I/O so the FTS write is visible. Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
|
Read in full. The structure is right and slice 1 is complete with no slice-2 leakage. But the delta test has exactly the hole it was written to close, and there is a second behavioural change the description does not mention. Both are fixable; neither is a rethink. The delta test does not guard the delta call siteThis is the important one, and I checked it rather than reading the name and moving on.
So: revert The fix is to drive the production delta path: build a base index, add nodes, run the real delta merge, then assert A fourth write site exists
Not a correctness bug today, but the BM25 ranking changes for every query, including nodes with no body
The PR body says "backward compatible — additive column". That is true of the schema and not of the ranking. This is worth an explicit decision rather than an accident: either accept the shift and say so, or pass explicit column weights to Alongside that: Two test weaknesses
Mechanical
Worth knowing that this is currently blocking everything else: the What is right, and it is most of it
Scope is clean: On the benchmark harness — you committed it about twenty minutes after asking whether it was wanted. I have since said yes on #617, so keep it; I mention it only because "asked, then shipped before the answer" is a habit worth not forming. It is 477 of the 859 lines here, which is also worth a sentence in the description so a reviewer knows 55% of the diff is a measurement tool rather than the feature. Fix the delta test, decide the BM25 weights question, tighten the two tests and split that header line, and I will merge this. Then I will run the real-corpus benchmark on my side as promised. |
Harness is in — Your storage estimate was right. Beyond the 100k point, where fixed page overhead distorts the ratio, the marginal cost settles at ~24 bytes per node — about 200 MB extrapolated to 8.5M nodes, inside the +150–400 MB you estimated. The 95% figure for the
At 500k the lever takes +12.0 MB down to +4.1 MB. The 95% is correct for row count — C indexes 17.8k bodies against B's 157k, which is 11% — but storage doesn't follow row count. Markdown section bodies run substantially longer than function docstrings, and the fifth column carries fixed overhead whether or not a given row has prose. So narrowing buys about two thirds, not nineteen twentieths, and it costs function-docstring search. Given your findability-first call I'd read that as weakening the case for the lever, but it's your trade to make and the harness will re-measure it against whatever corpus shape you think is realistic — the distribution constants are at the top of the file. I'm not quoting timings. My machine shows 39–44% run-to-run spread, which is large next to the delta being measured. The harness detects that and prints a warning rather than letting a noisy column pass as authoritative, so the timing half is there but self-flagged as unreliable here. Size figures are deterministic and unaffected. Your reference-machine run is the one that will produce meaningful wall-clock, and thank you for taking that half. Two methodology notes, because I got both wrong first and each produced confident-looking nonsense. Running the variants grouped (A,A,A, B,B,B, C,C,C) let drift over the life of the process land entirely on whichever went last, which reported C as slower than B despite C doing strictly less work — variants are now interleaved so drift cancels. And the seed was a No rush from my side on slice 2 — it stacks on whatever this lands as, so I'll hold until this has a direction. |
|
My previous comment crossed with your review — I posted it without having read this. Apologies; it reads as if I ignored you. Replying properly now. I checked every finding against the tree rather than taking them on trust. All six are correct, and the first one is a real hole in the thing I was most confident about. The delta test doesn't guard the delta call site — you're right
So revert Fixing it to drive the real merge: build a base index, add nodes, run Fourth write site — and my comment overstates its own coverage
The BM25 weights — you asked for my reasoning, and it changes the recommendation slightlyConfirmed: One thing worth putting on the record before choosing, because it affects what weights can actually deliver: explicit weights cannot restore the old ordering. FTS5's bm25 normalises by document length against the corpus average. Column weights scale each column's term contributions, but body tokens still count toward row length and therefore toward the corpus average — so So "additive column, backward compatible" is wrong about ranking in a way weights can't repair, and I should not have written it. The real choice is not whether ranking moves but whether it moves deliberately. Given that, I agree with your lean, for a slightly different reason than preservation: weights are worth setting because they let us bound how far prose can outrank a symbol match, which is the actual risk. A term in a name is a stronger signal of intent than the same term in a paragraph. I'd propose weighting I don't want to pick the numbers unilaterally since you own the ranking model, and I'd rather tune them against the label-boost CASE than guess. Happy to propose a concrete set with before/after orderings on a sample corpus if that's useful. And the The two weak testsBoth fair.
Lint, and what it cost
The expensive part is the one you pointed out: lint failing skipped the test job, so none of these seven tests have run in CI even once. Every "verified" claim I've made rests on local runs on a toolchain that stubs TRE regex and can't build the product binary. Splitting that line is the first thing I'll do, so the next push produces a real signal rather than my assurances. On shipping the harness before your answerFair, and I'd rather you said it. I asked whether it was the evidence you wanted specifically so I wouldn't measure the wrong thing twice, and then didn't wait for the answer — which made the asking decorative. Noted, and I'll wait next time. I'll add the note that Plan, in the order I'll push them: split the header line so CI actually runs, then the delta test against Thanks for checking the delta test by reading what it does rather than what it's called. That's the finding I'd least have caught myself. |
The macro was emitted as a single 274-character physical line, past the 100-column limit, which failed clang-format and therefore the lint job. Because the test job is gated on lint, none of the tests added for DeusData#518 had executed in CI at all. Split across escaped-newline continuations with backslashes aligned per AlignEscapedNewlines: Left (longest content line + 2). Verified the macro still expands to a byte-identical SQL string, so this is formatting only with no behavioural change. Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
The previous fts_delta_insert_populates_body hand-prepared its own INSERT
inside the test body rather than calling any production entry point, so
reverting pipeline_delta.c to a four-column nodes_fts insert left it
passing. It guarded CBM_SQL_FTS_BODY_EXPR, not the site that consumes it
— which is the exact failure mode it was written to catch, since that bug
produces no compile error and no failing assertion, only a silently NULL
body on the incremental path.
Replaced with pipeline_delta_merge_indexes_body in test_pipeline.c, which
drives the real path: seed a store, cbm_delta_preseed, add a Section node
carrying prose, cbm_delta_patch, then assert the merged node's body text
is matchable through nodes_fts.
Verified the new test actually discriminates. Against a four-column
revert of pipeline_delta.c the body assertions ('canary', 'rollback')
fail while the name assertion ('deployment') still passes — confirming
the node merged and only its body was lost, which is precisely the
regression being guarded.
Writing it against the production path also surfaced an ordering
constraint the hand-rolled version could not: cbm_delta_preseed lifts the
gbuf id watermark above MAX(id), so a node added to the gbuf before that
call keeps a low temp id and is never merged by the "id > max_db_id"
predicate. Documented in the test.
Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
The previous split put the escaped newlines at column 78, anchored on the macro's final line. clang-format aligns them to the longest line that actually carries a backslash, and the final line of a macro carries none — so the anchor is the second line (39 chars) and the column is 41, matching the violation clang-format reported at store.h:362:40. Cross-checks against the existing multi-line macro at cli.c:2983, whose longest backslash-bearing line is 95 characters with its backslashes at column 97 — the same content+2. Macro expansion re-verified byte-identical; formatting only. Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
Attacked the inputs rather than the patterns this time. Three worked.
ONE INVALID BYTE HID A WHOLE FILE. The scanner abandoned any file that
failed to decode as UTF-8, so appending a single 0xFF made it skip every
readable line in that file, plaintext payload included. A complete
evasion costing one byte. Files are now decoded with replacement rather
than abandoned, and a NUL byte -- git's own binary heuristic -- is what
marks a file as genuinely not a review surface. Only three tracked files
reach that path today: a PNG, a Windows ETW manifest and the nomic blob.
Better still, the evasion is now its own signal: a file with a TEXT
extension that is not valid UTF-8 is reported, because a stray byte in a
.md or .c is anomalous regardless of what surrounds it.
THE TRIPWIRE MATCHED CASE-SENSITIVELY. `Scripts/evil.sh` and
`.GitHub/workflows/` walked past it, and on a case-insensitive checkout
those are the same files as the guarded ones. Now matched with POSIX
character classes rather than `${v,,}` (bash 4 only) or `tr` (external),
so the logic can be tested on any shell -- which matters for a gate
nobody can run locally the way CI runs it.
THE FILES ENDPOINT CAPS AT 3000. A pull request padded past that limit
would hide a CI change in the tail, and the gate would report green over
a change set it never saw. It now compares what the API returned against
the count the PR itself declares and REFUSES when they disagree, rather
than passing on partial data.
All three are pinned in the selftest, alongside the requirement that a
PNG with invalid UTF-8 stays silent -- the hardening must not turn every
binary into a finding.
Verified end to end against real pull requests: #1422 refused (4 guarded
paths), #1245 refused (42 guarded, 392/392 received so no truncation),
#1778 refused (2 guarded), #1678 passes.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Both tests passed with the feature reverted, so neither was guarding it. markdown_section_no_body asserted only that a bare heading has no docstring. Every Section docstring is NULL on a build that never captures bodies, so that assertion held with or without the change. It now extracts two sections in one file and asserts the documented sibling HAS prose while the bare one does not — a pair that is only true once capture works and stops at the heading boundary. markdown_section_body_capped hardcoded 500 rather than referencing the budget, reintroducing at the test layer the parallel-constant coupling this branch removed from production. Its corpus was also "alpha " repeated, so the UTF-8 backoff branch never executed in any run of the suite. Promoted MAX_COMMENT_LEN to CBM_MAX_COMMENT_LEN in cbm.h so production and tests assert against one definition instead of a copied literal, and gave the test 498 ASCII bytes followed by U+20AC so that character straddles byte offsets 498-500. A naive cut at the cap lands on its final continuation byte; the test now asserts the result is exactly 498 bytes and ends on a character boundary, which fails if the backoff is removed. Verified locally: body length 498, not 500. Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
The issue-552 fixture hand-rolled a delete-all plus a four-column nodes_fts INSERT. That stayed valid SQL against the five-column table and simply left body NULL, so the fixture had quietly stopped mirroring the schema search_graph actually queries — and nothing went red to say so. Replaced with a call to cbm_store_fts_rebuild, which is what production uses. A fixture that spells out its own column list has to be remembered on every schema change; one that calls the rebuild cannot drift. This was the last nodes_fts write site outside the store and the delta patch, so the CBM_SQL_FTS_BODY_EXPR comment in store.h no longer overstates its coverage. Reworded it to say what is actually true and why the failure mode needs review rather than the compiler to catch. Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
|
Five of the six items are pushed. The BM25 weights decision is the one left, and it is yours. Lint, and the signal it was hiding. CI is green now, all 34 checks, and the The delta test. Replaced with Driving the production path immediately paid for itself by surfacing something the hand-rolled version could not: The fourth write site. Rather than routing The two weak tests. BM25 weights — yours to call, with one correction that narrows it. Confirmed no column weights at So "additive column, backward compatible" was wrong about ranking in a way weights cannot repair, and I have corrected that in the description rather than leaving it standing. The real choice is not whether scores move but whether they move deliberately. That is why I still agree with your lean, for a different reason than preservation: weights are worth setting to bound how far prose can outrank a symbol match. A term in a name is a stronger signal of intent than the same term in a paragraph, so I would put Description now carries the On shipping the harness before your answer — fair, and noted. I asked precisely so I would not measure the wrong thing twice, then did not wait, which made the asking decorative. |
Slice 1 of the #617 split, per the review guidance there: the FTS body infrastructure plus Markdown
Sectionindexing. #617 stays open as the ledger; the #519 YAML/JSON Module description promotion follows as a second PR stacked on this one.Rebuilt against current
mainrather than rebased — the original branch was ~1100 commits behind andpipeline.c,mcp.candstore.chad all been reworked around the touched regions, so the old diff served as the design spec.What & why
search_graphBM25 only matched node names and headings, so it was blind to markdown prose.Sectionnodes exposed only their heading text and were excluded from BM25 results entirely. This indexes the section body so content is searchable.Closes #518.
Changes
store — adds a
bodycolumn to thenodes_ftsFTS5 table. Newcbm_store_fts_rebuild()drops and recreates the table (which is what upgrades legacy 4-column databases) and backfillsbodyfrom each node's docstring, guarded byjson_valid()so malformed-JSON rows degrade to name-only indexing instead of aborting the wholeINSERT...SELECT.CBM_SQL_FTS_BODY_EXPRis exported fromstore.hso everynodes_ftswrite site feedsbodythrough one expression.pipeline — the wholesale backfill delegates to
cbm_store_fts_rebuild()(net −17 lines). The row-level delta-merge insert inpipeline_delta.cwritesbodythrough the same shared expression.That third write site is the one worth reviewing closely.
nodes_ftsnow has five columns, and an INSERT naming only the original four is still perfectly valid SQL that silently leavesbodyNULL — prose arriving through delta merge would have been unsearchable while a full reindex looked correct. It has its own test rather than relying on the full-index path.mcp — stops excluding
Sectionfrom BM25 results in both the search and count queries.Sectionfalls in the unboostedELSE 0.0bucket of the label-boost CASE, so code symbols keep their ranking advantage by construction rather than by exclusion.Modulestays excluded here and is handled in the #519 slice.internal/cbm — captures the markdown section body beneath each heading, stopping at the first nested subsection (each subsection gets its own
Sectionnode and its own body). Capped at the existingMAX_COMMENT_LEN(500) with a UTF-8-safe backoff, reusing the existing docstring property.Testing
Seven cases across three suites. CI runs them on ubuntu/macOS/Windows including MSan, TSan and LSan.
markdown_section_body_capturedmarkdown_section_no_bodymarkdown_section_body_cappedfts_rebuild_indexes_body_contentfts_rebuild_upgrades_legacy_schemafts_rebuild_tolerates_malformed_propertiesjson_valid()guard holdspipeline_delta_merge_indexes_bodycbm_delta_patchThe delta test is the one worth looking at. It drives the real merge —
cbm_delta_preseed, thencbm_delta_patch— rather than reimplementing the INSERT in the test body. Verified that it discriminates: against a four-column revert ofpipeline_delta.cthe body assertions fail while the name assertion still passes, confirming the node merged and only its body was lost.Writing it against the production path also surfaced an ordering constraint a hand-rolled version could not:
cbm_delta_preseedlifts the gbuf id watermark aboveMAX(id), so a node added to the gbuf before that call is silently never merged.The extractor's grammar assumption was verified directly against
tree_sitter_markdownrather than assumed: for nested headings the parser producessection [0-81]containing the heading, a paragraph, and a nestedsection [48-81], and the helper correctly stops at byte 48.Performance
scripts/benchmark-fts-body.c(added here) isolates what thebodycolumn actually costs. Since the full-index path already did delete-all plus a full re-INSERT,bodydoes not add a pass over the graph — it makes an existing rebuild index more text. So the harness measures the two things that genuinely change: per-row body tokenisation and the storage the column adds. It builds one synthetic corpus three ways:Section/Modulerows (theWHERElever)Worth knowing when reading the diff: the harness is 477 of the ~934 added lines, so a little over half of this PR is a measurement tool rather than feature code.
FTS index size
Beyond the 100k point (where fixed page overhead distorts the ratio) the marginal cost settles at ~24 bytes per node. Extrapolated to the ~8.5M-node reference corpus that is ~200 MB — inside the +150–400 MB band estimated in review.
One correction to the estimate
The
WHERE label IN ('Section','Module')lever was expected to remove ~95% of the cost. Measured, it removes ~66% of the added storage (at 500k: +12.0 MB → +4.1 MB).The 95% holds for row count — variant C indexes 17.8k bodies against B's 157k, which is 11% — but storage does not follow row count. Markdown section bodies run substantially longer than function docstrings, and the fifth column carries fixed overhead whether or not a given row has prose. So narrowing buys less than it appears to, which seemed worth knowing before trading away function-docstring search for it.
On timing
I am deliberately not quoting backfill timings. My machine shows 39–44% run-to-run spread, which is large relative to the delta being measured; the harness detects this and prints a warning rather than letting a noisy column pass as authoritative. The size figures above are deterministic and unaffected. Timings on a quieter machine would be meaningful —
scripts/benchmark-fts-body.shbuilds and runs it.Two methodology notes, since both mistakes produce confident-looking nonsense and I hit them: variants are interleaved rather than grouped (grouped runs let drift land on whichever variant goes last, which reported C as slower than B despite C doing strictly less work), and the minimum is reported rather than the mean.
This does not replace
scripts/benchmark-index.sh, which measures real end-to-end indexing on a real repository. I cannot run that one — it needs a built product binary and shells out topython3, and my toolchain is 32-bit MinGW.org GCC 6.3.0 with nomake; a 32-bit address space also will not hold anything near the reference corpus. Per the split agreed on #617, that half is being run on the reference machine — this harness measures the mechanism portably, the reference run measures the reality.Behavioural changes
Two things change for existing callers, both deliberate, neither purely additive:
Sectionis now returned by BM25 and counted in the total. That is the intended effect of the direction call on #617 — it changes both the result set and thetotalfor existingsearch_graphcallers.Sectionsits in the unboostedELSE 0.0bucket of the label-boost CASE, so code symbols keep their ranking advantage by construction rather than by exclusion.BM25 scores shift for every row, including rows with no body.
bm25(nodes_fts)is called with no column weights, so all columns sit at the default 1.0. FTS5 normalises by document length against the corpus average, and body tokens count toward row length — soavgdlmoves as soon asbodyis populated, and every row's normalisation moves with it. This is not repairable by weighting: evenbodyat 0.0 removes the contribution but not the length.So the ranking question is not whether scores move but whether they move deliberately. I have left the weights alone pending a steer, since the ranking model is the maintainer's call. My reasoning is in the review thread: weights are worth setting to bound how far prose can outrank a symbol match, which argues for
bodybelowname/qualified_namerather than at parity.Notes
The schema change is backward compatible — additive column, legacy databases keep working read-only and are upgraded by the DROP+recreate at the next index run, which was already a full FTS rebuild. Note that this is a statement about the schema, not about ranking; see the behavioural changes above.
One operational caveat worth stating rather than leaving to be discovered: the DROP+recreate opens a brief window during the schema upgrade where a concurrent
bm25_searchfinds no table and degrades to the regex path. It is one-time per database and degrades gracefully, but with daemon-era concurrent sessions it is observable.No MCP tool changes, no new dependencies, no new
system()/popen()/network calls.