Skip to content

feat(search): index prose content for BM25 full-text search - #1882

Merged
DeusData merged 3 commits into
mainfrom
feat/518-519-bm25-prose
Aug 29, 2026
Merged

feat(search): index prose content for BM25 full-text search#1882
DeusData merged 3 commits into
mainfrom
feat/518-519-bm25-prose

Conversation

@DeusData

Copy link
Copy Markdown
Owner

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_fts gains a fifth column, body, backfilled from each node's existing properties.docstring. Two node kinds that never had a docstring now get one: markdown section bodies (#518) and top-level YAML/JSON description/summary/purpose promoted onto the Module node (#519). Section and Module stop being excluded from BM25 results.

No reindex

CBM_INDEX_FORMAT_VERSION is unchanged. nodes_fts is contentless — a derived index, not a source of truth — and nodes/edges gain 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; body sits 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|/avgdl rather than BM25F's per-field length normalisation, and b=0.75 is hardcoded. So populating body shifts avgdl and therefore moves every row's score, including bodiless ones — no weight setting can undo that, because w_c acts on tf, never on |D|. Measured: cbm's own index moves avgdl 22.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_fts at pipeline_delta.c:528 that #617 predates. Left alone it would have written four columns into a five-column table, so body would 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 both bm25() query sites share a single BM25_WEIGHTS macro so ranking and reported score cannot desync.

Verification

  • 967 passed, 6 skipped, 0 failed across 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.
  • Revert-check: forcing the body column off makes 9 tests fail, including the delta-path test at test_pipeline.c:12587. The green means the fix works, not that the tests are asleep.
  • Covered: prose findable end-to-end; results and totals stay consistent across both query sites; an identifier match still outranks a prose-only match (i.e. the 0.3 weighting behaves); UTF-8-safe 500-byte cap; malformed properties_json survived; legacy 4-column table tolerated; and body indexed raw, not camelCase-split (cbm_camel_split applies to name only — splitting prose would mangle it).

DeusData and others added 3 commits August 29, 2026 01:04
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
DeusData force-pushed the feat/518-519-bm25-prose branch from 56d21fe to dc23628 Compare August 28, 2026 23:04
@DeusData
DeusData merged commit be7b578 into main Aug 29, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

META.yaml/frontmatter description values not indexed for BM25 search Section nodes don't index body text — BM25 can't search markdown content

1 participant