Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion laurus/src/vector/index/flat/searcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,10 @@ impl VectorIndexSearcher for FlatVectorSearcher {
if let Some(ref field_name) = request.field_name {
Ok(self.index_reader.doc_ids_for_field(field_name).len() as u64)
} else {
Ok(self.index_reader.vector_ids()?.len() as u64)
// Issue #672: `vector_ids()` materializes a String per record
// just to be counted; `vector_count()` is the same number (one
// entry per (doc, field) record) with no allocation.
Ok(self.index_reader.vector_count() as u64)
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions laurus/src/vector/index/hnsw/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,24 @@ impl HnswIndexReader {
&self.vectors
}

/// Iterate the interned `(doc_id, field_name)` records without
/// materializing a `String` per record (Issue #672).
///
/// The trait-level [`VectorIndexReader::vector_ids`] must rehydrate
/// owned `String`s at every call (its signature predates the #633
/// interning); callers that already hold a concrete
/// `HnswIndexReader` — e.g. the warmup page-fault pass — can borrow
/// the dictionary-backed names instead.
///
/// # Returns
///
/// An iterator over `(doc_id, &field_name)` in record order.
pub(crate) fn interned_vector_ids(&self) -> impl Iterator<Item = (u64, &str)> {
self.vector_ids
.iter()
.map(|&(id, fid)| (id, &*self.field_dict[fid as usize]))
}

/// Borrow the optional Stage 2 rerank storage pool.
///
/// Returns `Some(_)` only when this reader was loaded against a
Expand Down
12 changes: 8 additions & 4 deletions laurus/src/vector/index/hnsw/searcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,10 @@ impl VectorIndexSearcher for HnswSearcher {
if let Some(ref field_name) = request.field_name {
Ok(self.index_reader.doc_ids_for_field(field_name).len() as u64)
} else {
Ok(self.index_reader.vector_ids()?.len() as u64)
// Issue #672: `vector_ids()` materializes a String per record
// just to be counted; `vector_count()` is the same number (one
// entry per (doc, field) record) with no allocation.
Ok(self.index_reader.vector_count() as u64)
}
}

Expand Down Expand Up @@ -468,10 +471,11 @@ impl VectorIndexSearcher for HnswSearcher {
}
// Read every stored vector so its backing page is faulted in. The
// accumulator (kept live via `black_box`) stops the loop from being
// optimised away as dead code.
// optimised away as dead code. The interned iterator (#672) avoids
// materializing one `String` per record just to name the field.
let mut acc = 0u64;
for (doc_id, field) in reader.vector_ids()? {
if let Ok(Some(vector)) = reader.get_vector(doc_id, &field)
for (doc_id, field) in reader.interned_vector_ids() {
if let Ok(Some(vector)) = reader.get_vector(doc_id, field)
&& let Some(first) = vector.data.first()
{
acc = acc.wrapping_add(first.to_bits() as u64);
Expand Down
5 changes: 4 additions & 1 deletion laurus/src/vector/index/ivf/searcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,10 @@ impl VectorIndexSearcher for IvfSearcher {
if let Some(ref field_name) = request.field_name {
Ok(self.index_reader.doc_ids_for_field(field_name).len() as u64)
} else {
Ok(self.index_reader.vector_ids()?.len() as u64)
// Issue #672: `vector_ids()` materializes a String per record
// just to be counted; `vector_count()` is the same number (one
// entry per (doc, field) record) with no allocation.
Ok(self.index_reader.vector_count() as u64)
}
}
}
Expand Down
34 changes: 21 additions & 13 deletions laurus/src/vector/index/segment/fanout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,21 +549,29 @@ impl VectorIndexSearcher for SegmentFanoutSearcher {
// Count distinct live `(doc_id, field)` keys across segments with
// the same newest-wins masking as `search`, excluding soft-deleted
// docs.
//
// Issue #672: iterate per field through `doc_ids_for_field` (an
// O(1) `Arc` clone on every reader type, #405) instead of
// `vector_ids()`, whose trait-boundary rehydration materializes a
// fresh `String` per record on every call — a full-corpus
// allocation per segment per count query. `field_names()` is
// dictionary-backed and tiny on all three readers.
let mut count = 0u64;
for (idx, reader) in self.readers.iter().enumerate() {
for (doc_id, field) in reader.vector_ids()? {
if let Some(ref field_name) = request.field_name
&& &field != field_name
{
continue;
}
if let Some(bitmap) = &self.bitmap
&& bitmap.is_deleted(doc_id)
{
continue;
}
if !self.shadowed(idx, doc_id, &field) {
count += 1;
let fields: Vec<String> = match request.field_name {
Some(ref field_name) => vec![field_name.clone()],
None => reader.field_names()?,
};
for field in &fields {
for &doc_id in reader.doc_ids_for_field(field).iter() {
if let Some(bitmap) = &self.bitmap
&& bitmap.is_deleted(doc_id)
{
continue;
}
if !self.shadowed(idx, doc_id, field) {
count += 1;
}
}
}
}
Expand Down
31 changes: 31 additions & 0 deletions laurus/tests/segment_score_comparability_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,34 @@ fn min_similarity_applies_to_the_shared_basis_score() {
);
}
}

/// Issue #672 regression: `SegmentFanoutSearcher::count` was rewritten
/// from per-segment `vector_ids()` full clones onto the Arc-backed
/// `doc_ids_for_field` path — its semantics must be unchanged: distinct
/// live `(doc_id, field)` keys, counting cross-segment duplicates once
/// (newest-wins), excluding soft-deleted docs, and honoring the field
/// filter.
#[test]
fn count_masks_duplicates_and_deletions() {
use laurus::vector::search::searcher::VectorIndexQueryParams;

let (index, live) = build_fixture();
let searcher = index.searcher().unwrap();

let count_for = |field: Option<&str>| {
searcher
.count(VectorIndexQuery {
query: grid(0.0),
params: VectorIndexQueryParams::default(),
field_name: field.map(str::to_string),
filter: None,
})
.unwrap()
};

// 30 docs, one deleted; the stale copies of docs 3 and 7 are the
// same (doc, field) keys and must not be double-counted.
assert_eq!(count_for(Some("v")), live.len() as u64);
assert_eq!(count_for(None), live.len() as u64);
assert_eq!(count_for(Some("missing")), 0);
}