Skip to content

Commit ded6bd5

Browse files
Add index.subsample() to resize an index without re-encoding
Encoding a sentence produces the same numbers regardless of which index it ends up in, so a small index is just a large one with rows removed. Today there is no way to exploit that: resizing means re-encoding the whole corpus from scratch, which makes sweeping index size - the highest-leverage knob there is - prohibitively expensive for anyone outside the internal pipeline. subsample() copies the rows you want instead. On the shipped example index, building a 3x3 grid of (n_positive, ratio) configurations takes 9 ms, against roughly 12.5 s to re-encode the same 16,100 rows. Design decisions worth reviewing: - Returns a new instance and never mutates the receiver. Callers loop over sizes, so if each call shrank the original, run 2 would start from run 1's leftovers and every result after the first would be silently wrong. This differs from _apply_negative_ratio, which mutates in place - fine there because it runs once inside load(). - Positives are chosen first, because the ratio is defined relative to how many positives survive. - Invalid sizes raise rather than warn-and-continue. A grid search that quietly ignored a bad argument would emit rows describing an index nobody asked for. - scale_fn, encoding kwargs and model card all carry over; a dropped scale_fn would silently change scores for models like E5. The sentence model is shared rather than reloaded, since reloading it would reintroduce the cost being removed. - Reuses the _take_rows helper and the local-torch.Generator seeding convention, so corpus alignment is handled in one place across the whole library. Adds 15 tests, including that the original index is provably unchanged after repeated calls, and that corpus texts still track their own embedding rows on both sides of the index. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d64f37e commit ded6bd5

2 files changed

Lines changed: 292 additions & 0 deletions

File tree

src/sentinel/sentinel_local_index.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,129 @@ def _apply_negative_ratio(
397397
self.negative_embeddings.shape[0],
398398
)
399399

400+
def _select_subset(
401+
self,
402+
embeddings: torch.Tensor,
403+
corpus: Optional[List[str]],
404+
n_keep: Optional[int],
405+
generator: Optional[torch.Generator],
406+
label: str,
407+
) -> Tuple[torch.Tensor, Optional[List[str]]]:
408+
"""Randomly keep n_keep rows of one side of the index, corpus included.
409+
410+
Args:
411+
embeddings: The embeddings to select from.
412+
corpus: Matching texts, or None.
413+
n_keep: How many rows to keep. None or a value at least as large as the
414+
available rows keeps everything.
415+
generator: Optional seeded generator for a reproducible choice.
416+
label: "positive" or "negative", used in log messages.
417+
418+
Returns:
419+
Tuple of (embeddings, corpus) for the kept rows.
420+
"""
421+
available = embeddings.shape[0]
422+
423+
if n_keep is None or n_keep >= available:
424+
if n_keep is not None and n_keep > available:
425+
LOG.info(
426+
"Requested %d %s examples but the index only has %d - keeping all of them.",
427+
n_keep,
428+
label,
429+
available,
430+
)
431+
# Copy the corpus list so callers cannot mutate the original through the copy.
432+
return embeddings, (list(corpus) if corpus is not None else None)
433+
434+
indices = torch.randperm(available, generator=generator)[:n_keep]
435+
# Order does not affect semantic_search, but keeping the original relative
436+
# order makes the result far easier to diff and debug.
437+
indices = torch.sort(indices).values
438+
LOG.info("Keeping %d %s examples out of %d", n_keep, label, available)
439+
return _take_rows(embeddings, corpus, indices)
440+
441+
def subsample(
442+
self,
443+
n_positive: Optional[int] = None,
444+
neg_to_pos_ratio: Optional[float] = None,
445+
seed: Optional[int] = None,
446+
) -> "SentinelLocalIndex":
447+
"""Return a smaller copy of this index, reusing the existing embeddings.
448+
449+
Encoding a sentence produces the same numbers regardless of which index it
450+
ends up in, so a small index is just a large one with rows removed. This turns
451+
"re-encode everything" into "copy the rows you want", which is what makes
452+
sweeping index sizes affordable.
453+
454+
Corpus texts are kept aligned with the embeddings they describe, and the
455+
sentence model is shared with the copy rather than reloaded.
456+
457+
Args:
458+
n_positive: How many positive examples to keep. None keeps all of them.
459+
If larger than the index, everything available is kept.
460+
neg_to_pos_ratio: Negatives to keep per kept positive. None leaves the
461+
negatives untouched - note that shrinking the positives alone therefore
462+
*changes* the effective ratio, which is easy to do by accident.
463+
seed: Optional seed making the selection reproducible. Uses a private
464+
torch.Generator, so the caller's other randomness is unaffected.
465+
466+
Returns:
467+
A new SentinelLocalIndex. This instance is never modified.
468+
469+
Raises:
470+
ValueError: If the index has no embeddings, or if n_positive or
471+
neg_to_pos_ratio is not positive. Unlike load()'s ratio handling, which
472+
warns and carries on, this raises: a grid search that silently ignored a
473+
bad argument would emit result rows describing an index you did not ask for.
474+
"""
475+
if self.positive_embeddings is None or self.negative_embeddings is None:
476+
raise ValueError(
477+
"Cannot subsample an index without both positive and negative embeddings."
478+
)
479+
if n_positive is not None and n_positive <= 0:
480+
raise ValueError(f"n_positive must be positive, got {n_positive}.")
481+
if neg_to_pos_ratio is not None and neg_to_pos_ratio <= 0:
482+
raise ValueError(
483+
f"neg_to_pos_ratio must be positive, got {neg_to_pos_ratio}."
484+
)
485+
486+
generator = torch.Generator().manual_seed(seed) if seed is not None else None
487+
488+
# Positives first: the ratio is defined relative to how many positives survive,
489+
# so that count has to be settled before the negatives can be sized.
490+
positive_embeddings, positive_corpus = self._select_subset(
491+
self.positive_embeddings, self.positive_corpus, n_positive, generator, "positive"
492+
)
493+
494+
n_negative: Optional[int] = None
495+
if neg_to_pos_ratio is not None:
496+
n_negative = int(positive_embeddings.shape[0] * neg_to_pos_ratio)
497+
if n_negative <= 0:
498+
LOG.info(
499+
"Ratio %.4f against %d positives rounds to zero negatives - keeping 1, "
500+
"since an index with no negatives cannot score anything.",
501+
neg_to_pos_ratio,
502+
positive_embeddings.shape[0],
503+
)
504+
n_negative = 1
505+
506+
negative_embeddings, negative_corpus = self._select_subset(
507+
self.negative_embeddings, self.negative_corpus, n_negative, generator, "negative"
508+
)
509+
510+
# scale_fn, encoding_kwargs and model_card all carry over. A dropped scale_fn
511+
# would silently change scores for models like E5.
512+
return type(self)(
513+
sentence_model=self.sentence_model,
514+
positive_embeddings=positive_embeddings,
515+
negative_embeddings=negative_embeddings,
516+
scale_fn=self.scale_fn,
517+
encoding_additional_kwargs=self.encoding_kwargs,
518+
positive_corpus=positive_corpus,
519+
negative_corpus=negative_corpus,
520+
model_card=self.model_card,
521+
)
522+
400523
def calculate_rare_class_affinity(
401524
self,
402525
text_samples: List[str],

tests/test_sentinel_local_index.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,3 +637,172 @@ def test_misaligned_corpus_is_dropped_with_warning(self, caplog):
637637

638638
assert index.negative_corpus is None
639639
assert "does not match embedding rows" in caplog.text
640+
641+
642+
def _labelled_pair_index(n_positive=20, n_negative=40, dim=8):
643+
"""Index where BOTH sides carry the row-number-in-the-embedding trick."""
644+
return SentinelLocalIndex(
645+
sentence_model=None,
646+
positive_embeddings=torch.arange(n_positive, dtype=torch.float32).repeat(dim, 1).T,
647+
negative_embeddings=torch.arange(n_negative, dtype=torch.float32).repeat(dim, 1).T,
648+
positive_corpus=[f"pos {i}" for i in range(n_positive)],
649+
negative_corpus=[f"neg {i}" for i in range(n_negative)],
650+
scale_fn=lambda s: s * 2,
651+
model_card={"version": "1.0"},
652+
)
653+
654+
655+
def _assert_aligned(embeddings, corpus, prefix):
656+
"""Every row must still sit beside the text it was built from."""
657+
for row, text in zip(embeddings, corpus):
658+
assert text == f"{prefix} {int(row[0].item())}"
659+
660+
661+
class TestSubsample:
662+
"""Resizing an index without re-encoding."""
663+
664+
def test_shapes_for_both_axes(self):
665+
"""Both axes resize as asked, independently and together."""
666+
index = _labelled_pair_index()
667+
668+
only_positive = index.subsample(n_positive=5)
669+
assert only_positive.positive_embeddings.shape[0] == 5
670+
assert only_positive.negative_embeddings.shape[0] == 40 # untouched
671+
672+
only_ratio = index.subsample(neg_to_pos_ratio=0.5)
673+
assert only_ratio.positive_embeddings.shape[0] == 20 # untouched
674+
assert only_ratio.negative_embeddings.shape[0] == 10 # 20 * 0.5
675+
676+
both = index.subsample(n_positive=6, neg_to_pos_ratio=2.0)
677+
assert both.positive_embeddings.shape[0] == 6
678+
assert both.negative_embeddings.shape[0] == 12 # 6 * 2.0
679+
680+
def test_original_index_is_untouched(self):
681+
"""The most important guarantee: subsample() never mutates the receiver.
682+
683+
A grid search loops over sizes. If each call shrank the original, run 2 would
684+
start from run 1's leftovers and every result after the first would be wrong.
685+
"""
686+
index = _labelled_pair_index()
687+
before_positive = index.positive_embeddings.clone()
688+
before_negative = index.negative_embeddings.clone()
689+
before_pos_corpus = list(index.positive_corpus)
690+
691+
for _ in range(3):
692+
index.subsample(n_positive=5, neg_to_pos_ratio=1.0, seed=1)
693+
694+
assert torch.equal(index.positive_embeddings, before_positive)
695+
assert torch.equal(index.negative_embeddings, before_negative)
696+
assert index.positive_corpus == before_pos_corpus
697+
assert index.negative_embeddings.shape[0] == 40
698+
699+
def test_repeated_calls_are_independent(self):
700+
"""Chained calls all measure the same starting index, not each other's output."""
701+
index = _labelled_pair_index()
702+
sizes = [index.subsample(n_positive=n).positive_embeddings.shape[0] for n in (10, 8, 3)]
703+
assert sizes == [10, 8, 3]
704+
705+
def test_alignment_preserved_on_both_sides(self):
706+
"""Corpus texts follow their own embedding rows through the resize."""
707+
index = _labelled_pair_index()
708+
smaller = index.subsample(n_positive=7, neg_to_pos_ratio=2.0, seed=3)
709+
710+
assert len(smaller.positive_corpus) == 7
711+
assert len(smaller.negative_corpus) == 14
712+
_assert_aligned(smaller.positive_embeddings, smaller.positive_corpus, "pos")
713+
_assert_aligned(smaller.negative_embeddings, smaller.negative_corpus, "neg")
714+
715+
def test_same_seed_reproducible_different_seed_not(self):
716+
"""Seeding controls the selection, as everywhere else in the library."""
717+
index = _labelled_pair_index(n_positive=200, n_negative=400)
718+
719+
a = index.subsample(n_positive=50, neg_to_pos_ratio=1.0, seed=42)
720+
b = index.subsample(n_positive=50, neg_to_pos_ratio=1.0, seed=42)
721+
c = index.subsample(n_positive=50, neg_to_pos_ratio=1.0, seed=99)
722+
723+
assert torch.equal(a.positive_embeddings, b.positive_embeddings)
724+
assert a.positive_corpus == b.positive_corpus
725+
assert not torch.equal(a.positive_embeddings, c.positive_embeddings)
726+
727+
def test_requesting_more_than_available_keeps_everything(self):
728+
"""Over-asking clips rather than erroring, matching load()'s existing behaviour."""
729+
index = _labelled_pair_index(n_positive=20, n_negative=40)
730+
bigger = index.subsample(n_positive=999, neg_to_pos_ratio=999.0)
731+
732+
assert bigger.positive_embeddings.shape[0] == 20
733+
assert bigger.negative_embeddings.shape[0] == 40
734+
735+
def test_no_arguments_returns_equivalent_copy(self):
736+
"""Both arguments None yields an equal but distinct index."""
737+
index = _labelled_pair_index()
738+
copy = index.subsample()
739+
740+
assert copy is not index
741+
assert torch.equal(copy.positive_embeddings, index.positive_embeddings)
742+
assert copy.positive_corpus == index.positive_corpus
743+
# Mutating the copy's corpus list must not reach back into the original.
744+
copy.positive_corpus.append("extra")
745+
assert len(index.positive_corpus) == 20
746+
747+
def test_metadata_carries_over(self):
748+
"""scale_fn, encoding kwargs, model card and the model itself come along.
749+
750+
A dropped scale_fn silently changes scores for models like E5, so this is a
751+
correctness check rather than a tidiness one.
752+
"""
753+
index = _labelled_pair_index()
754+
smaller = index.subsample(n_positive=4)
755+
756+
assert smaller.scale_fn is index.scale_fn
757+
assert smaller.scale_fn(3) == 6
758+
assert smaller.model_card == index.model_card
759+
assert smaller.encoding_kwargs == index.encoding_kwargs
760+
assert smaller.encoding_kwargs["normalize_embeddings"] is True
761+
# The model is large and read-only, so it is shared rather than reloaded.
762+
assert smaller.sentence_model is index.sentence_model
763+
764+
def test_index_without_corpus(self):
765+
"""An index carrying no corpus subsamples fine and stays corpus-free."""
766+
index = SentinelLocalIndex(
767+
sentence_model=None,
768+
positive_embeddings=torch.rand(20, 8),
769+
negative_embeddings=torch.rand(40, 8),
770+
)
771+
smaller = index.subsample(n_positive=5, neg_to_pos_ratio=2.0, seed=1)
772+
773+
assert smaller.positive_embeddings.shape[0] == 5
774+
assert smaller.negative_embeddings.shape[0] == 10
775+
assert smaller.positive_corpus is None
776+
assert smaller.negative_corpus is None
777+
778+
def test_tiny_ratio_keeps_at_least_one_negative(self):
779+
"""A ratio that rounds to zero negatives is clipped to one, not left empty."""
780+
index = _labelled_pair_index(n_positive=20, n_negative=40)
781+
smaller = index.subsample(n_positive=2, neg_to_pos_ratio=0.1) # 2 * 0.1 -> 0
782+
783+
assert smaller.negative_embeddings.shape[0] == 1
784+
785+
@pytest.mark.parametrize(
786+
"kwargs,message",
787+
[
788+
({"n_positive": 0}, "n_positive must be positive"),
789+
({"n_positive": -5}, "n_positive must be positive"),
790+
({"neg_to_pos_ratio": 0}, "neg_to_pos_ratio must be positive"),
791+
({"neg_to_pos_ratio": -1.0}, "neg_to_pos_ratio must be positive"),
792+
],
793+
)
794+
def test_invalid_arguments_raise(self, kwargs, message):
795+
"""Bad sizes raise instead of being quietly ignored.
796+
797+
Silently ignoring them would produce grid-search rows describing an index the
798+
caller never asked for.
799+
"""
800+
index = _labelled_pair_index()
801+
with pytest.raises(ValueError, match=message):
802+
index.subsample(**kwargs)
803+
804+
def test_missing_embeddings_raise(self):
805+
"""Subsampling an index with nothing in it is an error, not a silent no-op."""
806+
index = SentinelLocalIndex(sentence_model=None)
807+
with pytest.raises(ValueError, match="without both positive and negative"):
808+
index.subsample(n_positive=1)

0 commit comments

Comments
 (0)