Skip to content

fix(knowledge): key RAG chunk memo entries on the chunker source, not just the shim - #3487

Merged
chernistry merged 8 commits into
mainfrom
fix/memo-key-rag-chunker
Aug 9, 2026
Merged

fix(knowledge): key RAG chunk memo entries on the chunker source, not just the shim#3487
chernistry merged 8 commits into
mainfrom
fix/memo-key-rag-chunker

Conversation

@chernistry

@chernistry chernistry commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

User description

knowledge/rag.py carries the same memo-key defect #3486 fixes for the knowledge-graph site. Review surfaced two more problems in the same path; all three are fixed here.

1. The memo key does not move when the chunker does

The shim only dispatches:

if is_python:
    return _extract_python_chunks(source, rel_path)
return _line_chunks(source, rel_path)

fingerprint hashes only the decorated function's own source, so that body is byte-identical before and after any rewrite of either chunker. The docstring claimed the opposite:

Fingerprint key = (chunk_sha, rel_path, this-function-body-hash).
A change to the chunker invalidates the cached chunks, so a bug fix
in chunk shaping correctly re-derives.

It does not. Declare the module as a memo dependency at the _get_memoized_chunker call site, using the depends_on parameter added in #3486.

The chunkers live beside the shim rather than in another file, so the site names its own module through sys.modules[__name__] instead of importing itself. That keeps the reference on the canonical module object whichever import path loaded it first - bernstein.core.rag is served by the legacy redirect finder.

Module granularity rather than depends_on=(_extract_python_chunks, _line_chunks) also covers the syntax-error fallback edge between the two chunkers, and the chunk_size and overlap defaults.

2. The index never reaches the memo layer

Fixing the memo key alone does not deliver the outcome it exists for. _build_inner re-reads a file only when its mtime moved:

if old_mtime is None or mtime > old_mtime:
    to_index.append(rel)

For a file whose bytes have not changed the chunker is never called and the memo key is never consulted, so the FTS rows the previous chunker wrote stay in the index and searches keep returning them.

code_digest of the chunker module now goes into a new index_meta table, and every file is reprocessed when it stops matching. It is deliberately the same digest that backs depends_on, so the two layers cannot disagree about which chunker is current.

An index written before this change has no stored revision, so the first build after upgrading reprocesses everything once. Those files are re-read, but their chunks still come from the memo store when the chunker source itself has not moved - the sweep costs IO, not re-chunking. There is a test for exactly that layering.

3. The rebuild decision was not atomic

The revision read ran on a deferred transaction, which SQLite escalates to a write lock only at the first DELETE further down. Two processes can hold different chunker revisions - an upgrade while a long-lived process is still running - so the older one could read the revision the newer one had just committed, conclude the chunker changed, reindex with its own older chunker and record that as current. The newer build is undone, and the pair thrash until the older process exits.

BEGIN IMMEDIATE before the read makes the whole read-decide-write step serialized. Lock duration is unchanged for a real build, since the DELETE loop already held it across the chunking pass; a no-op build now takes and releases it in microseconds, and WAL keeps readers unblocked. A failed build rolls back explicitly rather than holding the lock while recording a revision the index does not reflect.

Thanks to baz-reviewer[bot] for catching 2 and 3.

Test placement

tests/unit/test_rag.py is skipped wholesale (pytestmark = pytest.mark.skipif(True, ...)) while the FTS5 indexer leaks memory, so a regression test added there would never run. The new tests get their own file and index one file each.

Verification

Reverting each fix alone, with everything else in place, fails the tests that cover it:

Reverted Failing
depends_on argument all 3 TestChunkerMemoInvalidation
chunker_changed in the mtime gate both TestIndexChunkerRevision reindex tests
BEGIN IMMEDIATE test_write_lock_is_taken_before_the_revision_is_read

test_a_second_builder_cannot_write_while_a_build_is_in_flight passes on the pre-fix code too - by the time chunking runs, the DELETEs have already opened the transaction. It is there to guard the atomicity property against a later move to autocommit or per-file commits, not as a regression test for 3.

pytest tests/unit/test_rag_memo_invalidation.py \
       tests/unit/test_fingerprint.py \
       tests/unit/test_knowledge_graph.py \
       tests/unit/test_rag.py \
       tests/unit/test_context_budget_enforcement.py -q
37 passed, 36 skipped

ruff check / ruff format --check   pass
mypy src/bernstein/core/knowledge/rag.py   Success: no issues found in 1 source file

Docs

docs/concepts/fingerprint-memoization.md gains the self-declaring-module shape and a limitation note on caches sitting above the memo layer. The key column for knowledge/rag.py is corrected - it listed an embedder_id component the key never had.

Base

Stacked on #3486, which adds depends_on. GitHub retargets this to main when that merges.

Acknowledged, not fixed here

current_paths is collected before the write lock (3740365491). Accurate as a description, but not a defect this PR should close:

  • It predates the PR and is narrower because of it. Before, the lock was acquired implicitly at the first DELETE, two SELECTs after the walk. Now BEGIN IMMEDIATE runs immediately after the walk, so less can happen in between.
  • Recollecting under the lock would not establish a guarantee. A SQLite write lock does not lock the filesystem, and files can be created or removed during the walk itself. The window shrinks; it never closes.
  • The states it produces are self-correcting and biased safe. A file deleted after the walk keeps its chunks until the next build. A file created after the walk is picked up by the next build - that is what incremental indexing means. A file modified after the walk stores the walk-time mtime, which is older than the content actually read, so the next build reindexes it rather than skipping it.

Closing this properly means deciding what freshness the index promises, which is a larger change than a chunker-invalidation fix should carry.


Generated description

Below is a concise technical summary of the changes proposed in this PR:
Invalidate RAG chunk memo entries and incremental index rows when chunker code changes, using code_digest, depends_on, and atomic SQLite locking in CodebaseIndexer. Track extractor revisions in the knowledge graph cache so get_or_build_knowledge_graph rebuilds stale graphs even within the freshness window, with regression coverage and updated memoization documentation.

TopicDetails
Graph revision tracking Track the extractor revision in graph metadata and rebuild recently created graphs when their extractor no longer matches, including pre-revision databases and mid-build changes.
Modified files (3)
  • docs/concepts/fingerprint-memoization.md
  • src/bernstein/core/knowledge/knowledge_graph.py
  • tests/unit/test_knowledge_graph.py
Latest Contributors(2)
UserCommitDate
sanderchernitsky@gmail...fix(knowledge): record...August 09, 2026
chernistryfix(persistence): key ...August 08, 2026
RAG chunk invalidation Invalidate memoized chunks and reprocess unchanged files when the RAG chunker revision changes, while serializing revision checks and writes with BEGIN IMMEDIATE.
Modified files (2)
  • src/bernstein/core/knowledge/rag.py
  • tests/unit/test_rag_memo_invalidation.py
Latest Contributors(2)
UserCommitDate
sanderchernitsky@gmail...Merge origin/main into...August 08, 2026
alex@alexchernysh.comfix(knowledge): decide...August 08, 2026
Review this PR on Baz | Customize your next review

…t just the shim

`_extract_symbols_for_memo` is a one-line delegate to
`parse_file_symbols`, so its body is byte-identical before and after any
rewrite of `ast_symbol_graph`. `fingerprint` hashes only the decorated
function's own source, so the memo store kept serving symbol data built
by the previous parser for every file whose bytes had not changed - the
recent import-resolution change did not take effect on already-indexed
files.

Add `memoize_persistent(..., depends_on=...)`: declared callables and
modules contribute a source digest to the key. The knowledge-graph site
declares the whole `ast_symbol_graph` module rather than
`parse_file_symbols` alone, so the key also moves when a private helper
changes (the import-resolution rules live in one) and when the
`FileSymbols` dataclass gains a field - the latter would otherwise be
absent from an unpickled entry and raise `AttributeError` at the access
site instead of falling back to the default.

Omitting `depends_on` leaves keys byte-identical to the previous scheme,
so the other memo sites keep their caches.

Correct the shim docstring, which claimed the fingerprint already
captured changes to `parse_file_symbols`.
… just the shim

`_chunk_for_memo` only dispatches to `_extract_python_chunks` or
`_line_chunks`, so its body is byte-identical before and after any
rewrite of either. `fingerprint` hashes only the decorated function's
own source, so the memo store kept serving chunks shaped by the previous
chunker for every file whose bytes had not changed - a fix to chunk
shaping never reached an already-indexed file.

Declare the module as a memo dependency. The chunkers live beside the
shim rather than in another file, so the site names its own module via
`sys.modules[__name__]` instead of importing itself, which keeps the
reference on the canonical module object whichever import path loaded it
first. Module granularity also covers the syntax-error fallback edge
between the two chunkers and the chunk-size and overlap defaults, none of
which a hash of the entry point alone can see.

Correct the shim docstring, which claimed the fingerprint already
captured chunker changes, and the key column in the memoization concept
doc, which listed an `embedder_id` component the key never had.

The regression test gets its own file: `tests/unit/test_rag.py` is
skipped wholesale while the FTS5 indexer leaks memory, so a test added
there would never run.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review-bot acknowledgement summary

  • Must-address findings: 5 (5 acknowledged, 0 open)
  • Informational findings: 6

Review coverage for head cf7da9ca

  • baz-reviewer[bot]: reviewed - reviewed this head commit

All must-address findings are resolved or acknowledged.

Comment thread src/bernstein/core/knowledge/rag.py
…oves

bot-ack: 3740340955

Keying the memo store on the chunker source fixes the layer below the
one that decides whether to call it. `_build_inner` re-reads a file only
when its mtime moved, so for an unchanged file the chunker is never
invoked and the memo key never consulted - the rows the previous chunker
wrote stay in FTS until that file happens to be edited, and searches keep
returning them.

Record `code_digest` of the chunker module in a new `index_meta` table
and reindex every file when it stops matching. It is the same digest that
keys the memo store, so the two layers cannot disagree about which
chunker is current.

An index written before this change has no stored revision, so the first
build after upgrading reprocesses everything once. Files are re-read but
their chunks still come from the memo store when the chunker source
itself has not moved, so that sweep costs IO, not re-chunking.
Comment thread src/bernstein/core/knowledge/rag.py
bot-ack: 3740356220

The revision read ran on a deferred transaction, which SQLite only
escalates to a write lock at the first DELETE further down. Two processes
can hold different chunker revisions - an upgrade while a long-lived
process is still running - so the older one could read the revision the
newer one had just committed, conclude the chunker changed, reindex with
its own older chunker and record that as current. The newer build is
undone and the pair thrash until the older process exits.

Take the lock with BEGIN IMMEDIATE before the read, so the whole
read-decide-write step is serialized. Lock duration is unchanged for a
real build - the DELETE loop already held it across the chunking pass -
and a no-op build now takes and releases it in microseconds. WAL keeps
readers unblocked either way.

Roll back explicitly if the build raises, so a failed run cannot leave
the lock held while recording a revision the index does not reflect.
@baz-reviewer

baz-reviewer Bot commented Aug 8, 2026

Copy link
Copy Markdown

⚠️ Advanced Security cannot run on this PR.

Your organization's Advanced Security usage limit has been reached. To continue using Advanced Security reviews, please upgrade your plan or increase your usage limits in your account settings.

Comment thread src/bernstein/core/knowledge/rag.py
baz-reviewer[bot]
baz-reviewer Bot previously approved these changes Aug 8, 2026
Base automatically changed from fix/memo-key-extractor-version to main August 8, 2026 08:55
@baz-reviewer
baz-reviewer Bot dismissed their stale review August 8, 2026 08:59

Baz dismissed its prior approval because a re-review found new findings.

Comment on lines +114 to +118
_memoized_extract = memoize_persistent(
_kg_memo_store,
site="knowledge_graph",
depends_on=(_ast_symbol_graph,),
)(_extract_symbols_for_memo)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh graph masks parser revisions

depends_on=(_ast_symbol_graph,) invalidates memo entries only after build_knowledge_graph runs, so get_or_build_knowledge_graph can serve a graph with stale symbols/import edges for up to 30 minutes through query_impact and export_graph_summary after an ast_symbol_graph fix — should we persist an extractor revision/digest and require it to match before the built_at fast path?

Severity

Baz can fix this - reply apply commit / apply pr to fix without changes, or comment to modify the plan.

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`src/bernstein/core/knowledge/knowledge_graph.py` around lines 114-118, update the
`get_or_build_knowledge_graph`/graph caching logic so `depends_on=(_ast_symbol_graph,)`
invalidation also invalidates recently built graph databases. Persist the current
extractor/module digest or revision in the graph metadata when building, and require it
to match before taking the 30-minute fresh-enough fast path; otherwise force a rebuild
so `query_impact` and `export_graph_summary` cannot serve stale symbols or import edges
after parser changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 45b4c13 addressed this comment by persisting an extractor revision in graph metadata and requiring it to match before the fresh-enough fast path. A mismatched or legacy revision now forces the graph to rebuild.

`fingerprint.py` landed on main with the stat-token freshness check from
that branch's review, which is a strict superset of the copy here - the
shared change plus re-hashing a module whose file moves under a running
interpreter. Main's `fingerprint.py`, `test_fingerprint.py` and
`test_knowledge_graph.py` taken wholesale; main's fingerprint tests also
cover an in-process reload case this branch's did not.

The RAG work is preserved: the `knowledge/rag.py` key row, the
`sys.modules[__name__]` guidance for a shim whose delegates sit beside it,
and the limitation about a cache upstream of the memo layer needing its own
revision gate.

`_edit_chunker_source` wrote a bare digest into `_SOURCE_DIGEST_CACHE`,
which is now `(freshness_token, digest)`. It writes the pair and keeps the
existing token, so the substituted digest still reads as current - a fresh
token would send the next lookup back to the unedited file and quietly undo
the simulated edit, leaving the three invalidation tests asserting nothing.
…sion

bot-ack: 3740379624

`get_or_build_knowledge_graph` returned the cached database whenever
`built_at` was inside the age window, without consulting the extractor.
`depends_on` invalidates memo entries only when the memoised extractor is
actually called, and this fast path returns before that happens - so an
`ast_symbol_graph` fix was masked for up to the whole window while
`query_impact` and `export_graph_summary` kept serving the symbols and
import edges the fix was meant to correct.

This is the limitation this branch already documents - a cache upstream of
the memo layer needs its own revision gate - present one layer up from the
site it was written about. The gate is the same one `knowledge/rag.py`
applies to its index: record `code_digest` over the extractor module at
build time, and require it to match before taking the fast path. Both
layers read the same digest, so they cannot disagree about which extractor
is current.

A database written before the key existed reads as `None` and rebuilds
once, which is the safe direction.

Verified: with the gate removed, a graph inside the window is served after
the extractor revision moves, and a database with no recorded revision is
trusted. Both now rebuild, and a current graph inside the window is still
not rebuilt.
@chernistry

Copy link
Copy Markdown
Collaborator Author

Verdict: fixed in 45b4c13b. The finding is right, and it is this branch's own documented limitation showing up one layer above the site it was written about.

get_or_build_knowledge_graph returned the cached database whenever built_at was inside the window, without consulting the extractor. depends_on invalidates memo entries only when the memoised extractor is actually called, and that fast path returns before it is — so an ast_symbol_graph fix was masked for up to the full window, with query_impact and export_graph_summary serving exactly the symbols and import edges the fix was meant to correct.

The gate is the one knowledge/rag.py already applies to its index, which is why it fits rather than being invented: record code_digest over the extractor module at build time, require it to match before taking the fast path. Both layers read the same digest, so they cannot disagree about which extractor is current.

A database written before the key existed reads as None and rebuilds once — the safe direction, and worth stating because it is the one case where the gate deliberately does not trust silence.

Verified by removing the gate rather than by reading it:

current graph in window extractor revision moved no recorded revision
without the gate not rebuilt not rebuilt (stale served) not rebuilt
with the gate not rebuilt rebuilt rebuilt

The first column matters as much as the others — a gate that rebuilt every time would pass the staleness assertions while destroying the point of the window.

Comment on lines +221 to +224
connection.execute(
"INSERT INTO metadata(key, value) VALUES(?, ?)",
("extractor_revision", _extractor_revision()),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Freshness gate trusts graph from older extractor

_write_graph computes extractor_revision only after build_knowledge_graph has run build_semantic_graph and _collect_file_nodes_and_edges, so a mid-build change can produce graph data with the old extractor while code_digest(_ast_symbol_graph) records the new one. Then get_or_build_knowledge_graph sees a matching revision and reuses stale output — should we capture the revision before parsing, verify it before commit, and abort/retry if it changed?

Severity

Baz can fix this - reply apply commit / apply pr to fix without changes, or comment to modify the plan.

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`src/bernstein/core/knowledge/knowledge_graph.py` around lines 221-224, fix
`_write_graph` so it does not compute and persist the extractor revision only after
graph extraction has completed. Capture the revision before
`build_semantic_graph`/`_collect_file_nodes_and_edges`, pass that captured value into
the write path, and verify the current revision still matches immediately before
committing; abort or retry the build if it changed, rather than stamping the database
with a digest that did not produce its nodes and edges.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit cf7da9c addressed this comment by capturing the extractor revision before parsing and passing that value into _write_graph, preventing a mid-build edit from stamping the newer revision onto older graph data. It does not add the requested pre-commit verification or retry.

bot-ack: 3740567138

The revision was computed inside `_write_graph`, after `build_semantic_graph`
and `_collect_file_nodes_and_edges` had already produced the nodes and edges.
`code_digest` re-reads the extractor file, so an edit landing mid-build gave
the new digest, while the graph itself came from the module already resident
in memory. The gate added in the previous commit would then see a matching
revision and trust that stale output indefinitely - the very failure the gate
exists to prevent, moved into the write path.

Captured once before any parsing starts and threaded through to the write.
A build overtaken by an edit now records the revision it parsed under, which
no longer matches the current one, so the next call rebuilds. No abort or
retry is needed: the mismatch already means exactly "rebuild".

Verified by computing it at write time again - the test then observes the
post-edit revision stamped onto pre-edit output and fails.
@chernistry

Copy link
Copy Markdown
Collaborator Author

Verdict: fixed in cf7da9ca. Correct, and it is the previous fix's own failure mode relocated into the write path — the gate would have trusted exactly the output it exists to distrust.

code_digest re-reads the extractor file, so computing the revision inside _write_graph named whatever that file said once the build finished, while the nodes and edges came from the module already resident in memory. An edit landing mid-build therefore stamped the new revision onto output the old extractor produced, and every later call matched and reused it.

Captured once before any parsing starts and threaded through to the write.

On the abort/retry you suggested: not needed, and I would rather not add a retry loop that has to be reasoned about separately. A build overtaken by an edit now records the revision it parsed under, which no longer equals the current one — so the next call mismatches and rebuilds. The mismatch already means "rebuild"; recording honestly is enough to reach it, and there is no window where the wrong answer is served twice.

Verified by putting the computation back at write time: the test then observes the post-edit revision recorded against pre-edit output and fails. The three assertions from the previous round still hold — a current graph inside the window is not rebuilt, a moved revision is, and a database with no recorded revision is.

@github-actions github-actions Bot added size/l and removed size/m labels Aug 9, 2026
@chernistry
chernistry merged commit 23c8a5d into main Aug 9, 2026
57 of 60 checks passed
@chernistry
chernistry deleted the fix/memo-key-rag-chunker branch August 9, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant