Skip to main content

iqdb_eval/
lib.rs

1//! # iqdb-eval
2//!
3//! Index-agnostic evaluation harness for the HiveDB **iqdb** vector-database
4//! spine. Measures **recall@k** and **per-query latency percentiles** for
5//! any type that implements [`iqdb_index::IndexCore`].
6//!
7//! ## Surface
8//!
9//! All measurements are top-level free functions generic over the index
10//! under test, so a single harness call works against
11//! [`iqdb_flat::FlatIndex`], an HNSW index, or any future index that
12//! implements the same trait:
13//!
14//! - [`recall_at_k`] — recall@k for an index against an externally
15//!   supplied `Vec<Vec<u32>>` ground truth (typically loaded from a SIFT
16//!   `.ivecs` file via [`read_ivecs`]).
17//! - [`recall_at_k_vs_oracle`] — convenience wrapper that takes a second
18//!   `IndexCore` (typically [`iqdb_flat::FlatIndex`]) as the oracle and
19//!   computes ground truth on the fly.
20//! - [`compute_ground_truth`] — the oracle-only half: returns the per-query
21//!   ground-truth ids as `Vec<Vec<u32>>`, matching the `.ivecs` shape.
22//! - [`latency`] — collect per-query wall-clock samples and report
23//!   mean / min / max / p50 / p95 / p99 (nearest-rank) and single-thread QPS.
24//! - [`build_index_from_base`] — build a fresh index from a `&[Vec<f32>]`
25//!   base set, inserting each row at `VectorId::U64(row_index)` so the
26//!   resulting ids align with `.ivecs` ground-truth files.
27//! - [`read_fvecs`] / [`read_ivecs`] / [`load_sift_dataset`] — TEXMEX
28//!   SIFT-family loaders.
29//!
30//! ## Correctness invariants
31//!
32//! - **Row-index ↔ `VectorId::U64`.** [`build_index_from_base`] inserts each
33//!   row of the base set at `VectorId::U64(row_index as u64)`. Callers that
34//!   build oracles or indexes by hand must do the same; otherwise ids in
35//!   `.ivecs` ground-truth cannot match the ids returned by `search`.
36//! - **Latency excludes build cost.** [`latency`] takes a borrowed
37//!   `&I`, so the index is constructed (and therefore paid for) before
38//!   timing begins.
39//! - **Percentiles are nearest-rank.** No interpolation; every reported
40//!   percentile is an observed sample. See [`LatencyReport`].
41//! - **Metric is read from the oracle.** [`compute_ground_truth`] derives
42//!   the metric from `oracle.metric()` so a mismatched metric cannot
43//!   silently corrupt the ground-truth set.
44//!
45//! ## Example
46//!
47//! ```
48//! use iqdb_eval::{
49//!     build_index_from_base, latency, recall_at_k_vs_oracle, LatencyConfig,
50//! };
51//! use iqdb_flat::{FlatConfig, FlatIndex};
52//! use iqdb_types::{DistanceMetric, SearchParams};
53//!
54//! let base: Vec<Vec<f32>> = vec![
55//!     vec![0.0, 0.0],
56//!     vec![3.0, 4.0],
57//!     vec![1.0, 1.0],
58//! ];
59//! let queries: Vec<Vec<f32>> = vec![vec![0.5, 0.5]];
60//!
61//! let target: FlatIndex =
62//!     build_index_from_base(FlatConfig, 2, DistanceMetric::Euclidean, &base)?;
63//! let oracle: FlatIndex =
64//!     build_index_from_base(FlatConfig, 2, DistanceMetric::Euclidean, &base)?;
65//! let params = SearchParams::new(2, DistanceMetric::Euclidean);
66//!
67//! let recall = recall_at_k_vs_oracle(&target, &oracle, &queries, &params)?;
68//! assert_eq!(recall.mean_recall, 1.0);
69//!
70//! let lat = latency(&target, &queries, &params, &LatencyConfig::default())?;
71//! assert!(lat.p50_us <= lat.p95_us);
72//! # Ok::<(), iqdb_eval::EvalError>(())
73//! ```
74
75#![cfg_attr(docsrs, feature(doc_cfg))]
76#![forbid(unsafe_code)]
77#![deny(warnings)]
78#![deny(missing_docs)]
79#![deny(unsafe_op_in_unsafe_fn)]
80#![deny(unused_must_use)]
81#![deny(unused_results)]
82#![deny(clippy::unwrap_used)]
83#![deny(clippy::expect_used)]
84#![deny(clippy::todo)]
85#![deny(clippy::unimplemented)]
86#![deny(clippy::print_stdout)]
87#![deny(clippy::print_stderr)]
88#![deny(clippy::dbg_macro)]
89#![deny(clippy::unreachable)]
90#![deny(clippy::undocumented_unsafe_blocks)]
91
92mod dataset;
93mod error;
94mod latency;
95mod recall;
96mod report;
97
98use std::sync::Arc;
99
100use iqdb_index::Index;
101use iqdb_types::{DistanceMetric, VectorId};
102
103pub use crate::dataset::{SiftDataset, load_sift_dataset, read_fvecs, read_ivecs};
104pub use crate::error::{EvalError, Result};
105pub use crate::latency::{LatencyConfig, latency};
106pub use crate::recall::{compute_ground_truth, recall_at_k, recall_at_k_vs_oracle};
107pub use crate::report::{LatencyReport, RecallReport};
108
109/// The version of this crate, taken from `Cargo.toml` at compile time.
110///
111/// # Examples
112///
113/// ```
114/// let v = iqdb_eval::VERSION;
115/// assert_eq!(v.split('.').count(), 3);
116/// ```
117pub const VERSION: &str = env!("CARGO_PKG_VERSION");
118
119/// Build a fresh index from a `&[Vec<f32>]` base set, inserting each row
120/// at `VectorId::U64(row_index)`.
121///
122/// This is the harness's canonical way to construct both **the index under
123/// test** and **the oracle** so the ids returned by `search` align with
124/// row indices stored in `.ivecs` ground-truth files. The function is
125/// generic over [`Index`], so any concrete index that implements the trait
126/// (flat, HNSW, …) works.
127///
128/// Each base row is cloned into a fresh `Arc<[f32]>` so it can be handed to
129/// [`iqdb_index::IndexCore::insert`]; that allocation is **O(N · dim)** and
130/// is unavoidable given `insert`'s `Arc<[f32]>` signature.
131///
132/// # Errors
133///
134/// - [`EvalError::EmptyInput`] when `base` is empty.
135/// - [`EvalError::DimensionMismatch`] when any row's `len()` differs from
136///   `dim`.
137/// - [`EvalError::Search`] when [`Index::new`] or
138///   [`iqdb_index::IndexCore::insert`] returns an [`iqdb_types::IqdbError`].
139///
140/// # Examples
141///
142/// ```
143/// use iqdb_eval::build_index_from_base;
144/// use iqdb_flat::{FlatConfig, FlatIndex};
145/// use iqdb_index::IndexCore;
146/// use iqdb_types::DistanceMetric;
147///
148/// let base: Vec<Vec<f32>> = vec![vec![0.0, 0.0], vec![3.0, 4.0]];
149/// let idx: FlatIndex =
150///     build_index_from_base(FlatConfig, 2, DistanceMetric::Euclidean, &base)?;
151/// assert_eq!(idx.len(), 2);
152/// # Ok::<(), iqdb_eval::EvalError>(())
153/// ```
154pub fn build_index_from_base<I: Index>(
155    config: I::Config,
156    dim: usize,
157    metric: DistanceMetric,
158    base: &[Vec<f32>],
159) -> Result<I> {
160    if base.is_empty() {
161        return Err(EvalError::EmptyInput { kind: "base" });
162    }
163    for row in base {
164        if row.len() != dim {
165            return Err(EvalError::DimensionMismatch {
166                expected: dim,
167                found: row.len(),
168            });
169        }
170    }
171
172    let span = tracing::info_span!("eval.build_index_from_base", n_base = base.len(), dim = dim,);
173    let _enter = span.enter();
174
175    let mut idx = I::new(dim, metric, config)?;
176    for (i, row) in base.iter().enumerate() {
177        let id = VectorId::U64(i as u64);
178        let vector: Arc<[f32]> = Arc::from(row.as_slice());
179        iqdb_index::IndexCore::insert(&mut idx, id, vector, None)?;
180    }
181    Ok(idx)
182}