Skip to content

fix(retrieval): #888 chunk-backed vectors — a document's embedding is no longer its first half - #889

Merged
jasonssdev merged 10 commits into
mainfrom
retrieval-chunking-888
Aug 27, 2026
Merged

fix(retrieval): #888 chunk-backed vectors — a document's embedding is no longer its first half#889
jasonssdev merged 10 commits into
mainfrom
retrieval-chunking-888

Conversation

@jasonssdev

Copy link
Copy Markdown
Owner

Closes #888

The defect, in one measurement

state/reindex.py composed title+description+tags+full body and sent it in a single embedder.embed([text])[0] call. bge-m3 caps at 8192 tokens. Embedding a 56 KB source whole, then each half, and comparing:

cos(full, FIRST half)  = 1.0000     <- exactly identical
cos(full, SECOND half) = 0.6582

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 dense fusion 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 one VecHit per concept_id before returning, and neighbors() reads a derived doc_vectors table holding normalize(mean(normalize(chunk_i))).

That choice is why the diff is small:

src/openkos/cli/main.py          |  92 ++++----
src/openkos/state/reindex.py     | 146 +++++++++---
src/openkos/state/vectorstore.py | 276 ++++++++++++++++++-----

Zero production diff in retrieval/answer.py, retrieval/fusion.py, graph/proximity.py. Citation, _split_attribution's positional mapping, --save provenance and _assemble_context's sensitivity re-check are untouched because the collapse happens below them.

Also fixed here, because the migration triggers it: reindex printed embedding 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.db is dropped and recreated on open when a legacy-shape store is detected, and vector_meta is cleared — without that clear, dropped vectors plus a surviving hash cache read as cache hits forever and the store stays permanently empty. Users run openkos reindex once. Measured cost on the 32-document bundle: 40 embed calls, 1.25x — only the two long documents chunk, 5 chunks each.

Evidence

  • uv run pytest unpiped: 5687 passed, 1 skipped. mypy clean over 273 files. ruff clean.
  • vec0 0.1.9 spike run against the installed extension: accepts chunk_index INTEGER, returns it from a KNN projection, and DELETE FROM vectors WHERE concept_id = ? removes all N chunk rows in one statement.
  • New gate evals/pair_nomination/ (design D9). Nothing previously measured live candidate-pair nomination — edge_typing and contradictions score classifiers over fixed fixtures. Baseline captured on pre-change code before the schema commit.
  • Every safety-critical seam was mutation-tested: the bug was injected, the test confirmed red for the right reason, then reverted.

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/transcription1 vs decisions/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 commit 5b596c2. 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

  • Task 5.2 is deliberately unrun: re-measuring 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, and query_identity measures the question-vector space this change does not touch.
  • The pair-nomination signal still does not separate. Pre-existing, not a regression.
  • ~1,995 authored lines against this repo's 400-line review convention, hence 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

…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.
@jasonssdev jasonssdev added the size:exception PR exceeds the 400-line review budget with maintainer-accepted exception label Aug 26, 2026
@jasonssdev
jasonssdev merged commit 750d7d0 into main Aug 27, 2026
7 checks passed
@jasonssdev
jasonssdev deleted the retrieval-chunking-888 branch August 27, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:exception PR exceeds the 400-line review budget with maintainer-accepted exception

Projects

None yet

Development

Successfully merging this pull request may close these issues.

no chunking on the read side: half of every long Source is invisible to dense retrieval

1 participant