use std::collections::BTreeSet;
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use arrow_array::builder::{FixedSizeListBuilder, Float32Builder, ListBuilder};
use arrow_array::{Array, Float32Array, RecordBatch, UInt64Array};
use futures::TryStreamExt;
use lancedb::DistanceType;
use lancedb::index::Index;
use lancedb::index::vector::{IvfFlatIndexBuilder, IvfPqIndexBuilder};
use lancedb::query::{ExecutableQuery, QueryBase};
use tempfile::TempDir;
const DIM: i32 = 64;
const N_DOCS: u64 = 800;
const N_QUERY_TOKENS: usize = 4;
const K: usize = 10;
const N_PARTITIONS: u32 = 16;
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn next_f32(&mut self) -> f32 {
((self.next_u64() >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
}
fn unit_vector(&mut self) -> Vec<f32> {
let mut v: Vec<f32> = (0..DIM).map(|_| self.next_f32()).collect();
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-9);
for x in &mut v {
*x /= norm;
}
v
}
}
struct Doc {
id: u64,
tokens: Vec<Vec<f32>>,
}
#[tokio::main]
async fn main() -> Result<()> {
let mut rng = Rng(0x9E3779B97F4A7C15);
let docs: Vec<Doc> = (0..N_DOCS)
.map(|id| {
let n_tokens = 2 + (rng.next_u64() % 5) as usize; let tokens = (0..n_tokens).map(|_| rng.unit_vector()).collect();
Doc { id, tokens }
})
.collect();
let query: Vec<Vec<f32>> = (0..N_QUERY_TOKENS).map(|_| rng.unit_vector()).collect();
let total_tokens: usize = docs.iter().map(|d| d.tokens.len()).sum();
println!(
"Corpus: {N_DOCS} docs, {total_tokens} tokens, dim {DIM}, {N_QUERY_TOKENS} query tokens, k={K}\n"
);
let truth = top_k_ids(brute_force_maxsim(&docs, &query));
println!("Ground-truth top-{K} (brute-force MaxSim): {truth:?}\n");
let tmp = TempDir::new().context("temp dir")?;
let uri = tmp.path().to_str().context("non-utf8 temp path")?;
let conn = lancedb::connect(uri).execute().await.context("connect")?;
let flat = build_table(&conn, "flat", &docs).await?;
report(
"no-index (Flat)",
&truth,
&query_topk(&flat, &query, None).await?,
);
flat.create_index(
&["vector"],
Index::IvfFlat(
IvfFlatIndexBuilder::default()
.distance_type(DistanceType::Cosine)
.num_partitions(N_PARTITIONS),
),
)
.execute()
.await
.context("create IVF_FLAT index")?;
report(
"IVF_FLAT (default nprobes)",
&truth,
&query_topk(&flat, &query, None).await?,
);
report(
"IVF_FLAT (all partitions)",
&truth,
&query_topk(&flat, &query, Some(N_PARTITIONS as usize)).await?,
);
let pq = build_table(&conn, "pq", &docs).await?;
pq.create_index(
&["vector"],
Index::IvfPq(
IvfPqIndexBuilder::default()
.distance_type(DistanceType::Cosine)
.num_partitions(N_PARTITIONS)
.num_sub_vectors(8),
),
)
.execute()
.await
.context("create IVF_PQ index")?;
report(
"IVF_PQ (default nprobes)",
&truth,
&query_topk(&pq, &query, None).await?,
);
report(
"IVF_PQ (all partitions)",
&truth,
&query_topk(&pq, &query, Some(N_PARTITIONS as usize)).await?,
);
println!(
"\nGate: a native first-stage multi-vector index is worth building only if IVF\n\
recall@{K} is high enough (≈0.95+). Otherwise Phase 1 rerank-only stands."
);
Ok(())
}
fn cos(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
fn brute_force_maxsim(docs: &[Doc], query: &[Vec<f32>]) -> Vec<(u64, f32)> {
let mut scored: Vec<(u64, f32)> = docs
.iter()
.map(|doc| {
let score: f32 = query
.iter()
.map(|q| {
doc.tokens
.iter()
.map(|d| cos(q, d))
.fold(f32::NEG_INFINITY, f32::max)
})
.sum();
(doc.id, score)
})
.collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
scored
}
fn top_k_ids(ranked: Vec<(u64, f32)>) -> Vec<u64> {
ranked.into_iter().take(K).map(|(id, _)| id).collect()
}
async fn build_table(
conn: &lancedb::Connection,
name: &str,
docs: &[Doc],
) -> Result<lancedb::Table> {
let ids = UInt64Array::from(docs.iter().map(|d| d.id).collect::<Vec<_>>());
let mut vectors = ListBuilder::new(FixedSizeListBuilder::new(Float32Builder::new(), DIM));
for doc in docs {
for tok in &doc.tokens {
vectors.values().values().append_slice(tok);
vectors.values().append(true);
}
vectors.append(true);
}
let batch = RecordBatch::try_from_iter(vec![
("id", Arc::new(ids) as Arc<dyn Array>),
("vector", Arc::new(vectors.finish()) as Arc<dyn Array>),
])
.context("assemble batch")?;
conn.create_table(name, vec![batch])
.execute()
.await
.with_context(|| format!("create table {name}"))
}
async fn query_topk(
table: &lancedb::Table,
query: &[Vec<f32>],
nprobes: Option<usize>,
) -> Result<Vec<u64>> {
let (first, rest) = query.split_first().ok_or_else(|| anyhow!("empty query"))?;
let mut vq = table.vector_search(first.clone())?;
for tok in rest {
vq = vq.add_query_vector(tok.clone())?;
}
let mut vq = vq
.column("vector")
.distance_type(DistanceType::Cosine)
.limit(K);
if let Some(n) = nprobes {
vq = vq.nprobes(n);
}
let batches = vq
.execute()
.await?
.try_collect::<Vec<RecordBatch>>()
.await?;
let mut out: Vec<(u64, f32)> = Vec::new();
for batch in &batches {
let ids = batch
.column_by_name("id")
.and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
.ok_or_else(|| anyhow!("missing id column"))?;
let dist = batch
.column_by_name("_distance")
.and_then(|c| c.as_any().downcast_ref::<Float32Array>())
.ok_or_else(|| anyhow!("missing _distance column"))?;
for row in 0..batch.num_rows() {
out.push((ids.value(row), dist.value(row)));
}
}
out.sort_by(|a, b| a.1.total_cmp(&b.1)); Ok(out.into_iter().take(K).map(|(id, _)| id).collect())
}
fn report(label: &str, truth: &[u64], got: &[u64]) {
let truth_set: BTreeSet<u64> = truth.iter().copied().collect();
let hit = got.iter().filter(|id| truth_set.contains(id)).count();
let recall = hit as f32 / truth.len() as f32;
println!(
"{label:>14}: recall@{K} = {recall:.3} ({hit}/{}) top-k={got:?}",
truth.len()
);
}