Skip to content

fix(kb): re-embed vectorless rows, report the real model, add --force - #14

Merged
josaum merged 1 commit into
mainfrom
fix/kb-build-force-and-stamp
Nov 4, 2026
Merged

josaum merged 1 commit into
mainfrom
fix/kb-build-force-and-stamp

Conversation

@josaum

@josaum josaum commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Follow-up to #13. That PR made stored vectors readable; these three faults are
why a collection could still be stuck lexical-only with no way to tell and no
way out.

1. A vectorless row was never re-embedded

The carry decision matched on presence in the carried map rather than on
whether a vector was actually there:

match carried.get(&chunk) {
    Some((vector, model)) => (vector.clone(), model.clone()), // vector may be None!
    None => { to_embed.push(...); (None, None) }
}

A chunk from a lexical-only build is present with None, so it took the first
arm and was carried forward empty. The content hash never changes, so turning
an encoder on later had no effect whatsoever
— every subsequent kb build
reported embedded: 0 and reused the empty slots. The only escape was deleting
~/.leio-code/kb/<name>.{arrow,meta.json} by hand.

Extracted as reusable_vector, which reuses a row only when it really has a
vector.

2. embedding_model named an encoder that never ran

.or_else(|| std::env::var("LEIO_CODE_EMBED_MODEL").ok())

With no row carrying a model, this reported whatever the environment happened
to say. That produced the genuinely misleading combination embedded: 0
alongside a confident "embedding_model": "google/embeddinggemma-2" — a
collection with no vectors looking fully embedded.

Now only a model that actually produced a stored vector is reported, and the
embed pass stamps rows from config::embed_model instead of reading the env
var directly, so the stamp follows the same resolution order as the request.

3. lexical_only described the run, not the collection

It was left at its default when nothing needed embedding, so a collection with
no vectors at all reported lexical_only: false. It is now derived from the
finished rows.

4. New: --force

Discards carried vectors and re-embeds everything. Needed when the encoder
changes rather than the content — switching model or width leaves every content
hash identical, so nothing would otherwise be re-embedded.

Verification

cargo fmt --check, cargo clippy --all-targets and the full suite are clean
(556 lib tests + all integration targets). New regression test
kb::lexical_only_rows_are_requeued_for_embedding covers all three carry cases.

End to end against a local encoder, same collection throughout:

step embedded lexical_only embedding_model
incremental, vectors present 0 false google/embeddinggemma-2
--force, encoder unreachable 0 true null
plain rebuild of that store, encoder up, no --force 67 false google/embeddinggemma-2

Row two is what previously reported lexical_only: false with a model name.
Row three is the fix for #1 — on the old code this was a no-op; the Arrow
embedding column ends with 0 nulls of 67, and kb query returns
cosine: 0.6711.

Note for the private tree: build_kb's signature becomes
(name, repo_root, force). There is no other Rust caller — the CLI is the only
one, and cargo build would fail otherwise — but a vendored copy will need the
same change.

🤖 Generated with Claude Code

Three related faults in `kb build`, all of which made a lexical-only
collection look embedded or made it impossible to fix one.

1. A surviving chunk was never re-embedded once it existed, even with no
   vector. The carry decision matched on presence in the carried map, not
   on whether a vector was actually there, so every row of a lexical-only
   build was stranded: the content hash does not change, so enabling an
   encoder later had no effect at all until the store was deleted by
   hand. Extracted as `reusable_vector`, which only reuses a row that has
   a vector.

2. `embedding_model` fell back to `LEIO_CODE_EMBED_MODEL` when no row
   carried a model, so a build that embedded nothing still reported an
   encoder that had never run -- `embedded: 0` alongside a confident
   model name. It now reports only a model that actually produced a
   stored vector, and the embed pass stamps rows from
   `config::embed_model` rather than reading the env var directly.

3. `lexical_only` was a property of the run, not the collection: it was
   left at its default when nothing needed embedding, so a collection
   with no vectors at all reported `lexical_only: false`. It is now
   derived from the finished rows.

Also adds `--force`, which discards carried vectors and re-embeds
everything. Needed when the encoder itself changes: content hashes are
unchanged in that case, so nothing would otherwise be re-embedded.

Verified end to end against a local encoder: `--force` with the encoder
unreachable now reports `lexical_only: true` and `embedding_model: null`
(previously `false` and a model name), and a plain rebuild of that
lexical-only store with the encoder up re-embeds all 67 rows, leaving
zero nulls in the Arrow `embedding` column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@josaum
josaum merged commit aca1aaa into main Nov 4, 2026
4 checks passed
@josaum
josaum deleted the fix/kb-build-force-and-stamp branch November 4, 2026 03:02
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.

1 participant