Persist the corpus and make index loading deterministic - #32
Conversation
Three related defects in save/load, all of which undermine explainability or reproducibility. They ship together because fixing the first without the second would make things worse. 1. save() dropped the corpus. Only embeddings and config were written, so after any reload positive_corpus/negative_corpus were None and explanations reported the row number of a match instead of the matched sentence. Adds an optional corpus.json alongside the existing two files. 2. _apply_negative_ratio() reshuffled the embeddings without touching the corpus, so the two parallel lists came apart. Latent until now because no corpus was ever loaded - fix 1 would have activated it, turning "a useless number" into "a real but unrelated sentence presented as the reason for a score", which fools a reviewer in a way that obvious breakage does not. Both lists are now sliced together through one _take_rows helper. 3. torch.randperm ran without a generator, so every load() kept a different random subset of negatives. A saved index behaved like a slightly different model each time it was loaded: on the shipped example index the same text scored 0.028636 on one load and 0.011523 on the next. load() now takes an optional seed. Backward compatibility: corpus.json is optional, so indices saved by earlier versions load exactly as before. load_index() keeps its 3-tuple return value - the corpus is read by a separate load_corpus() rather than widening a function that external callers unpack positionally. Both new arguments are keyword-only or last-with-a-None-default, so positional callers are unaffected. Seeding uses a private torch.Generator rather than a global seed, so a caller's other randomness is left alone. Safety valves: a corpus whose length does not match its embeddings is refused at save time (ValueError) and discarded with a warning at load time. Degrading to row numbers is recoverable; naming the wrong sentence is not. Adds 13 tests covering the round trip, backward compatibility, seeded and unseeded downsampling, alignment after downsampling, and explanations naming real text after a reload. Co-authored-by: Cursor <cursoragent@cursor.com>
d64f37e to
cb513d9
Compare
Stack map (updated after #31 merged)#31 has landed on Suggested merge order: #32 → #33 → #34, with #35 mergeable any time after #32. #35 branches off #32 rather than the stack tip, because its only real dependency is the
Test counts on each branch: 67, 82, 87 and 79 respectively, all passing, with flake8 output identical to each PR's base. A note for whoever merges this. #31 was squash-merged, which collapsed its four commits into one new commit on That will happen again to #33 and #35 each time a PR in this stack is squash-merged, and the remaining PRs will need the same rebase. If you'd prefer to avoid that churn, a merge commit or rebase merge on the rest of the stack keeps the commits as genuine ancestors of |
vcai4071
left a comment
There was a problem hiding this comment.
Looks good overall. A few comments.
|
|
||
| # The corpus is small text rather than a tensor blob, so smart_open handles both | ||
| # local and S3 destinations directly, the same way the config file does. | ||
| if positive_corpus is not None or negative_corpus is not None: |
There was a problem hiding this comment.
Do we want to require positive_corpus and/or negative_corpus? Otherwise, it seems we can overwrite the embeddings, but then if the corpuses aren't provided, then previously written corpuses would be used for explanations.
Alternatively, if they are not supplied, we can write null corpuses?
There was a problem hiding this comment.
Basically, positive_corpus and/or negative_corpus (text) are both option and it is added in this PR. We want to keep it this way and also work backward comparable.
Good catch, this is a real bug. Save embeddings + corpus to a path, then save new embeddings to that same path without a corpus, and corpus.json is left behind describing the old rows.
I will always write corpus.json and store nulls when the corpora aren't supplied, rather than requiring them.
| if corpus is None or embeddings is None: | ||
| return | ||
| if len(corpus) != embeddings.shape[0]: | ||
| raise ValueError( |
There was a problem hiding this comment.
Do we want to log an error and not write the corpuses if this is the case, rather than failing the entire run? Alternatively, we can at least write embeddings first, and then fail? Worried about calculating all the embeddings for nothing.
There was a problem hiding this comment.
The worry about wasted compute is fair, but I'd keep the raise. Two reasons.
This check can only fire on a caller bug. In every build path we document, the corpus is the same list that was encoded:
positive_embeddings = torch.tensor(model.encode(positive_examples, ...))
...
positive_corpus=positive_examples,
so the lengths match by construction, and a mismatch means the caller subsampled one side or passed the wrong variable. That's deterministic: it fails identically on rerun. Logging and skipping the corpus would instead write an expensive artifact that has permanently lost its explanations, and we'd likely only notice much later when production explanations show row numbers instead of text. That also undercuts the invariant the rest of the module works to keep (_corpus_if_aligned on load, _take_rows on subsetting).
On writing the embeddings first and then failing, I'd push back harder, because it will cause inconsistent embedding and corpus.json. It also rarely happens if run correctly.
| "normalize_embeddings": True, | ||
| } | ||
| self.encoding_kwargs.update(encoding_additional_kwargs) | ||
| self.positive_corpus = positive_corpus |
There was a problem hiding this comment.
We could potentially have the size check in the init, comparing the corpus to embeddings.
There was a problem hiding this comment.
Will check if we can add this in follow up PR.
Skipping the corpus write when no corpus was supplied left an earlier save's corpus.json sitting next to freshly written embeddings. A stale corpus whose row count happens to match the new embeddings passes _corpus_if_aligned on load, so explanations named the wrong text with no warning at all. Index sizes are typically a fixed sample size, which makes a same-size rebuild the likely case rather than a corner one. Write the file unconditionally, storing nulls when there is no corpus. Callers keep both parameters optional, load_corpus already maps a JSON null to None exactly as it does a missing file, and an absent file still means the index was saved before corpus support. Saving without a corpus now clears any corpus at that path, which drops the implicit "keep the previous corpus" behaviour - only ever correct when the new embeddings are the same rows in the same order, and nothing enforced that. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Goal
To make a saved index a faithful, self-contained, reproducible artifact — so that loading one gives you back exactly what you saved, and gives you the same thing every time. Right now it fails both halves of that. Saving quietly loses information, and loading quietly adds randomness.
Fixes
Three related save/load bugs. They ship together because fixing the first without the second would make things worse.
1.
save()dropped the corpus. Only the embeddings and config were written, so after anysave()→load()round trippositive_corpusandnegative_corpuswereNone, and explanations reported a row number instead of the matched sentence. Adds an optionalcorpus.jsonnext to the existing two files.2.
_apply_negative_ratio()reshuffled the embeddings without the corpus, so the two parallel lists came apart. Latent onmainbecause nothing loads a corpus today — fix 1 is what activates it, turning "a useless number" into "a real but unrelated sentence presented as the reason for a score". Both lists now go through a single_take_rows()helper.3.
torch.randpermran without a generator, so everyload()kept a different random subset of negatives and a saved index behaved like a slightly different model each time it was loaded.load()now takes an optionalseed, using a privatetorch.Generatorso the caller's other randomness is left alone.Backward compatibility
corpus.jsonis optional, so indices saved by earlier versions load exactly as before.load_index()keeps its 3-tuple return value — the corpus is read by a separateload_corpus()rather than widening a function that external callers unpack positionally. Both new arguments are keyword-only or last with aNonedefault.Safety valves
A corpus whose length doesn't match its embeddings is refused at save (
ValueError) and discarded with a warning at load. Degrading to row numbers is recoverable; confidently naming the wrong sentence is not.Test plan
13 new tests, 67 total (was 54): round trip, backward compatibility, seeded vs unseeded downsampling, alignment after downsampling, and explanations naming real text after a reload.
flake8output is unchanged from base anddocs/check_docs_sync.pyreports in sync. README documentscorpus.jsonandseed=.Base of #33, #34 and #35.