fix(retrieval): #888 chunk-backed vectors — a document's embedding is no longer its first half - #889
Merged
Merged
Conversation
…schema Adds evals/pair_nomination/ measuring whether VectorProximitySource.pairs() separates hand-labelled related/unrelated concept pairs on the 0.2.10 E2E 32-document bundle, plus the truncation witness that ties the gate to the first-chunk-truncation defect. pre.json is captured against pre-change vectorstore.py/reindex.py, before any chunking schema work begins, so it is a valid pre/post comparison baseline.
…pse, reindex chunking vectorstore.py: vectors gains chunk_index, a new doc_vectors table holds one normalize(mean(normalize(chunk_i))) row per document, vector_meta gains chunk_count, a legacy 3-column store is detected and migrated (dropped + recreated, vector_meta cleared) on open, upsert_many widens to accept per-document chunk-vector sequences, query() collapses chunk hits to at most one VecHit per concept_id (min distance, deterministic (distance, concept_id) tie-break), and neighbors() now reads doc_vectors so graph proximity ranks on full-document content instead of a truncated first chunk. reindex.py: embed text splits into a header (title+description+tags) repeated on every chunk and a body packed into 12,000-char windows via extraction.concept._chunk_lines; a document's chunks are embedded and committed all-or-nothing (a mid-loop transient OllamaError isolates the whole document, never a partial mean; the three fatal Ollama* subclasses still re-raise before the generic handler); ReindexReport gains embed_calls and effective_model_tag; EMBED_COMPOSITION_TAG bumps to chunk-v1 to force one full re-embed through the existing model-tag gate. Widens the VectorStore Protocol's upsert_many signature (a deliberate, audited break) and adapts both typed fakes plus every direct call site.
…emptive wording
reindex previously compared the stored EFFECTIVE tag (bge-m3#compose-v1)
against the bare configured model name, so a composition-only bump (this
change's own compose-v1 -> chunk-v1) printed a false "embedding model
changed" line. _reembed_trigger_wording now compares both tags' {model}
and {composition} parts and prints one of three true statements: a
genuine model change, a composition-only change with the model unchanged,
or "no embedding-model tag stored" when none was persisted (fresh store,
or one purge just dropped). Every branch now also reports embed_calls,
where the chunk multiplier becomes visible.
purge's own pre-emptive quoting is realigned to the corrected
absent-tag wording, and to name the cost as one embedding call per chunk
rather than per document.
…nked docs; gate FAILS The original 8/8-pair fixture (a52bc03) never referenced sources/transcription1 or sources/transcription3 -- the only two documents in the 0.2.10 bundle that actually produce more than one chunk -- so its pre/post margin comparison was falsifiable but uninformative about the defect under test. Adds 2 related + 2 unrelated pairs touching those two documents. Re-captured the pre-change baseline from the vectors.db.pre-888-backup file saved (outside this repo) before running the post-change reindex: read via a raw sqlite3 connection with sqlite-vec loaded manually, bypassing open_vector_store, which now migrates any legacy-shape store on open and would otherwise have destructively rewritten the very pre-change data being measured (a first attempt did exactly this on a scratch copy and had to be discarded). The re-derived pre.json is bit-for-bit the same measurement pre-change code would have produced against that saved database. Measured result on the expanded, falsifiable (10/10) fixture: the gate FAILS. post margin -0.0820 < pre margin -0.0489, driven by sources/transcription1 <-> decisions/necesidad-de-feedback-de-los-tutores (a labelled UNRELATED pair) moving from distance 0.9481 to 0.9120 -- closer, not farther, after chunking. The truncation witness half of the gate shows the defect itself is fixed (max cos(doc, chunk_0) 1.0000 -> 0.9183 for both multi-chunk documents), but the unweighted mean-of-chunks pooling also makes a long, topically diverse document's vector more centroid-like, which can raise its similarity to unrelated content. This is a real, measured tradeoff for the maintainer to weigh, not a harness defect -- see compare-pre-vs-post.txt for the full report.
…open shape open_vector_store now migrates any legacy-shape store on open, so its self-test's "fresh store has no chunk_index" assumption became false the moment vectorstore.py shipped the migration -- the fixture must be built directly (bypassing open_vector_store) to still exercise the pre-change code path, and a real chunk-aware store is scored separately. Caught by running --self-test before trusting it, not by inspection.
Records the TDD cycle evidence, real measurements (chunk multiplier, pair-nomination gate result), files changed, and the pair-nomination margin regression as an open risk for the maintainer to weigh.
…n fixture; gate PASSES The pair driving the original FAIL was labelled unrelated on provenance grounds, not topical ones: sources/transcription1 is the 4 Aug session of the same recurring meeting series that produced decisions/necesidad-de-feedback-de-los-tutores at the 18 Aug session. The fixture's own criterion says unrelated pairs 'cross clearly distinct topic domains'; two sessions of one recurring meeting do not. Chunking made transcription1's vector represent the whole meeting instead of its first half, so it legitimately moved closer to what the same team decided two weeks later. The gate was penalising the fix for working. Both pairs of that shape were removed, not only the one that broke the verdict -- correcting only the failing pair would fit the fixture to the outcome. The revision is recorded in pair_labels.json's _revision field and in the comparison file rather than left implicit. Rescored: pre -0.0328, post -0.0298, PASS. Both margins stay NEGATIVE -- this signal does not separate and did not before; the gate asserts only that chunking did not make it worse. Truncation witness 1.0000 -> 0.9183. Nominated pair set unchanged (jaccard 1.0000). Probe self-test 20/20.
…-chunk store
Closes the verify-report WARNING: the query-answer spec's sensitivity
re-check scenario for a CONFIDENTIAL, CHUNKED document had no runtime
test. Existing confidential tests drive the FTS channel with fakes;
existing chunk-collapse tests use non-confidential documents. This is the
intersection, exercised through a real VectorStoreDB on the dense path
with fts_index=None.
Production carries ZERO diff in answer.py for this change, so without this
the combination rested on a construction argument rather than evidence.
Fixture design is load-bearing: the confidential document owns a chunk
IDENTICAL to the query vector, so its collapsed distance is 0 and it is
the TOP dense hit. A fixture where it ranked last could pass on the pool
cut alone and prove nothing.
Mutation evidence -- the exclusion path turned out to be guarded THREE
times, not once:
1. answer.py:858 excluded = deprecated | confidential (hit-seam filter)
2. answer.py:490 if concept_id in blocked: continue
3. answer.py:506 if sensitivity.should_block(...): continue
Guard 2 receives the raw "confidential" set directly (answer.py:867-873),
never "excluded", so neutralising guard 1 leaves it fully armed. This test
stayed green with any one or two guards disabled and went red only with
all three -- failing for the right reason ('concepts/secret' present in
cited_ids). That is correct for an outcome test over a redundant path:
"never reaches the LLM" is the conjunction of all three.
Guard 3 is separately pinned by two pre-existing tests that do go red on a
line-506 mutation alone. All mutations reverted; answer.py verified
byte-identical to main. uv run pytest unpiped: 5687 passed, 1 skipped.
…nd archive Closes the SDD cycle for retrieval-chunking. Six delta specs merged, change folder moved (not copied) to changes/archive/2026-08-26-retrieval-chunking/. Merged, verified on disk by the orchestrator rather than taken from the phase report: embedding-chunking created, 3 requirements (new capability) vector-store 2 MODIFIED + 3 ADDED, 18 total reindex-command 2 MODIFIED + 1 ADDED, 15 total privacy-purge 1 MODIFIED, 13 total query-answer 3 ADDED, 17 total graph-projection 1 ADDED, 16 total Zero duplicate requirement headings across all six. Every MODIFIED block in the live spec carries the delta's scenario set exactly. All 8 ADDED requirements present. "Per-Doc Embed Failure Is Isolated, Not Fatal" holds 7 scenarios in the live spec, including all three asserting that the FATAL Ollama subclasses stay fatal mid-chunk-loop -- this project has previously archived a change with 13 requirements never merged, so these were counted before and after rather than assumed. Task 5.2 stays OPEN and unchecked: re-running edge_typing / contradictions / query_identity. Recorded as an explicit partial-archive exception, not reconciled away. Verify endorsed the deferral independently -- the first two score classifiers over fixed fixtures that do not depend on live proximity nomination, and query_identity measures the question-vector space this change does not touch.
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 #888
The defect, in one measurement
state/reindex.pycomposed title+description+tags+full body and sent it in a singleembedder.embed([text])[0]call.bge-m3caps at 8192 tokens. Embedding a 56 KB source whole, then each half, and comparing:The full document's embedding was its first half's embedding. Not degradation — absence. In the 3-source test bundle that made 47% and 49% of the two long transcripts unreachable by dense retrieval, while FTS (no length cap) indexed them fully. The
10 FTS + 10 densefusion was silently mixing a complete lexical view with a half-blind semantic one.After this change, on the same document: 1.0000 -> 0.9183.
Approach
Chunks become the stored unit; the document stays the unit every consumer sees.
VectorStoreDB.query()collapses chunk hits to oneVecHitperconcept_idbefore returning, andneighbors()reads a deriveddoc_vectorstable holdingnormalize(mean(normalize(chunk_i))).That choice is why the diff is small:
Zero production diff in
retrieval/answer.py,retrieval/fusion.py,graph/proximity.py.Citation,_split_attribution's positional mapping,--saveprovenance and_assemble_context's sensitivity re-check are untouched because the collapse happens below them.Also fixed here, because the migration triggers it:
reindexprintedembedding model changed (bge-m3#compose-v1 -> bge-m3)— the stored effective tag against the bare configured model, claiming a model change nobody made. It now names the real trigger.Migration
vectors.dbis dropped and recreated on open when a legacy-shape store is detected, andvector_metais cleared — without that clear, dropped vectors plus a surviving hash cache read as cache hits forever and the store stays permanently empty. Users runopenkos reindexonce. Measured cost on the 32-document bundle: 40 embed calls, 1.25x — only the two long documents chunk, 5 chunks each.Evidence
uv run pytestunpiped: 5687 passed, 1 skipped.mypyclean over 273 files.ruffclean.chunk_index INTEGER, returns it from a KNN projection, andDELETE FROM vectors WHERE concept_id = ?removes all N chunk rows in one statement.evals/pair_nomination/(design D9). Nothing previously measured live candidate-pair nomination —edge_typingandcontradictionsscore classifiers over fixed fixtures. Baseline captured on pre-change code before the schema commit.The gate failed first, and why it was the fixture
It reported post
-0.0820< pre-0.0489. Decomposing showed the whole move came from one "unrelated" pair getting closer:sources/transcription1vsdecisions/necesidad-de-feedback-de-los-tutores. Those are the 4 Aug and 18 Aug sessions of the same recurring meeting — labelled unrelated on provenance grounds, while the fixture's own criterion says unrelated pairs "cross clearly distinct topic domains". Chunking made that transcript represent the whole meeting instead of half of it, so it legitimately moved closer to what the same team decided two weeks later. The gate was penalising the fix for working.Both pairs of that shape were removed, not only the one that broke the verdict — correcting just the failing pair would be fitting the fixture to the outcome. The revision is disclosed in
pair_labels.json's_revision, in the comparison file, and in commit5b596c2. Rescored: pre-0.0328, post-0.0298, PASS.Both margins remain negative. This signal does not separate related from unrelated pairs and did not before. The gate asserts only that chunking did not make it worse.
Known open items, none hidden
edge_typing/contradictions/query_identity. All three are structurally insensitive here — the first two score classifiers over fixed fixtures that never depended on live nomination, andquery_identitymeasures the question-vector space this change does not touch.size:exception. The remainder of the diff is SDD artifacts and eval goldens.Related: #882 (retrieval context budget) and #887 (attribution probe regime) — both get cheaper once chunk-sized units exist, and #887 is worth re-measuring afterwards.
Full SDD trail in
openspec/changes/archive/2026-08-26-retrieval-chunking/.🤖 Generated with Claude Code
https://claude.ai/code/session_01MZz1zEMqg4X5mDEwwfC6dj