Skip to content

Commit 055cfd0

Browse files
committed
feat: SPANN vector search impl
1 parent f476016 commit 055cfd0

46 files changed

Lines changed: 10131 additions & 20 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ oneshot = "0.1.13"
1919
base64 = "0.22.0"
2020
byteorder = "1.4.3"
2121
crc32fast = "1.3.2"
22+
fastrand = "2"
2223
once_cell = "1.10.0"
2324
regex = { version = "1.5.5", default-features = false, features = [
2425
"std",
@@ -221,3 +222,11 @@ harness = false
221222
[[bench]]
222223
name = "term_set_queries"
223224
harness = false
225+
226+
[[bench]]
227+
name = "distance"
228+
harness = false
229+
230+
[[bench]]
231+
name = "graph"
232+
harness = false

benches/distance.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Benchmarks the SIMD distance kernels in src/vector/distance.rs.
2+
//
3+
// What's measured:
4+
// - Pairwise f32: l2_squared / dot / cosine on two f32 slices
5+
// - Pairwise bytes: query is f32, doc side is LE bytes (segment shape)
6+
//
7+
// Dimension sweep covers common embedding widths:
8+
// 128 (word2vec) / 384 (MiniLM) / 768 (BERT-base) / 1024 (e5-large)
9+
// 1536 (OpenAI ada-002) / 3072 (text-embedding-3-large)
10+
//
11+
// Throughput is reported in bytes/sec so criterion prints MB/s, which
12+
// is directly comparable against:
13+
// - L1 bandwidth (~1 TB/s on modern cores)
14+
// - FMA peak (AVX-512: 64 FLOPS/cycle/core; NEON Apple M-series: 16-32 FLOPS/cycle/core
15+
// depending on generation)
16+
//
17+
// Run with: cargo bench --bench distance
18+
19+
use std::hint::black_box;
20+
21+
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
22+
use tantivy::vector::{cosine, cosine_bytes, dot, dot_bytes, l2_squared, l2_squared_bytes};
23+
24+
const DIMS: &[usize] = &[1024, 2048];
25+
26+
/// Guaranteed-scalar dot product. Serves as the "no SIMD at all"
27+
/// baseline so we can see how much the autovectorizer is buying us.
28+
///
29+
/// `black_box` on the accumulator each iteration is the trick: it
30+
/// makes LLVM treat `acc` as opaque between iterations, which prevents
31+
/// the loop vectorizer from recognizing the reduction pattern and
32+
/// emitting SIMD.
33+
#[inline(never)]
34+
fn dot_scalar(a: &[f32], b: &[f32]) -> f32 {
35+
debug_assert_eq!(a.len(), b.len());
36+
let mut acc = 0.0f32;
37+
for i in 0..a.len() {
38+
acc = black_box(acc + a[i] * b[i]);
39+
}
40+
acc
41+
}
42+
43+
/// Deterministic pseudo-random f32 values in [-1, 1). A bare LCG is
44+
/// enough — we just need non-zero, non-equal data the optimizer can't
45+
/// fold to a constant.
46+
fn make_f32(dim: usize, seed: u64) -> Vec<f32> {
47+
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
48+
(0..dim)
49+
.map(|_| {
50+
state = state
51+
.wrapping_mul(6_364_136_223_846_793_005)
52+
.wrapping_add(1_442_695_040_888_963_407);
53+
let bits = (state >> 33) as u32;
54+
(bits as f32 / u32::MAX as f32) * 2.0 - 1.0
55+
})
56+
.collect()
57+
}
58+
59+
fn f32_to_le_bytes(v: &[f32]) -> Vec<u8> {
60+
let mut out = Vec::with_capacity(v.len() * 4);
61+
for x in v {
62+
out.extend_from_slice(&x.to_le_bytes());
63+
}
64+
out
65+
}
66+
67+
fn bench_pairwise_f32(c: &mut Criterion) {
68+
let mut group = c.benchmark_group("pairwise_f32");
69+
for &dim in DIMS {
70+
let a = make_f32(dim, 1);
71+
let b = make_f32(dim, 2);
72+
73+
// Sanity check: the scalar baseline must agree with the
74+
// autovectorized kernel within fp tolerance. Different reduction
75+
// orders mean we can't expect bit-exact equality.
76+
{
77+
let autovec = dot(&a, &b);
78+
let scalar = dot_scalar(&a, &b);
79+
let tol = 1e-3 * autovec.abs().max(1.0);
80+
assert!(
81+
(autovec - scalar).abs() < tol,
82+
"dot_scalar mismatch at dim={dim}: autovec={autovec} scalar={scalar}"
83+
);
84+
}
85+
86+
// 2 vectors * 4 B/element.
87+
group.throughput(Throughput::Bytes((dim * 8) as u64));
88+
group.bench_with_input(BenchmarkId::new("dot_scalar", dim), &dim, |bn, _| {
89+
bn.iter(|| dot_scalar(black_box(&a), black_box(&b)))
90+
});
91+
group.bench_with_input(BenchmarkId::new("dot", dim), &dim, |bn, _| {
92+
bn.iter(|| dot(black_box(&a), black_box(&b)))
93+
});
94+
group.bench_with_input(BenchmarkId::new("l2_squared", dim), &dim, |bn, _| {
95+
bn.iter(|| l2_squared(black_box(&a), black_box(&b)))
96+
});
97+
group.bench_with_input(BenchmarkId::new("cosine", dim), &dim, |bn, _| {
98+
bn.iter(|| cosine(black_box(&a), black_box(&b)))
99+
});
100+
}
101+
group.finish();
102+
}
103+
104+
fn bench_pairwise_bytes(c: &mut Criterion) {
105+
let mut group = c.benchmark_group("pairwise_bytes");
106+
for &dim in DIMS {
107+
let q = make_f32(dim, 1);
108+
let d = make_f32(dim, 2);
109+
let d_bytes = f32_to_le_bytes(&d);
110+
group.throughput(Throughput::Bytes((dim * 8) as u64));
111+
group.bench_with_input(BenchmarkId::new("l2_squared_bytes", dim), &dim, |bn, _| {
112+
bn.iter(|| l2_squared_bytes::<f32>(black_box(&q), black_box(&d_bytes)))
113+
});
114+
group.bench_with_input(BenchmarkId::new("dot_bytes", dim), &dim, |bn, _| {
115+
bn.iter(|| dot_bytes::<f32>(black_box(&q), black_box(&d_bytes)))
116+
});
117+
group.bench_with_input(BenchmarkId::new("cosine_bytes", dim), &dim, |bn, _| {
118+
bn.iter(|| cosine_bytes::<f32>(black_box(&q), black_box(&d_bytes)))
119+
});
120+
}
121+
group.finish();
122+
}
123+
124+
criterion_group! {
125+
name = benches;
126+
config = Criterion::default().sample_size(100);
127+
targets = bench_pairwise_f32, bench_pairwise_bytes
128+
}
129+
criterion_main!(benches);

benches/graph.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Benchmarks the search workspace of src/vector/ivf/graph.rs.
2+
//
3+
// workspace/visited_check — the `Workspace.visited` bitset, exercised
4+
// with the same contains-then-insert pattern `search` inlines. The
5+
// bitset was chosen over a Vec<u32> epoch-stamp array, which measured
6+
// ~8-10% slower at n=1M and is 32x larger. To evaluate another
7+
// representation, mirror the swap here and in `Workspace` and rerun —
8+
// the bench names are stable, so `-- --save-baseline <name>` before and
9+
// `-- --baseline <name>` after prints the per-case delta.
10+
//
11+
// Two axes:
12+
// - n: node count (the centroid graph size the set is sized to)
13+
// - q: queries served by one allocation
14+
//
15+
// One iteration = allocate the set, then run q queries, each of which
16+
// resets the set and inserts n uniform-random node ids (~63% land on
17+
// unvisited slots, so both branch outcomes are exercised). Every
18+
// measured operation is one check-and-insert, so with throughput set to
19+
// n * q elements, elem/s reads directly as checks/sec; q=1 additionally
20+
// carries the allocation cost (the fresh-workspace-per-query regime)
21+
// while larger q amortizes it and isolates the per-query reset.
22+
//
23+
// Run with: cargo bench --bench graph
24+
25+
use std::hint::black_box;
26+
use std::time::Duration;
27+
28+
use common::BitSet;
29+
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
30+
31+
/// Node counts to sweep (the centroid graph size).
32+
const NODE_COUNTS: &[usize] = &[100_000, 300_000, 1_000_000];
33+
34+
/// Queries served by a single allocation. Each query costs n inserts,
35+
/// so amortization of the allocation saturates quickly; larger values
36+
/// only inflate iteration time (n * q inserts each).
37+
const QUERIES_PER_ALLOC: &[usize] = &[1, 10, 100];
38+
39+
/// Same LCG as benches/distance.rs; deterministic and cheap enough to be
40+
/// noise next to the visited access it feeds.
41+
#[inline]
42+
fn lcg(state: u64) -> u64 {
43+
state
44+
.wrapping_mul(6_364_136_223_846_793_005)
45+
.wrapping_add(1_442_695_040_888_963_407)
46+
}
47+
48+
/// One iteration: q queries over one allocation, n random inserts each.
49+
#[inline]
50+
fn run_batch(n: usize, queries: usize) -> usize {
51+
let mut visited = BitSet::with_max_value(n as u32);
52+
let mut state = 0x9E37_79B9_7F4A_7C15u64;
53+
let mut first_visits = 0;
54+
for _ in 0..queries {
55+
visited.clear();
56+
for _ in 0..n {
57+
state = lcg(state);
58+
let node = ((state >> 33) as usize % n) as u32;
59+
if !visited.contains(node) {
60+
visited.insert(node);
61+
first_visits += 1;
62+
}
63+
}
64+
}
65+
first_visits
66+
}
67+
68+
fn bench_visited(c: &mut Criterion) {
69+
let mut group = c.benchmark_group("workspace/visited_check");
70+
for &n in NODE_COUNTS {
71+
for &q in QUERIES_PER_ALLOC {
72+
group.throughput(Throughput::Elements((n * q) as u64));
73+
group.bench_with_input(
74+
BenchmarkId::new(format!("n={n}"), q),
75+
&(n, q),
76+
|bn, &(n, q)| bn.iter(|| run_batch(black_box(n), black_box(q))),
77+
);
78+
}
79+
}
80+
group.finish();
81+
}
82+
83+
criterion_group! {
84+
name = benches;
85+
config = Criterion::default()
86+
.sample_size(10)
87+
.warm_up_time(Duration::from_secs(1))
88+
.measurement_time(Duration::from_secs(3));
89+
targets = bench_visited
90+
}
91+
criterion_main!(benches);

columnar/src/column_index/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::ops::Range;
1212

1313
pub use merge::merge_column_index;
1414
pub(crate) use multivalued_index::SerializableMultivalueIndex;
15-
pub use optional_index::{OptionalIndex, Set};
15+
pub use optional_index::{OptionalIndex, Set, open_optional_index, serialize_optional_index};
1616
pub use serialize::{
1717
SerializableColumnIndex, SerializableOptionalIndex, open_column_index, serialize_column_index,
1818
};

src/collector/top_score_collector.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use crate::collector::sort_key_top_collector::TopBySortKeyCollector;
1313
use crate::collector::top_collector::ComparableDoc;
1414
use crate::collector::{SegmentSortKeyComputer, SortKeyComputer};
1515
use crate::fastfield::FastValue;
16+
use crate::schema::Field;
17+
use crate::vector::{TopDocsByVectorSimilarity, VectorElement};
1618
use crate::{DocAddress, DocId, Order, Score, SegmentOrdinal, SegmentReader};
1719

1820
/// The `TopDocs` collector keeps track of the top `K` documents
@@ -329,6 +331,39 @@ impl TopDocs {
329331
TopBySortKeyCollector::new(sort_key_computer, self.doc_range())
330332
}
331333

334+
/// Ranks documents by similarity to a query vector against a
335+
/// [`FieldType::Vector`](crate::schema::FieldType::Vector) field.
336+
///
337+
/// The metric is read from the field's
338+
/// [`VectorOptions`](crate::schema::VectorOptions) at query time —
339+
/// callers don't pick a metric here. All metrics are presented in
340+
/// "higher is better" form (L2 is internally negated), so this is
341+
/// always a descending sort: the closest docs come first.
342+
///
343+
/// Only documents that have a vector for `field` are returned —
344+
/// filter-matching docs without a vector are dropped. This differs
345+
/// from `order_by_fast_field`'s `None`-trailing convention; the
346+
/// constraint comes from IVF, which can't see vectorless docs.
347+
///
348+
/// Generic over `T: VectorElement` — `T` is typically `f32` and is
349+
/// inferred from the `query` argument; it must match the schema's
350+
/// declared dtype.
351+
///
352+
/// The driving query (passed to `searcher.search`) decides which docs
353+
/// are candidates. For brute-force scan over every doc, pair this with
354+
/// [`AllQuery`](crate::query::AllQuery); pair with a filter query to
355+
/// restrict candidates.
356+
pub fn order_by_similarity<T>(
357+
self,
358+
field: Field,
359+
query: Vec<T>,
360+
) -> TopDocsByVectorSimilarity<T>
361+
where
362+
T: VectorElement,
363+
{
364+
TopDocsByVectorSimilarity::new(field, query, self.limit).and_offset(self.offset)
365+
}
366+
332367
/// Helper function to tweak the similarity score of documents using a function.
333368
/// (usually a closure).
334369
///
@@ -671,6 +706,26 @@ where
671706
self.append_doc(doc, sort_key);
672707
}
673708

709+
/// Like [`push`](Self::push), but doesn't require pushes to arrive
710+
/// in ascending `DocId`/`DocAddress` order.
711+
///
712+
/// The default `push` short-circuits below-threshold candidates with
713+
/// a strict `> threshold` test. That's only safe under ascending-D
714+
/// pushes — equal-sort-key future pushes are guaranteed to have a
715+
/// larger D and thus lose the tie-break, so they can be skipped.
716+
/// Out-of-order callers (eg a vector IVF format which visits cluster
717+
/// by cluster) can't make that guarantee, so this path relaxes the
718+
/// skip to strictly less than the threshold.
719+
#[inline]
720+
pub fn push_unordered(&mut self, sort_key: TSortKey, doc: D) {
721+
if let Some((last_median, _thresh_ord)) = &self.threshold {
722+
if self.comparator.compare(&sort_key, last_median) == std::cmp::Ordering::Less {
723+
return;
724+
}
725+
}
726+
self.append_doc(doc, sort_key);
727+
}
728+
674729
// Append a document to the top n.
675730
//
676731
// At this point, we need to have established that the doc is above the threshold.
@@ -870,6 +925,62 @@ mod tests {
870925
);
871926
}
872927

928+
#[test]
929+
fn test_topn_computer_push_unordered() {
930+
// Same inputs as test_topn_computer, but pushed in a non-ascending
931+
// DocId order — push_unordered should still produce the right top-K
932+
// and (where ties exist) the smallest-DocId tie-break.
933+
let mut computer: TopNComputer<u32, u32, NaturalComparator> =
934+
TopNComputer::new_with_comparator(2, NaturalComparator);
935+
936+
computer.push_unordered(3u32, 3u32);
937+
computer.push_unordered(1u32, 5u32);
938+
computer.push_unordered(2u32, 2u32);
939+
computer.push_unordered(2u32, 4u32);
940+
computer.push_unordered(1u32, 1u32);
941+
assert_eq!(
942+
computer.into_sorted_vec(),
943+
&[
944+
ComparableDoc {
945+
sort_key: 3u32,
946+
doc: 3u32,
947+
},
948+
ComparableDoc {
949+
sort_key: 2u32,
950+
doc: 2u32,
951+
}
952+
]
953+
);
954+
}
955+
956+
#[test]
957+
fn test_topn_computer_push_unordered_tiebreak_on_equal_sort_key() {
958+
// Tied sort keys must still resolve to the smallest DocId, even when
959+
// doc ids arrive out of order (and even when the smaller-DocId
960+
// candidate arrives *after* the larger-DocId one).
961+
let mut computer: TopNComputer<u32, u32, NaturalComparator> =
962+
TopNComputer::new_with_comparator(2, NaturalComparator);
963+
964+
computer.push_unordered(1u32, 100u32);
965+
computer.push_unordered(1u32, 50u32);
966+
computer.push_unordered(1u32, 200u32);
967+
computer.push_unordered(1u32, 25u32);
968+
computer.push_unordered(1u32, 175u32);
969+
assert_eq!(
970+
computer.into_sorted_vec(),
971+
&[
972+
ComparableDoc {
973+
sort_key: 1u32,
974+
doc: 25u32,
975+
},
976+
ComparableDoc {
977+
sort_key: 1u32,
978+
doc: 50u32,
979+
}
980+
]
981+
);
982+
}
983+
873984
#[test]
874985
fn test_topn_computer_no_panic() {
875986
for top_n in 0..10 {

0 commit comments

Comments
 (0)