use rayon::prelude::*;
use crate::quant_kernels::{
scan_b1_to_topk, scan_b2_to_topk, scan_b4_to_topk, scan_b8_asym, scan_b8_asym_with_lut,
scan_b8_to_topk, scan_via_lut_scalar, scan_via_lut_scalar_with_lut,
};
#[cfg(target_arch = "x86_64")]
use crate::quant_kernels::{
scan_b2_asym_avx2, scan_b2_asym_avx512, scan_b4_asym_avx2, scan_b4_asym_avx512,
};
use crate::rank::{
bucket_centre, bucket_ranks, pack_buckets, rank_to_bucket, rank_transform,
rankquant_bytes_per_vec, rankquant_norm,
};
use crate::sign_bitmap::SignBitmap;
use crate::util::{assert_all_finite, l2_normalise, l2_normalise_into, result_buffer_len, TopK};
use crate::{validate_candidate_ids, OrdvecError, SearchResults};
pub struct SubsetScratch {
q_unit: Vec<f32>,
sub_packed: Vec<u8>,
scalar_lut: Vec<f32>,
top: TopK,
local_indices: Vec<i64>,
final_order: Vec<(f32, i64, i64, usize)>,
}
impl Default for SubsetScratch {
fn default() -> Self {
Self {
q_unit: Vec::new(),
sub_packed: Vec::new(),
scalar_lut: Vec::new(),
top: TopK::new(0),
local_indices: Vec::new(),
final_order: Vec::new(),
}
}
}
impl SubsetScratch {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
*self = Self::default();
}
#[cfg(feature = "test-utils")]
#[doc(hidden)]
pub fn capacities_for_test(&self) -> (usize, usize, usize, usize) {
(
self.q_unit.capacity(),
self.sub_packed.capacity(),
self.local_indices.capacity(),
self.final_order.capacity(),
)
}
}
fn check_eval_bits(bits: u8) {
assert!((1..=8).contains(&bits), "bits must be in 1..=8");
}
fn rankquant_eval_norm(dim: usize, bits: u8) -> f32 {
check_eval_bits(bits);
assert!(dim >= 2, "dim must be >= 2");
assert!(dim <= u16::MAX as usize, "dim must fit in u16");
let mut acc = 0.0f64;
for rank in 0..dim {
let b = rank_to_bucket(rank as u16, dim, bits);
let c = bucket_centre(b, bits) as f64;
acc += c * c;
}
acc.sqrt() as f32
}
fn asymmetric_norm(dim: usize, bits: u8) -> f32 {
if bits == 8 && !dim.is_multiple_of(256) {
rankquant_eval_norm(dim, bits)
} else {
rankquant_norm(dim, bits)
}
}
fn rankquant_eval_centres(v: &[f32], bits: u8, out: &mut [f32]) {
debug_assert_eq!(v.len(), out.len());
let ranks = rank_transform(v);
for (dst, rank) in out.iter_mut().zip(ranks) {
let bucket = rank_to_bucket(rank, v.len(), bits);
*dst = bucket_centre(bucket, bits);
}
}
fn rankquant_eval_buckets(v: &[f32], bits: u8, out: &mut [u8]) {
debug_assert_eq!(v.len(), out.len());
let ranks = rank_transform(v);
for (dst, rank) in out.iter_mut().zip(ranks) {
*dst = rank_to_bucket(rank, v.len(), bits);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RankQuantCapability {
AsymmetricOnly,
SymmetricAndAsymmetric,
}
pub struct RankQuant {
pub(crate) dim: usize,
pub(crate) bits: u8,
pub(crate) n_vectors: usize,
pub(crate) capability: RankQuantCapability,
pub(crate) packed: Vec<u8>,
}
impl std::fmt::Debug for RankQuant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RankQuant")
.field("dim", &self.dim)
.field("bits", &self.bits)
.field("n_vectors", &self.n_vectors)
.field("capability", &self.capability)
.finish()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TwoStageCandidatePolicy {
pub min_candidates: usize,
pub k_multiplier: usize,
pub max_candidates: Option<usize>,
}
impl TwoStageCandidatePolicy {
pub fn candidate_count(&self, k: usize, search_space: usize) -> usize {
if k == 0 || search_space == 0 {
return 0;
}
let mut count = self.min_candidates.max(k.saturating_mul(self.k_multiplier));
if let Some(max_candidates) = self.max_candidates {
count = count.min(max_candidates);
}
count.min(search_space)
}
}
impl Default for TwoStageCandidatePolicy {
fn default() -> Self {
Self {
min_candidates: 256,
k_multiplier: 32,
max_candidates: None,
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
#[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))]
enum SimdTier {
None,
Avx2,
Avx512,
}
#[inline]
fn select_simd_tier(dim: usize, bits: u8) -> SimdTier {
if !matches!(bits, 2 | 4) {
return SimdTier::None;
}
#[cfg(target_arch = "x86_64")]
{
let avx512 = is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512dq");
let avx2 = is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma");
if avx512 && dim.is_multiple_of(64) {
return SimdTier::Avx512;
}
if avx2 && ((bits == 2 && dim.is_multiple_of(16)) || (bits == 4 && dim.is_multiple_of(8))) {
return SimdTier::Avx2;
}
SimdTier::None
}
#[cfg(not(target_arch = "x86_64"))]
{
let _ = (dim, bits);
SimdTier::None
}
}
#[cfg(feature = "test-utils")]
#[doc(hidden)]
#[must_use]
pub fn subset_rerank_uses_simd(dim: usize, bits: u8) -> bool {
RankQuant::validate_params(dim, bits).is_ok()
&& !matches!(select_simd_tier(dim, bits), SimdTier::None)
}
impl RankQuant {
pub fn validate_params(dim: usize, bits: u8) -> Result<(), OrdvecError> {
if !matches!(bits, 1 | 2 | 4 | 8) {
return Err(OrdvecError::InvalidParameter {
name: "bits",
message: "must be 1, 2, 4, or 8".to_string(),
});
}
if dim < 2 {
return Err(OrdvecError::InvalidParameter {
name: "dim",
message: "must be >= 2".to_string(),
});
}
if dim > u16::MAX as usize {
return Err(OrdvecError::InvalidParameter {
name: "dim",
message: "must fit in u16".to_string(),
});
}
let codes_per_byte = (8 / bits) as usize;
if !dim.is_multiple_of(codes_per_byte) {
return Err(OrdvecError::InvalidParameter {
name: "dim",
message: format!("must be a multiple of {codes_per_byte} for bits = {bits}"),
});
}
if bits != 8 {
let n_buckets = 1usize << bits;
if !dim.is_multiple_of(n_buckets) {
return Err(OrdvecError::InvalidParameter {
name: "dim",
message: format!(
"must be divisible by 2^bits = {n_buckets} so every bucket receives exactly dim / 2^bits rank entries"
),
});
}
}
Ok(())
}
pub fn new(dim: usize, bits: u8) -> Self {
assert!(matches!(bits, 1 | 2 | 4 | 8), "bits must be 1, 2, 4, or 8");
assert!(dim >= 2, "dim must be >= 2");
assert!(dim <= u16::MAX as usize, "dim must fit in u16");
let codes_per_byte = (8 / bits) as usize;
assert_eq!(
dim % codes_per_byte,
0,
"dim must be a multiple of {codes_per_byte} for bits = {bits}",
);
if bits == 8 {
assert_eq!(
dim % 256,
0,
"RankQuant::new(dim, 8) requires dim % 256 == 0 for symmetric \
scoring (equal-bucket analytical norm); dim={dim} is not \
256-aligned. Use RankQuant::new_asymmetric(dim, 8) for an \
asymmetric-only b=8 index at any dim.",
);
return Self {
dim,
bits,
n_vectors: 0,
capability: RankQuantCapability::SymmetricAndAsymmetric,
packed: Vec::new(),
};
}
let n_buckets = 1usize << bits;
assert_eq!(
dim % n_buckets,
0,
"dim must be divisible by 2^bits = {n_buckets} so every \
bucket receives exactly dim / 2^bits rank entries; this \
keeps the analytical rankquant_norm exact per document",
);
Self {
dim,
bits,
n_vectors: 0,
capability: RankQuantCapability::SymmetricAndAsymmetric,
packed: Vec::new(),
}
}
pub fn new_asymmetric(dim: usize, bits: u8) -> Self {
Self::validate_params(dim, bits)
.unwrap_or_else(|e| panic!("RankQuant::new_asymmetric invalid params: {e}"));
let capability = Self::capability_for(dim, bits);
Self {
dim,
bits,
n_vectors: 0,
capability,
packed: Vec::new(),
}
}
#[inline]
fn capability_for(dim: usize, bits: u8) -> RankQuantCapability {
if bits == 8 && !dim.is_multiple_of(256) {
RankQuantCapability::AsymmetricOnly
} else {
RankQuantCapability::SymmetricAndAsymmetric
}
}
#[inline]
pub fn capability(&self) -> RankQuantCapability {
self.capability
}
#[inline]
pub fn symmetric_supported(&self) -> bool {
matches!(self.capability, RankQuantCapability::SymmetricAndAsymmetric)
}
#[inline]
fn assert_symmetric_supported(&self) {
assert!(
self.symmetric_supported(),
"RankQuant b=8 symmetric scoring requires dim % 256 == 0; dim={} supports asymmetric/evidence APIs only.",
self.dim,
);
}
pub fn add(&mut self, vectors: &[f32]) {
let n = vectors.len() / self.dim;
assert_eq!(
vectors.len(),
n * self.dim,
"vectors length must be a multiple of dim",
);
assert_all_finite(vectors);
let bytes_per_vec = rankquant_bytes_per_vec(self.dim, self.bits);
let new_n = crate::util::checked_new_count(self.n_vectors, n, bytes_per_vec);
let start = self.packed.len();
self.packed.resize(start + n * bytes_per_vec, 0);
let dim = self.dim;
let bits = self.bits;
self.packed[start..]
.par_chunks_mut(bytes_per_vec)
.zip(vectors.par_chunks(dim))
.for_each(|(out, v)| {
let ranks = rank_transform(v);
let buckets = bucket_ranks(&ranks, bits);
let packed = pack_buckets(&buckets, bits);
out.copy_from_slice(&packed);
});
self.n_vectors = new_n;
}
pub fn search(&self, queries: &[f32], k: usize) -> SearchResults {
self.assert_symmetric_supported();
let nq = queries.len() / self.dim;
assert_eq!(queries.len(), nq * self.dim);
assert_all_finite(queries);
let k = k.min(self.n_vectors);
let k_eff = k;
let buf_len = result_buffer_len(nq, k);
if k_eff == 0 {
return SearchResults {
scores: vec![0.0; buf_len],
indices: vec![-1; buf_len],
nq,
k,
};
}
let dim = self.dim;
let bits = self.bits;
let n = self.n_vectors;
let norm = rankquant_norm(dim, bits);
let inv_norm_sq = 1.0_f32 / (norm * norm);
let bytes_per_vec = rankquant_bytes_per_vec(dim, bits);
let mut scores_flat = vec![0.0f32; buf_len];
let mut indices_flat = vec![-1i64; buf_len];
let n_buckets = 1usize << bits;
queries
.par_chunks(dim)
.zip(scores_flat.par_chunks_mut(k))
.zip(indices_flat.par_chunks_mut(k))
.for_each(|((q, out_scores), out_indices)| {
let q_ranks = rank_transform(q);
let mut lut = vec![0.0f32; dim * n_buckets];
for d in 0..dim {
let qb = rank_to_bucket(q_ranks[d], dim, bits);
let qc = bucket_centre(qb, bits);
for b in 0..n_buckets {
lut[d * n_buckets + b] = qc * bucket_centre(b as u8, bits);
}
}
let mut top = TopK::new(k_eff);
match bits {
1 => scan_b1_to_topk(&self.packed, n, dim, &lut, inv_norm_sq, &mut top),
2 => scan_b2_to_topk(&self.packed, n, dim, &lut, inv_norm_sq, &mut top),
4 => scan_b4_to_topk(&self.packed, n, dim, &lut, inv_norm_sq, &mut top),
8 => scan_b8_to_topk(&self.packed, n, dim, &lut, inv_norm_sq, &mut top),
_ => unreachable!(),
}
top.finalize_into(out_scores, out_indices);
let _ = bytes_per_vec; });
SearchResults {
scores: scores_flat,
indices: indices_flat,
nq,
k,
}
}
pub fn search_asymmetric(&self, queries: &[f32], k: usize) -> SearchResults {
let nq = queries.len() / self.dim;
assert_eq!(queries.len(), nq * self.dim);
assert_all_finite(queries);
let k = k.min(self.n_vectors);
let k_eff = k;
let buf_len = result_buffer_len(nq, k);
if k_eff == 0 {
return SearchResults {
scores: vec![0.0; buf_len],
indices: vec![-1; buf_len],
nq,
k,
};
}
let dim = self.dim;
let bits = self.bits;
let n = self.n_vectors;
let norm = asymmetric_norm(dim, bits);
let inv_norm = 1.0_f32 / norm;
let n_buckets = 1usize << bits;
let bytes_per_vec = rankquant_bytes_per_vec(dim, bits);
let mut scores_flat = vec![0.0f32; buf_len];
let mut indices_flat = vec![-1i64; buf_len];
#[cfg_attr(not(target_arch = "x86_64"), allow(unused_variables))]
let simd_tier = select_simd_tier(dim, bits);
#[cfg(target_arch = "x86_64")]
let centre = ((1u32 << bits) as f32 - 1.0) / 2.0;
queries
.par_chunks(dim)
.zip(scores_flat.par_chunks_mut(k))
.zip(indices_flat.par_chunks_mut(k))
.for_each(|((q, out_scores), out_indices)| {
let q_unit = l2_normalise(q);
let mut top = TopK::new(k_eff);
if bits == 8 {
scan_b8_asym(&self.packed, n, dim, &q_unit, inv_norm, &mut top);
top.finalize_into(out_scores, out_indices);
let _ = bytes_per_vec; return;
}
#[cfg(target_arch = "x86_64")]
let centre_offset = {
let q_sum: f32 = q_unit.iter().sum();
-centre * q_sum * inv_norm
};
#[cfg(target_arch = "x86_64")]
unsafe {
match (simd_tier, bits) {
(SimdTier::Avx512, 2) => {
top.set_score_offset(centre_offset);
scan_b2_asym_avx512(&self.packed, n, dim, &q_unit, inv_norm, &mut top);
}
(SimdTier::Avx512, 4) => {
top.set_score_offset(centre_offset);
scan_b4_asym_avx512(&self.packed, n, dim, &q_unit, inv_norm, &mut top);
}
(SimdTier::Avx2, 2) => {
top.set_score_offset(centre_offset);
scan_b2_asym_avx2(&self.packed, n, dim, &q_unit, inv_norm, &mut top);
}
(SimdTier::Avx2, 4) => {
top.set_score_offset(centre_offset);
scan_b4_asym_avx2(&self.packed, n, dim, &q_unit, inv_norm, &mut top);
}
_ => scan_via_lut_scalar(
&self.packed,
n,
dim,
bits,
n_buckets,
&q_unit,
inv_norm,
&mut top,
),
}
}
#[cfg(not(target_arch = "x86_64"))]
scan_via_lut_scalar(
&self.packed,
n,
dim,
bits,
n_buckets,
&q_unit,
inv_norm,
&mut top,
);
top.finalize_into(out_scores, out_indices);
let _ = bytes_per_vec; });
SearchResults {
scores: scores_flat,
indices: indices_flat,
nq,
k,
}
}
pub fn len(&self) -> usize {
self.n_vectors
}
pub fn is_empty(&self) -> bool {
self.n_vectors == 0
}
pub fn dim(&self) -> usize {
self.dim
}
pub fn bits(&self) -> u8 {
self.bits
}
pub fn bytes_per_vec(&self) -> usize {
rankquant_bytes_per_vec(self.dim, self.bits)
}
pub fn byte_size(&self) -> usize {
self.packed.len()
}
pub fn swap_remove(&mut self, idx: usize) -> usize {
assert!(idx < self.n_vectors, "index out of bounds");
let last = self.n_vectors - 1;
let bpv = self.bytes_per_vec();
if idx != last {
let src = last * bpv;
let dst = idx * bpv;
self.packed.copy_within(src..src + bpv, dst);
}
self.packed.truncate(last * bpv);
self.n_vectors -= 1;
last
}
pub fn write(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
if self.bits == 8 {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"RankQuant b=8 persistence is not supported yet (the .ovrq loader \
accepts bits ∈ {1, 2, 4}); b=8 is an in-memory evidence surface \
in this phase",
));
}
crate::rank_io::write_rankquant(path, self.bits, self.dim, self.n_vectors, &self.packed)
}
pub fn write_to<W: std::io::Write>(&self, writer: W) -> std::io::Result<()> {
if self.bits == 8 {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"RankQuant b=8 persistence is not supported yet (the .ovrq loader \
accepts bits ∈ {1, 2, 4}); b=8 is an in-memory evidence surface \
in this phase",
));
}
crate::rank_io::write_rankquant_to(
writer,
self.bits,
self.dim,
self.n_vectors,
&self.packed,
)
}
pub fn load(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
let (bits, dim, n_vectors, packed) = crate::rank_io::load_rankquant(path)?;
Self::from_persisted_parts(bits, dim, n_vectors, packed)
}
pub fn read_from<R: std::io::Read + std::io::Seek>(reader: R) -> std::io::Result<Self> {
let (bits, dim, n_vectors, packed) = crate::rank_io::load_rankquant_from(reader)?;
Self::from_persisted_parts(bits, dim, n_vectors, packed)
}
pub fn load_from_bytes(bytes: &[u8]) -> std::io::Result<Self> {
Self::read_from(std::io::Cursor::new(bytes))
}
fn from_persisted_parts(
bits: u8,
dim: usize,
n_vectors: usize,
packed: Vec<u8>,
) -> std::io::Result<Self> {
let n_buckets = 1usize << bits;
if !dim.is_multiple_of(n_buckets) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"OVRQ dim {dim} is not a multiple of 2^bits = {n_buckets}; \
constant-composition invariant violated"
),
));
}
let codes_per_byte = (8 / bits) as usize;
if !dim.is_multiple_of(codes_per_byte) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("OVRQ dim {dim} is not a multiple of codes_per_byte = {codes_per_byte}",),
));
}
let nv_dim = n_vectors.checked_mul(dim).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"OVRQ n_vectors * dim overflows usize",
)
})?;
let expected_bytes = nv_dim
.checked_mul(bits as usize)
.map(|x| x / 8)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"OVRQ (n_vectors * dim) * bits overflows usize",
)
})?;
if packed.len() != expected_bytes {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"OVRQ payload length {} does not match expected {expected_bytes}",
packed.len(),
),
));
}
let capability = Self::capability_for(dim, bits);
Ok(Self {
dim,
bits,
n_vectors,
capability,
packed,
})
}
pub fn search_asymmetric_subset(
&self,
query: &[f32],
candidates: &[u32],
k: usize,
) -> (Vec<f32>, Vec<i64>) {
assert_eq!(query.len(), self.dim);
assert_all_finite(query);
assert!(
candidates.len() <= self.n_vectors,
"search_asymmetric_subset: candidate list length {} exceeds n_vectors {}; deduplicate repeated ids before calling",
candidates.len(),
self.n_vectors,
);
assert!(
candidates.iter().all(|&di| (di as usize) < self.n_vectors),
"search_asymmetric_subset: candidate id out of range (n_vectors {})",
self.n_vectors,
);
let m = candidates.len();
let out_k = k.min(m);
if out_k == 0 {
return (Vec::new(), Vec::new());
}
let mut scratch = SubsetScratch::new();
l2_normalise_into(&mut scratch.q_unit, query);
let mut scores = vec![f32::NEG_INFINITY; out_k];
let mut indices = vec![-1i64; out_k];
self.subset_rerank_row_into(candidates, &mut scores, &mut indices, &mut scratch);
(scores, indices)
}
fn validate_csr_batch(&self, nq: usize, candidate_offsets: &[usize], candidates: &[u32]) {
assert_eq!(
candidate_offsets.len(),
nq + 1,
"candidate_offsets length {} must be nq+1 ({})",
candidate_offsets.len(),
nq + 1
);
assert_eq!(candidate_offsets[0], 0, "candidate_offsets[0] must be 0");
assert_eq!(
*candidate_offsets.last().unwrap(),
candidates.len(),
"candidate_offsets[nq] must equal candidates.len()"
);
for w in candidate_offsets.windows(2) {
assert!(
w[1] >= w[0],
"candidate_offsets must be monotonic non-decreasing"
);
let row_len = w[1] - w[0];
assert!(
row_len <= self.n_vectors,
"per-row candidate count {row_len} exceeds n_vectors {}",
self.n_vectors
);
}
assert!(
candidates.iter().all(|&di| (di as usize) < self.n_vectors),
"candidate id out of range (n_vectors {})",
self.n_vectors
);
}
fn subset_rerank_row_into(
&self,
candidates_row: &[u32],
out_scores: &mut [f32],
out_indices: &mut [i64],
scratch: &mut SubsetScratch,
) {
let dim = self.dim;
let bits = self.bits;
let bpv = self.bytes_per_vec();
let n_buckets = 1usize << bits;
let m = candidates_row.len();
let out_k = out_scores.len();
debug_assert_eq!(out_indices.len(), out_k);
if out_k == 0 || m == 0 {
return;
}
let norm = asymmetric_norm(dim, bits);
let inv_norm = 1.0_f32 / norm;
#[cfg(target_arch = "x86_64")]
let centre_offset = {
let centre = ((1u32 << bits) as f32 - 1.0) / 2.0;
let q_sum: f32 = scratch.q_unit.iter().sum();
-centre * q_sum * inv_norm
};
let sub_len = m
.checked_mul(bpv)
.expect("subset rerank: candidate scratch length overflows usize");
scratch.sub_packed.clear();
scratch.sub_packed.reserve(sub_len);
for &di in candidates_row {
let src = (di as usize) * bpv;
scratch
.sub_packed
.extend_from_slice(&self.packed[src..src + bpv]);
}
#[cfg_attr(not(target_arch = "x86_64"), allow(unused_variables))]
let simd_tier = select_simd_tier(dim, bits);
scratch.top.reset_with_tie_keys(out_k, candidates_row);
if bits == 8 {
scan_b8_asym_with_lut(
&scratch.sub_packed,
m,
dim,
&scratch.q_unit,
inv_norm,
&mut scratch.top,
&mut scratch.scalar_lut,
);
} else {
#[cfg(target_arch = "x86_64")]
unsafe {
match (simd_tier, bits) {
(SimdTier::Avx512, 2) => {
scratch.top.set_score_offset(centre_offset);
scan_b2_asym_avx512(
&scratch.sub_packed,
m,
dim,
&scratch.q_unit,
inv_norm,
&mut scratch.top,
);
}
(SimdTier::Avx512, 4) => {
scratch.top.set_score_offset(centre_offset);
scan_b4_asym_avx512(
&scratch.sub_packed,
m,
dim,
&scratch.q_unit,
inv_norm,
&mut scratch.top,
);
}
(SimdTier::Avx2, 2) => {
scratch.top.set_score_offset(centre_offset);
scan_b2_asym_avx2(
&scratch.sub_packed,
m,
dim,
&scratch.q_unit,
inv_norm,
&mut scratch.top,
);
}
(SimdTier::Avx2, 4) => {
scratch.top.set_score_offset(centre_offset);
scan_b4_asym_avx2(
&scratch.sub_packed,
m,
dim,
&scratch.q_unit,
inv_norm,
&mut scratch.top,
);
}
_ => scan_via_lut_scalar_with_lut(
&scratch.sub_packed,
m,
dim,
bits,
n_buckets,
&scratch.q_unit,
inv_norm,
&mut scratch.top,
&mut scratch.scalar_lut,
),
}
}
#[cfg(not(target_arch = "x86_64"))]
scan_via_lut_scalar_with_lut(
&scratch.sub_packed,
m,
dim,
bits,
n_buckets,
&scratch.q_unit,
inv_norm,
&mut scratch.top,
&mut scratch.scalar_lut,
);
}
scratch.local_indices.clear();
scratch.local_indices.resize(out_k, -1);
scratch.top.finalize_into_with_scratch(
&mut scratch.final_order,
out_scores,
&mut scratch.local_indices,
);
for (out, &loc) in out_indices.iter_mut().zip(scratch.local_indices.iter()) {
*out = if loc < 0 {
-1
} else {
candidates_row[loc as usize] as i64
};
}
}
#[allow(clippy::too_many_arguments)] pub fn search_asymmetric_subset_batched_serial_into(
&self,
queries: &[f32],
candidate_offsets: &[usize],
candidates: &[u32],
k: usize,
scratch: &mut SubsetScratch,
out_scores: &mut [f32],
out_indices: &mut [i64],
) {
let dim = self.dim;
assert!(
queries.len().is_multiple_of(dim),
"queries length {} must be a multiple of dim {dim}",
queries.len()
);
let nq = queries.len() / dim;
assert_all_finite(queries);
self.validate_csr_batch(nq, candidate_offsets, candidates);
let out_k = k.min(self.n_vectors);
let buf_len = result_buffer_len(nq, out_k);
assert_eq!(
out_scores.len(),
buf_len,
"out_scores length must be nq*out_k ({buf_len})"
);
assert_eq!(
out_indices.len(),
buf_len,
"out_indices length must be nq*out_k ({buf_len})"
);
if out_k == 0 || nq == 0 {
return;
}
for qi in 0..nq {
let q = &queries[qi * dim..(qi + 1) * dim];
let row = &candidates[candidate_offsets[qi]..candidate_offsets[qi + 1]];
let os = &mut out_scores[qi * out_k..(qi + 1) * out_k];
let oi = &mut out_indices[qi * out_k..(qi + 1) * out_k];
if row.is_empty() {
for s in os.iter_mut() {
*s = f32::NEG_INFINITY;
}
for i in oi.iter_mut() {
*i = -1;
}
} else {
l2_normalise_into(&mut scratch.q_unit, q);
self.subset_rerank_row_into(row, os, oi, scratch);
}
}
}
pub fn search_asymmetric_subset_batched_serial(
&self,
queries: &[f32],
candidate_offsets: &[usize],
candidates: &[u32],
k: usize,
) -> SearchResults {
let dim = self.dim;
assert!(
queries.len().is_multiple_of(dim),
"queries length {} must be a multiple of dim {dim}",
queries.len()
);
let nq = queries.len() / dim;
let out_k = k.min(self.n_vectors);
let buf_len = result_buffer_len(nq, out_k);
let mut scores = vec![f32::NEG_INFINITY; buf_len];
let mut indices = vec![-1i64; buf_len];
let mut scratch = SubsetScratch::new();
self.search_asymmetric_subset_batched_serial_into(
queries,
candidate_offsets,
candidates,
k,
&mut scratch,
&mut scores,
&mut indices,
);
SearchResults {
scores,
indices,
nq,
k: out_k,
}
}
pub fn try_search_with_sign_probe(
&self,
sign_probe: &SignBitmap,
query: &[f32],
k: usize,
) -> Result<(Vec<f32>, Vec<i64>), OrdvecError> {
self.try_search_with_sign_probe_with_policy(
sign_probe,
query,
k,
TwoStageCandidatePolicy::default(),
)
}
pub fn try_search_with_sign_probe_with_policy(
&self,
sign_probe: &SignBitmap,
query: &[f32],
k: usize,
policy: TwoStageCandidatePolicy,
) -> Result<(Vec<f32>, Vec<i64>), OrdvecError> {
if sign_probe.dim() != self.dim {
return Err(OrdvecError::InvalidParameter {
name: "sign_probe.dim",
message: format!("must match RankQuant dim {}", self.dim),
});
}
if sign_probe.len() != self.n_vectors {
return Err(OrdvecError::InvalidParameter {
name: "sign_probe.len",
message: format!("must match RankQuant len {}", self.n_vectors),
});
}
if query.len() != self.dim {
return Err(OrdvecError::InvalidVectorLength {
name: "query",
len: query.len(),
expected: self.dim,
});
}
validate_finite(query, "query")?;
let candidate_count = policy.candidate_count(k, self.n_vectors);
let candidates = sign_probe.top_m_candidates(query, candidate_count);
validate_candidate_ids(&candidates, self.n_vectors)?;
Ok(self.search_asymmetric_subset(query, &candidates, k))
}
pub fn search_with_sign_probe(
&self,
sign_probe: &SignBitmap,
query: &[f32],
k: usize,
) -> (Vec<f32>, Vec<i64>) {
self.try_search_with_sign_probe(sign_probe, query, k)
.expect("search_with_sign_probe validation failed")
}
}
fn validate_finite(values: &[f32], name: &'static str) -> Result<(), OrdvecError> {
if values.iter().any(|value| !value.is_finite()) {
return Err(OrdvecError::InvalidParameter {
name,
message: "must contain only finite values".to_string(),
});
}
Ok(())
}
pub fn rankquant_eval_search(
corpus: &[f32],
queries: &[f32],
dim: usize,
bits: u8,
k: usize,
) -> SearchResults {
check_eval_bits(bits);
assert!(dim >= 2, "dim must be >= 2");
assert!(dim <= u16::MAX as usize, "dim must fit in u16");
let n = corpus.len() / dim;
let nq = queries.len() / dim;
assert_eq!(
corpus.len(),
n * dim,
"corpus length must be a multiple of dim"
);
assert_eq!(
queries.len(),
nq * dim,
"queries length must be a multiple of dim"
);
assert_all_finite(corpus);
assert_all_finite(queries);
let k = k.min(n);
let k_eff = k;
let buf_len = result_buffer_len(nq, k);
if nq == 0 || k_eff == 0 {
return SearchResults {
scores: vec![0.0; buf_len],
indices: vec![-1; buf_len],
nq,
k,
};
}
let norm = rankquant_eval_norm(dim, bits);
let inv_norm_sq = 1.0_f32 / (norm * norm);
let centres: Vec<f32> = (0..(1usize << bits))
.map(|bucket| bucket_centre(bucket as u8, bits))
.collect();
let mut doc_buckets = vec![0u8; n * dim];
doc_buckets
.par_chunks_mut(dim)
.zip(corpus.par_chunks(dim))
.for_each(|(out, doc)| rankquant_eval_buckets(doc, bits, out));
let mut scores_flat = vec![0.0f32; buf_len];
let mut indices_flat = vec![-1i64; buf_len];
queries
.par_chunks(dim)
.zip(scores_flat.par_chunks_mut(k))
.zip(indices_flat.par_chunks_mut(k))
.for_each(|((q, out_scores), out_indices)| {
let mut q_centres = vec![0.0f32; dim];
rankquant_eval_centres(q, bits, &mut q_centres);
let mut top = TopK::new(k_eff);
for (di, doc) in doc_buckets.chunks_exact(dim).enumerate() {
let acc: f32 = q_centres
.iter()
.zip(doc)
.map(|(q, &bucket)| q * centres[bucket as usize])
.sum();
top.maybe_insert(acc * inv_norm_sq, di);
}
top.finalize_into(out_scores, out_indices);
});
SearchResults {
scores: scores_flat,
indices: indices_flat,
nq,
k,
}
}
#[cfg(feature = "bench-utils")]
fn build_byte_lut_b2(q_unit: &[f32]) -> Vec<f32> {
let dim = q_unit.len();
debug_assert_eq!(dim % 4, 0);
let n_groups = dim / 4;
let mut lut = vec![0.0f32; n_groups * 256];
for g in 0..n_groups {
let q0 = q_unit[g * 4];
let q1 = q_unit[g * 4 + 1];
let q2 = q_unit[g * 4 + 2];
let q3 = q_unit[g * 4 + 3];
for byte in 0u32..256 {
let c0 = ((byte >> 6) & 3) as f32 - 1.5;
let c1 = ((byte >> 4) & 3) as f32 - 1.5;
let c2 = ((byte >> 2) & 3) as f32 - 1.5;
let c3 = (byte & 3) as f32 - 1.5;
lut[g * 256 + byte as usize] = q0 * c0 + q1 * c1 + q2 * c2 + q3 * c3;
}
}
lut
}
#[cfg(feature = "bench-utils")]
fn build_byte_lut_b4(q_unit: &[f32]) -> Vec<f32> {
let dim = q_unit.len();
debug_assert_eq!(dim % 2, 0);
let n_groups = dim / 2;
let mut lut = vec![0.0f32; n_groups * 256];
for g in 0..n_groups {
let q0 = q_unit[g * 2];
let q1 = q_unit[g * 2 + 1];
for byte in 0u32..256 {
let hi = ((byte >> 4) & 0xF) as f32 - 7.5;
let lo = (byte & 0xF) as f32 - 7.5;
lut[g * 256 + byte as usize] = q0 * hi + q1 * lo;
}
}
lut
}
#[cfg(feature = "bench-utils")]
fn scan_b2_asym_byte_lut(
packed: &[u8],
n: usize,
dim: usize,
q_unit: &[f32],
scale: f32,
top: &mut TopK,
) {
let bytes_per_vec = dim / 4;
let lut = build_byte_lut_b2(q_unit);
for di in 0..n {
let doc = &packed[di * bytes_per_vec..(di + 1) * bytes_per_vec];
let mut acc = 0.0f32;
for (g, &byte) in doc.iter().enumerate() {
acc += lut[g * 256 + byte as usize];
}
top.maybe_insert(acc * scale, di);
}
}
#[cfg(feature = "bench-utils")]
fn scan_b4_asym_byte_lut(
packed: &[u8],
n: usize,
dim: usize,
q_unit: &[f32],
scale: f32,
top: &mut TopK,
) {
let bytes_per_vec = dim / 2;
let lut = build_byte_lut_b4(q_unit);
for di in 0..n {
let doc = &packed[di * bytes_per_vec..(di + 1) * bytes_per_vec];
let mut acc = 0.0f32;
for (g, &byte) in doc.iter().enumerate() {
acc += lut[g * 256 + byte as usize];
}
top.maybe_insert(acc * scale, di);
}
}
#[cfg(feature = "bench-utils")]
pub fn search_asymmetric_byte_lut(index: &RankQuant, queries: &[f32], k: usize) -> SearchResults {
let dim = index.dim;
let bits = index.bits;
let n = index.n_vectors;
let nq = queries.len() / dim;
assert_eq!(queries.len(), nq * dim);
assert_all_finite(queries);
let k = k.min(n);
let k_eff = k;
let buf_len = result_buffer_len(nq, k);
if k_eff == 0 {
return SearchResults {
scores: vec![0.0; buf_len],
indices: vec![-1; buf_len],
nq,
k,
};
}
let norm = rankquant_norm(dim, bits);
let inv_norm = 1.0_f32 / norm;
let mut scores_flat = vec![0.0f32; buf_len];
let mut indices_flat = vec![-1i64; buf_len];
queries
.par_chunks(dim)
.zip(scores_flat.par_chunks_mut(k))
.zip(indices_flat.par_chunks_mut(k))
.for_each(|((q, out_scores), out_indices)| {
let q_unit = l2_normalise(q);
let mut top = TopK::new(k_eff);
match bits {
2 => scan_b2_asym_byte_lut(&index.packed, n, dim, &q_unit, inv_norm, &mut top),
4 => scan_b4_asym_byte_lut(&index.packed, n, dim, &q_unit, inv_norm, &mut top),
_ => panic!("byte-LUT path only supports bits in {{2, 4}}"),
}
top.finalize_into(out_scores, out_indices);
});
SearchResults {
scores: scores_flat,
indices: indices_flat,
nq,
k,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn corpus(rows: usize, dim: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(rows * dim);
for row in 0..rows {
for col in 0..dim {
out.push((((row + 3) * (col + 5)) % 23) as f32 - 11.0);
}
}
out
}
#[test]
fn scalar_lut_scratch_reuses_capacity_after_warmup() {
let dim = 64usize;
let rows = 16usize;
let mut index = RankQuant::new(dim, 1);
let corpus = corpus(rows, dim);
index.add(&corpus);
let nq = 2usize;
let queries = corpus[..nq * dim].to_vec();
let candidates: Vec<u32> = (0..rows as u32).chain(0..rows as u32).collect();
let candidate_offsets = vec![0usize, rows, rows * 2];
let k = 4usize;
let mut scores = vec![0.0f32; nq * k];
let mut indices = vec![0i64; nq * k];
let mut scratch = SubsetScratch::new();
index.search_asymmetric_subset_batched_serial_into(
&queries,
&candidate_offsets,
&candidates,
k,
&mut scratch,
&mut scores,
&mut indices,
);
let scalar_lut_capacity = scratch.scalar_lut.capacity();
assert!(
scalar_lut_capacity >= dim * 2,
"b=1 scalar LUT should reserve one row per coordinate and bucket"
);
index.search_asymmetric_subset_batched_serial_into(
&queries,
&candidate_offsets,
&candidates,
k,
&mut scratch,
&mut scores,
&mut indices,
);
assert_eq!(
scratch.scalar_lut.capacity(),
scalar_lut_capacity,
"scalar LUT scratch must reuse capacity after warmup"
);
}
}