1#[path = "retrieval.rs"]
2mod retrieval;
3use crate::{
4 analyzer::{Analyzer, TextAnalyzer},
5 fde::{Vector, dot, maxsim_flat, normalize},
6 muvera::FdeEncoder,
7 storage::{
8 CompressedVectorStore, FixedVectorStore, ObjectLocation, atomic_write, commit_boundary,
9 record_bytes, verify_record,
10 },
11};
12use annex::{
13 utils::types::DistanceMetric,
14 vector::hnsw::{HNSWIndex, SearchRuntimeOptions},
15};
16use rayon::prelude::*;
17pub use retrieval::{
18 AdaptiveRerank, Channel, Chunk, ContextHit, ContextOptions, Fusion, Predicate, Representation,
19 Rerank, RetrievalDocument, RetrievalResponse, RetrievalTrace, RetrieveRequest,
20};
21use retrieval::{FieldSchema, Fields, RetrievalState};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use std::sync::Arc;
25use std::{
26 collections::{BTreeMap, HashMap, HashSet},
27 fs::{self, File, OpenOptions},
28 io,
29 path::{Path, PathBuf},
30 sync::{Mutex, RwLock},
31};
32use thiserror::Error;
33
34#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
35#[serde(deny_unknown_fields)]
36pub struct IndexConfig {
37 pub dimension: usize,
38 #[serde(default = "default_centroids")]
39 pub centroids: usize,
40 #[serde(default = "default_bits")]
41 pub residual_bits: u8,
42 #[serde(default = "default_probes")]
43 pub probes: usize,
44 #[serde(default = "default_fde_repetitions")]
45 pub fde_repetitions: usize,
46 #[serde(default = "default_fde_ksim")]
47 pub fde_ksim: usize,
48 #[serde(default = "default_fde_projected")]
49 pub fde_projected: usize,
50 #[serde(default)]
53 pub analyzer: TextAnalyzer,
54}
55fn default_centroids() -> usize {
56 64
57}
58fn default_bits() -> u8 {
59 2
60}
61fn default_probes() -> usize {
62 4
63}
64fn default_fde_repetitions() -> usize {
65 20
66}
67fn default_fde_ksim() -> usize {
68 4
69}
70fn default_fde_projected() -> usize {
71 8
72}
73impl IndexConfig {
74 pub fn new(dimension: usize) -> Self {
75 Self {
76 dimension,
77 centroids: 64,
78 residual_bits: 2,
79 probes: 4,
80 fde_repetitions: 20,
81 fde_ksim: 4,
82 fde_projected: 8,
83 analyzer: TextAnalyzer::plain(),
84 }
85 }
86}
87
88#[derive(Clone, Debug, Deserialize, Serialize)]
89struct DocumentRecord {
90 centroid_ids: Vec<u32>,
91 unique_centroids: Vec<u32>,
92 location: ObjectLocation,
93 fde_location: ObjectLocation,
94 metadata: Value,
95 tokens: usize,
96 compressed_bytes: u64,
97 #[serde(default)]
98 fields: Arc<Fields>,
99 #[serde(default)]
100 storage_id: u64,
101}
102const FORMAT_VERSION: u32 = 3;
109
110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
114pub enum Durability {
115 #[default]
116 Fsync,
117 Buffered,
118}
119
120#[derive(Clone, Copy, Deserialize, Serialize)]
121struct SegmentBoundaries {
122 objects: u64,
123 fde: u64,
124}
125
126#[derive(Deserialize, Serialize)]
129struct ManifestEnvelope {
130 format_version: u32,
131 checksum_blake3: String,
132 manifest: String,
133}
134
135#[derive(Deserialize, Serialize)]
136struct Manifest {
137 #[serde(default = "current_format_version")]
138 format_version: u32,
139 #[serde(default)]
140 generation: u64,
141 #[serde(default = "legacy_fde_encoding_version")]
142 fde_encoding_version: u32,
143 config: IndexConfig,
144 codebook: Vec<Vector>,
145 residual_codebook: Vec<f32>,
146 documents: HashMap<String, DocumentRecord>,
147 #[serde(default)]
150 representation_schema: BTreeMap<String, FieldSchema>,
151 #[serde(default)]
152 segments: Option<SegmentBoundaries>,
153 #[serde(default)]
154 storage_generation: Option<u64>,
155 #[serde(default)]
156 sealed: Vec<(u64, SegmentBoundaries)>,
157}
158
159fn legacy_fde_encoding_version() -> u32 {
160 1
161}
162
163fn current_format_version() -> u32 {
164 1 }
166
167fn verify_manifest_checksum(manifest_bytes: &[u8], sidecar_path: &Path) -> Result<(), IndexError> {
171 if !sidecar_path.exists() {
172 return Ok(());
173 }
174 let expected = fs::read_to_string(sidecar_path)?;
175 let expected = expected.trim();
176 let actual = blake3::hash(manifest_bytes);
177 if actual.to_hex().as_str() != expected {
178 return Err(IndexError::Invalid(format!(
179 "manifest.sha256 does not match manifest.json — index may be corrupt or torn: expected={expected}, actual={}",
180 actual.to_hex(),
181 )));
182 }
183 Ok(())
184}
185#[derive(Clone, Debug, Serialize, PartialEq)]
186pub struct Hit {
187 pub id: String,
188 pub score: f32,
189 #[serde(skip_serializing_if = "Option::is_none")]
194 pub fde_score: Option<f32>,
195 pub metadata: Value,
196}
197#[derive(Clone, Debug, Serialize, PartialEq)]
198pub struct CandidateHit {
199 pub id: String,
200 pub score: f32,
201}
202#[derive(Clone, Debug)]
203pub struct UpsertDocument {
204 pub id: String,
205 pub vectors: Vec<Vector>,
206 pub metadata: Value,
207}
208#[derive(Clone, Debug, Serialize, PartialEq)]
209pub struct IndexStats {
210 pub documents: usize,
211 pub generation: u64,
212 pub token_vectors: usize,
213 pub compressed_bytes: u64,
214 pub centroids: usize,
215 pub residual_bits: u8,
216 pub trained: bool,
217 pub fde_dimension: usize,
218 pub fde_ann_nodes: usize,
219 pub fde_ann_base_nodes: usize,
220 pub fde_ann_delta_documents: usize,
221 pub fde_ann_tombstones: usize,
222 pub fde_encoding_version: u32,
223 pub storage_segments: usize,
224 pub dense_ann_fields: HashMap<String, usize>,
225}
226#[derive(Debug, Error)]
227pub enum IndexError {
228 #[error("{0}")]
229 Invalid(String),
230 #[error("index configuration is {actual:?}, requested {requested:?}")]
231 Config {
232 actual: Box<IndexConfig>,
233 requested: Box<IndexConfig>,
234 },
235 #[error(transparent)]
236 Io(#[from] io::Error),
237 #[error(transparent)]
238 Json(#[from] serde_json::Error),
239 #[error(
240 "generation published, but durable commit is uncertain; reopen or retry idempotently: {0}"
241 )]
242 CommitUncertain(io::Error),
243}
244struct FdeAnnBase {
246 index: HNSWIndex,
247 ids: Vec<String>,
248 by_id: HashMap<String, u64>,
249 field: Option<String>,
250}
251
252#[derive(Clone)]
253struct FdeAnn {
254 base: Arc<FdeAnnBase>,
255 delta: HashSet<String>,
258 tombstones: HashSet<u64>,
259 generation: u64,
260}
261
262#[derive(Clone)]
263struct State {
264 generation: u64,
265 codebook: Vec<Vector>,
266 residual_codebook: Vec<f32>,
267 documents: HashMap<String, DocumentRecord>,
268 postings: Vec<HashSet<String>>,
269 fde_ann: Option<FdeAnn>,
270 named_ann: HashMap<String, FdeAnn>,
271 stores: Arc<SegmentStores>,
272 retrieval: Arc<RetrievalState>,
273 objects_map: Option<Arc<memmap2::Mmap>>,
274 fde_map: Option<Arc<memmap2::Mmap>>,
275 sealed: HashMap<u64, Arc<SegmentSnapshot>>,
276}
277struct SegmentSnapshot {
278 stores: Arc<SegmentStores>,
279 objects: Option<Arc<memmap2::Mmap>>,
280 fde: Option<Arc<memmap2::Mmap>>,
281 bounds: SegmentBoundaries,
282}
283impl SegmentSnapshot {
284 fn open(root: &Path, id: u64, bounds: SegmentBoundaries) -> Result<Self, IndexError> {
285 let path = if id == 0 {
286 root.to_owned()
287 } else {
288 root.join("segments").join(id.to_string())
289 };
290 if !path.is_dir() {
291 return Err(IndexError::Invalid("missing sealed segment".into()));
292 }
293 let stores = Arc::new(SegmentStores {
294 objects: CompressedVectorStore::new(path.join("objects"))?,
295 fde: FixedVectorStore::new(path.join("fde"))?,
296 root: path,
297 id: if id == 0 { None } else { Some(id) },
298 retired: std::sync::atomic::AtomicBool::new(false),
299 });
300 if stores.objects.len()? < bounds.objects || stores.fde.len()? < bounds.fde {
301 return Err(IndexError::Invalid(
302 "sealed segment shorter than committed boundary".into(),
303 ));
304 }
305 Ok(Self {
306 objects: if bounds.objects > 0 {
307 Some(Arc::new(stores.objects.map()?))
308 } else {
309 None
310 },
311 fde: if bounds.fde > 0 {
312 Some(Arc::new(stores.fde.map()?))
313 } else {
314 None
315 },
316 stores,
317 bounds,
318 })
319 }
320}
321impl State {
322 fn record_fde(&self, record: &DocumentRecord) -> &[u8] {
323 if record.storage_id == self.stores.id.unwrap_or(0) {
324 self.fde_bytes()
325 } else {
326 self.sealed[&record.storage_id]
327 .fde
328 .as_deref()
329 .map(|m| &m[..self.sealed[&record.storage_id].bounds.fde as usize])
330 .unwrap_or(&[])
331 }
332 }
333 fn record_objects(&self, record: &DocumentRecord) -> &[u8] {
334 if record.storage_id == self.stores.id.unwrap_or(0) {
335 self.object_bytes()
336 } else {
337 self.sealed[&record.storage_id]
338 .objects
339 .as_deref()
340 .map(|m| &m[..self.sealed[&record.storage_id].bounds.objects as usize])
341 .unwrap_or(&[])
342 }
343 }
344 fn object_bytes(&self) -> &[u8] {
345 self.objects_map.as_deref().map(|m| &m[..]).unwrap_or(&[])
346 }
347 fn fde_bytes(&self) -> &[u8] {
348 self.fde_map.as_deref().map(|m| &m[..]).unwrap_or(&[])
349 }
350}
351
352struct SegmentStores {
353 objects: CompressedVectorStore,
354 fde: FixedVectorStore,
355 root: PathBuf,
356 id: Option<u64>,
357 retired: std::sync::atomic::AtomicBool,
358}
359
360impl Drop for SegmentStores {
361 fn drop(&mut self) {
362 if self.retired.load(std::sync::atomic::Ordering::Relaxed) {
363 if self.id.is_some() {
364 let _ = fs::remove_dir_all(&self.root);
365 } else {
366 let _ = fs::remove_dir_all(self.root.join("objects"));
367 let _ = fs::remove_dir_all(self.root.join("fde"));
368 }
369 }
370 }
371}
372
373struct DirectoryLock(File);
374
375impl Drop for DirectoryLock {
376 fn drop(&mut self) {
377 let _ = fs2::FileExt::unlock(&self.0);
380 }
381}
382
383pub struct MultiVectorIndex {
384 root: PathBuf,
385 config: IndexConfig,
386 fde: FdeEncoder,
387 state: RwLock<Arc<State>>,
388 writer: Mutex<()>,
389 durability: Durability,
390 _directory_lock: DirectoryLock,
392}
393
394impl MultiVectorIndex {
395 pub(crate) fn initialize(&self) -> Result<(), IndexError> {
396 let _writer = self.writer.lock().unwrap();
397 self.persist(&self.snapshot())
398 }
399 pub fn open_existing(
400 path: impl AsRef<Path>,
401 durability: Durability,
402 ) -> Result<Self, IndexError> {
403 let bytes = fs::read(path.as_ref().join("manifest.json"))?;
404 let value: Value = serde_json::from_slice(&bytes)?;
405 let manifest: Manifest = if value.get("manifest").is_some() {
406 let envelope: ManifestEnvelope = serde_json::from_value(value)?;
407 if blake3::hash(envelope.manifest.as_bytes()).to_hex().as_str()
408 != envelope.checksum_blake3
409 {
410 return Err(IndexError::Invalid("manifest checksum mismatch".into()));
411 }
412 serde_json::from_str(&envelope.manifest)?
413 } else {
414 serde_json::from_slice(&bytes)?
415 };
416 Self::open_with_durability(path, manifest.config, durability)
417 }
418 pub fn open(path: impl AsRef<Path>, config: IndexConfig) -> Result<Self, IndexError> {
419 Self::open_with_durability(path, config, Durability::Fsync)
420 }
421
422 pub fn open_with_durability(
423 path: impl AsRef<Path>,
424 config: IndexConfig,
425 durability: Durability,
426 ) -> Result<Self, IndexError> {
427 if config.dimension == 0
428 || config.centroids == 0
429 || config.probes == 0
430 || !(1..=8).contains(&config.residual_bits)
431 || config.fde_repetitions == 0
432 || config.fde_ksim == 0
433 || config.fde_ksim > 12
434 || config.fde_projected == 0
435 {
436 return Err(IndexError::Invalid(
437 "dimension, centroids, probes, and residual_bits (1..=8) must be valid".into(),
438 ));
439 }
440 validate_config_size(&config)?;
441 config.analyzer.validate().map_err(IndexError::Invalid)?;
442 let root = path.as_ref().to_owned();
443 let mut created_parents = Vec::new();
444 let mut missing = root.as_path();
445 while !missing.exists() {
446 let parent = missing
447 .parent()
448 .filter(|p| !p.as_os_str().is_empty())
449 .unwrap_or(Path::new("."));
450 created_parents.push(parent.to_owned());
451 missing = parent;
452 }
453 fs::create_dir_all(&root)?;
454 let directory_lock = OpenOptions::new()
455 .create(true)
456 .truncate(false)
457 .read(true)
458 .write(true)
459 .open(root.join("index.lock"))?;
460 fs2::FileExt::try_lock_exclusive(&directory_lock).map_err(|e| {
461 IndexError::Invalid(format!("index already open or cannot lock directory: {e}"))
462 })?;
463 let directory_lock = DirectoryLock(directory_lock);
464 let manifest_path = root.join("manifest.json");
465 let (
466 generation,
467 fde_encoding_version,
468 codebook,
469 residual_codebook,
470 mut documents,
471 representation_schema,
472 segments,
473 storage_generation,
474 sealed_bounds,
475 checksummed,
476 ) = if manifest_path.exists() {
477 let bytes = fs::read(&manifest_path)?;
478 let header: Value = serde_json::from_slice(&bytes)?;
479 let (m, checksummed): (Manifest, bool) = if header.get("manifest").is_some() {
480 let envelope: ManifestEnvelope = serde_json::from_slice(&bytes)?;
481 if !matches!(envelope.format_version, 2 | FORMAT_VERSION)
482 || blake3::hash(envelope.manifest.as_bytes()).to_hex().as_str()
483 != envelope.checksum_blake3
484 {
485 return Err(IndexError::Invalid(
486 "manifest checksum or envelope version mismatch".into(),
487 ));
488 }
489 (serde_json::from_str(&envelope.manifest)?, true)
490 } else {
491 verify_manifest_checksum(&bytes, &root.join("manifest.sha256"))?;
492 (serde_json::from_slice(&bytes)?, false)
493 };
494 if !(if checksummed {
495 matches!(m.format_version, 2 | FORMAT_VERSION)
496 } else {
497 m.format_version == 1
498 }) || (checksummed && m.segments.is_none())
499 {
500 return Err(IndexError::Invalid(
501 "unsupported manifest version or missing committed boundaries".into(),
502 ));
503 }
504 if m.config != config {
505 return Err(IndexError::Config {
506 actual: Box::new(m.config),
507 requested: Box::new(config),
508 });
509 }
510 (
511 m.generation,
512 m.fde_encoding_version,
513 m.codebook,
514 m.residual_codebook,
515 m.documents,
516 m.representation_schema,
517 m.segments,
518 m.storage_generation,
519 m.sealed,
520 checksummed,
521 )
522 } else {
523 (
525 0,
526 2,
527 vec![],
528 vec![],
529 HashMap::new(),
530 BTreeMap::new(),
531 Some(SegmentBoundaries { objects: 0, fde: 0 }),
532 None,
533 Vec::new(),
534 false,
535 )
536 };
537 if !matches!(fde_encoding_version, 1 | 2) {
538 return Err(IndexError::Invalid(format!(
539 "unsupported FDE encoding version {fde_encoding_version}"
540 )));
541 }
542 validate_codebooks(
543 &config,
544 &codebook,
545 &residual_codebook,
546 documents.values().any(|d| d.tokens > 0),
547 )?;
548 let store_root = storage_generation
549 .map(|id| root.join("segments").join(id.to_string()))
550 .unwrap_or_else(|| root.clone());
551 if storage_generation.is_some() && !store_root.is_dir() {
552 return Err(IndexError::Invalid(
553 "missing committed segment directory".into(),
554 ));
555 }
556 let objects = CompressedVectorStore::new(store_root.join("objects"))?;
557 let fde_store = FixedVectorStore::new(store_root.join("fde"))?;
558 if !manifest_path.exists()
559 && (objects.len()? != 0
560 || fde_store.len()? != 0
561 || root
562 .join("segments")
563 .read_dir()
564 .is_ok_and(|mut entries| entries.next().is_some()))
565 {
566 return Err(IndexError::Invalid(
567 "missing manifest for non-empty segments".into(),
568 ));
569 }
570 let boundaries = segments.unwrap_or(SegmentBoundaries {
571 objects: objects.len()?,
572 fde: fde_store.len()?,
573 });
574 if objects.len()? < boundaries.objects || fde_store.len()? < boundaries.fde {
575 return Err(IndexError::Invalid(
576 "segment shorter than committed boundary".into(),
577 ));
578 }
579 let mut sealed = HashMap::new();
580 for (id, bounds) in sealed_bounds {
581 if id == storage_generation.unwrap_or(0) || sealed.contains_key(&id) {
582 return Err(IndexError::Invalid("duplicate storage segment".into()));
583 }
584 sealed.insert(id, Arc::new(SegmentSnapshot::open(&root, id, bounds)?));
585 }
586 if !documents.is_empty() {
587 let objects_map = if objects.len()? > 0 {
588 Some(objects.map()?)
589 } else {
590 None
591 };
592 let fde_map = if fde_store.len()? > 0 {
593 Some(fde_store.map()?)
594 } else {
595 None
596 };
597 let object_bytes = objects_map
598 .as_deref()
599 .unwrap_or(&[])
600 .get(
601 ..usize::try_from(boundaries.objects)
602 .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?,
603 )
604 .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?;
605 let fde_bytes = fde_map
606 .as_deref()
607 .unwrap_or(&[])
608 .get(
609 ..usize::try_from(boundaries.fde)
610 .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?,
611 )
612 .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?;
613 let fde_dimension =
614 (1usize << config.fde_ksim) * config.fde_projected * config.fde_repetitions;
615 for d in documents.values_mut() {
616 let (object_bytes, fde_bytes) = if d.storage_id == storage_generation.unwrap_or(0) {
617 (object_bytes, fde_bytes)
618 } else {
619 let segment = sealed.get(&d.storage_id).ok_or_else(|| {
620 IndexError::Invalid("document references missing segment".into())
621 })?;
622 (
623 segment
624 .objects
625 .as_deref()
626 .map(|m| &m[..segment.bounds.objects as usize])
627 .unwrap_or(&[]),
628 segment
629 .fde
630 .as_deref()
631 .map(|m| &m[..segment.bounds.fde as usize])
632 .unwrap_or(&[]),
633 )
634 };
635 d.fields.verify(fde_bytes)?;
636 if d.tokens == 0 {
637 if !d.centroid_ids.is_empty()
638 || !d.unique_centroids.is_empty()
639 || d.location.length != 0
640 || d.fde_location.length != 0
641 {
642 return Err(IndexError::Invalid("invalid empty multivector".into()));
643 }
644 continue;
645 }
646 verify_record(object_bytes, d.location, checksummed)?;
647 verify_record(fde_bytes, d.fde_location, checksummed)?;
648 let decoded = CompressedVectorStore::decode(
649 object_bytes,
650 d.location,
651 &codebook,
652 &residual_codebook,
653 )?;
654 if decoded.dimension != config.dimension
655 || d.tokens == 0
656 || decoded.values.len() / config.dimension != d.tokens
657 || d.centroid_ids.len() != d.tokens
658 || d.compressed_bytes != d.location.length
659 || d.centroid_ids.iter().any(|&c| c as usize >= codebook.len())
660 || decoded.values.iter().any(|v| !v.is_finite())
661 {
662 return Err(IndexError::Invalid(
663 "invalid document shape or centroid IDs".into(),
664 ));
665 }
666 let bytes = record_bytes(object_bytes, d.location)?;
667 let stored_ids = bytes[16..16 + d.tokens * 4]
668 .chunks_exact(4)
669 .map(|v| u32::from_le_bytes(v.try_into().unwrap()));
670 if !stored_ids.eq(d.centroid_ids.iter().copied()) {
671 return Err(IndexError::Invalid(
672 "manifest/record centroid mismatch".into(),
673 ));
674 }
675 let mut unique = d.centroid_ids.clone();
676 unique.sort_unstable();
677 unique.dedup();
678 if unique != d.unique_centroids {
679 return Err(IndexError::Invalid("invalid document posting list".into()));
680 }
681 if FixedVectorStore::get(fde_bytes, d.fde_location, fde_dimension)?
682 .iter()
683 .any(|v| !v.is_finite())
684 {
685 return Err(IndexError::Invalid("non-finite FDE record".into()));
686 }
687 d.location.checksum =
689 Some(*blake3::hash(record_bytes(object_bytes, d.location)?).as_bytes());
690 d.fde_location.checksum =
691 Some(*blake3::hash(record_bytes(fde_bytes, d.fde_location)?).as_bytes());
692 }
693 }
694 for segment in sealed.values() {
696 segment.stores.objects.recover(segment.bounds.objects)?;
697 segment.stores.fde.recover(segment.bounds.fde)?;
698 }
699 objects.recover(boundaries.objects)?;
700 fde_store.recover(boundaries.fde)?;
701 if durability == Durability::Fsync {
702 File::open(store_root.join("objects"))?.sync_all()?;
703 File::open(store_root.join("fde"))?.sync_all()?;
704 File::open(&root)?.sync_all()?;
705 for parent in created_parents {
706 File::open(parent)?.sync_all()?;
707 }
708 }
709 if manifest_path.exists() {
710 if let Ok(entries) = fs::read_dir(root.join("segments")) {
711 for entry in entries {
712 let entry = entry?;
713 if !entry.file_type()?.is_dir() {
714 continue;
715 }
716 if let Some(id) = entry
717 .file_name()
718 .to_str()
719 .and_then(|s| s.parse::<u64>().ok())
720 {
721 if Some(id) != storage_generation && !sealed.contains_key(&id) {
722 fs::remove_dir_all(entry.path())?;
723 }
724 }
725 }
726 }
727 if storage_generation.is_some() && !sealed.contains_key(&0) {
728 for name in ["objects", "fde"] {
729 let path = root.join(name);
730 if path.exists() {
731 fs::remove_dir_all(path)?;
732 }
733 }
734 }
735 }
736 let mut postings = vec![HashSet::new(); codebook.len()];
737 for (id, d) in &documents {
738 for &c in &d.centroid_ids {
739 postings[c as usize].insert(id.clone());
740 }
741 }
742 let mut retrieval = RetrievalState::from_schema(
747 representation_schema,
748 Analyzer::new(config.analyzer.clone()),
749 )?;
750 let mut ordered: Vec<_> = documents.iter().collect();
751 ordered.sort_by(|a, b| a.0.cmp(b.0));
752 for (id, d) in ordered {
753 retrieval.insert(id, &d.fields)?;
754 }
755 Ok(Self {
756 fde: if fde_encoding_version == 2 {
757 FdeEncoder::new(
758 config.dimension,
759 config.fde_ksim,
760 config.fde_projected,
761 config.fde_repetitions,
762 0x4d55_5645_5241,
763 )
764 } else {
765 FdeEncoder::with_version(
766 config.dimension,
767 config.fde_ksim,
768 config.fde_projected,
769 config.fde_repetitions,
770 0x4d55_5645_5241,
771 fde_encoding_version,
772 )
773 },
774 writer: Mutex::new(()),
775 durability,
776 _directory_lock: directory_lock,
777 state: RwLock::new(Arc::new(State {
778 generation,
779 codebook,
780 residual_codebook,
781 documents,
782 postings,
783 fde_ann: None,
784 named_ann: HashMap::new(),
785 objects_map: if objects.len()? > 0 {
786 Some(Arc::new(objects.map()?))
787 } else {
788 None
789 },
790 fde_map: if fde_store.len()? > 0 {
791 Some(Arc::new(fde_store.map()?))
792 } else {
793 None
794 },
795 stores: Arc::new(SegmentStores {
796 objects,
797 fde: fde_store,
798 root: store_root,
799 id: storage_generation,
800 retired: std::sync::atomic::AtomicBool::new(false),
801 }),
802 retrieval: Arc::new(retrieval),
803 sealed,
804 })),
805 root,
806 config,
807 })
808 }
809 fn snapshot(&self) -> Arc<State> {
810 Arc::clone(&self.state.read().unwrap())
811 }
812
813 fn validate(&self, v: &[Vector]) -> Result<(), IndexError> {
814 if v.is_empty()
815 || v.iter()
816 .any(|x| x.len() != self.config.dimension || x.iter().any(|n| !n.is_finite()))
817 {
818 Err(IndexError::Invalid(format!(
819 "vectors must be a non-empty matrix of {} finite values",
820 self.config.dimension
821 )))
822 } else {
823 Ok(())
824 }
825 }
826 fn persist(&self, s: &State) -> Result<(), IndexError> {
827 if self.durability == Durability::Fsync {
828 s.stores.objects.sync()?;
829 commit_boundary("objects_synced")?;
830 s.stores.fde.sync()?;
831 commit_boundary("fde_synced")?;
832 }
833 let manifest = serde_json::to_string(&Manifest {
834 format_version: FORMAT_VERSION,
835 generation: s.generation,
836 fde_encoding_version: self.fde.encoding_version(),
837 config: self.config.clone(),
838 codebook: s.codebook.clone(),
839 residual_codebook: s.residual_codebook.clone(),
840 documents: s.documents.clone(),
841 representation_schema: s.retrieval.schema().clone(),
842 storage_generation: s.stores.id,
843 sealed: s
844 .sealed
845 .iter()
846 .map(|(&id, segment)| (id, segment.bounds))
847 .collect(),
848 segments: Some(SegmentBoundaries {
849 objects: s.stores.objects.len()?,
850 fde: s.stores.fde.len()?,
851 }),
852 })?;
853 let envelope = ManifestEnvelope {
854 format_version: FORMAT_VERSION,
855 checksum_blake3: blake3::hash(manifest.as_bytes()).to_hex().to_string(),
856 manifest,
857 };
858 let bytes = serde_json::to_vec(&envelope)?;
859 atomic_write(
860 &self.root.join("manifest.json"),
861 &bytes,
862 self.durability == Durability::Fsync,
863 )
864 .map_err(|e| {
865 if e.published {
866 IndexError::CommitUncertain(e.source)
867 } else {
868 IndexError::Io(e.source)
869 }
870 })
871 }
872
873 fn commit(&self, current: &State, mut next: State) -> Result<(), IndexError> {
874 next.generation = current
875 .generation
876 .checked_add(1)
877 .ok_or_else(|| IndexError::Invalid("generation exhausted".into()))?;
878 if let Some(ann) = next.fde_ann.as_mut() {
881 ann.generation = next.generation;
882 }
883 for ann in next.named_ann.values_mut() {
884 ann.generation = next.generation;
885 }
886 if next.stores.objects.len()? as usize != next.object_bytes().len() {
887 next.objects_map = Some(Arc::new(next.stores.objects.map()?));
888 }
889 if next.stores.fde.len()? as usize != next.fde_bytes().len() {
890 next.fde_map = Some(Arc::new(next.stores.fde.map()?));
891 }
892 let result = self.persist(&next);
893 if result.is_ok() || matches!(result, Err(IndexError::CommitUncertain(_))) {
894 next.stores
895 .retired
896 .store(false, std::sync::atomic::Ordering::Relaxed);
897 *self.state.write().unwrap() = Arc::new(next);
898 }
899 result
900 }
901 fn invalidate_fde_ann(next: &mut State) {
902 next.fde_ann = None;
903 }
904 pub fn train(&self, samples: &[Vector], iterations: usize) -> Result<(), IndexError> {
906 self.validate(samples)?;
907 if samples.len() < self.config.centroids {
908 return Err(IndexError::Invalid(
909 "training samples must be >= centroid count".into(),
910 ));
911 }
912 let _writer = self.writer.lock().unwrap();
913 let s = self.snapshot();
914 if s.documents.values().any(|d| d.tokens > 0) {
915 return Err(IndexError::Invalid(
916 "cannot retrain an index containing token vectors".into(),
917 ));
918 }
919 let samples: Vec<_> = samples.iter().map(|sample| normalize(sample)).collect();
920 let mut centers: Vec<_> = (0..self.config.centroids)
921 .map(|i| samples[i * samples.len() / self.config.centroids].clone())
922 .collect();
923 for _ in 0..iterations.max(1) {
924 let mut sums = vec![vec![0.; self.config.dimension]; centers.len()];
925 let mut counts = vec![0usize; centers.len()];
926 for v in &samples {
927 let c = nearest(v, ¢ers);
928 counts[c] += 1;
929 for (i, x) in v.iter().enumerate() {
930 sums[c][i] += x;
931 }
932 }
933 for c in 0..centers.len() {
934 if counts[c] > 0 {
935 for x in &mut sums[c] {
936 *x /= counts[c] as f32;
937 }
938 centers[c] = normalize(&sums[c]);
939 }
940 }
941 }
942 let mut next = (*s).clone();
943 next.codebook = centers;
944 let residuals: Vec<f32> = samples
945 .iter()
946 .flat_map(|vector| {
947 let center = &next.codebook[nearest(vector, &next.codebook)];
948 vector
949 .iter()
950 .zip(center)
951 .map(|(value, centroid)| value - centroid)
952 .collect::<Vec<_>>()
953 })
954 .collect();
955 next.residual_codebook =
956 train_scalar_codebook(&residuals, 1usize << self.config.residual_bits, 12);
957 next.postings = vec![HashSet::new(); next.codebook.len()];
958 Self::invalidate_fde_ann(&mut next);
960 self.commit(&s, next)
961 }
962 pub fn upsert(
963 &self,
964 id: impl Into<String>,
965 vectors: Vec<Vector>,
966 metadata: Value,
967 ) -> Result<(), IndexError> {
968 self.upsert_batch(vec![UpsertDocument {
969 id: id.into(),
970 vectors,
971 metadata,
972 }])
973 }
974 pub fn upsert_batch(&self, batch: Vec<UpsertDocument>) -> Result<(), IndexError> {
979 self.upsert_records(
980 batch
981 .into_iter()
982 .map(|d| RetrievalDocument {
983 id: d.id,
984 vectors: d.vectors,
985 metadata: d.metadata,
986 ..RetrievalDocument::default()
987 })
988 .collect(),
989 )
990 }
991 pub fn upsert_records(&self, batch: Vec<RetrievalDocument>) -> Result<(), IndexError> {
992 if batch.is_empty() {
993 return Ok(());
994 }
995 for document in &batch {
996 if !document.vectors.is_empty() {
997 self.validate(&document.vectors)?;
998 }
999 }
1000 let _writer = self.writer.lock().unwrap();
1001 let s = self.snapshot();
1002 if s.codebook.is_empty() && batch.iter().any(|d| !d.vectors.is_empty()) {
1003 return Err(IndexError::Invalid(
1004 "index is untrained; call train first".into(),
1005 ));
1006 }
1007 let mut next = (*s).clone();
1008 if next.stores.objects.len()? + next.stores.fde.len()? >= 64 * 1024 * 1024 {
1009 self.rotate_segment(&mut next)?;
1010 }
1011 for document in batch {
1012 let fields = Arc::new(Fields::prepare(&document, &next.stores)?);
1013 let id = document.id;
1014 Arc::make_mut(&mut next.retrieval).insert(&id, &fields)?;
1015 if let Some(old_ids) = next
1016 .documents
1017 .get(&id)
1018 .map(|old| old.unique_centroids.clone())
1019 {
1020 for c in old_ids {
1021 next.postings[c as usize].remove(&id);
1022 }
1023 }
1024 let vectors: Vec<_> = document
1025 .vectors
1026 .iter()
1027 .map(|vector| normalize(vector))
1028 .collect();
1029 let ids: Vec<u32> = vectors
1030 .iter()
1031 .map(|v| nearest(v, &next.codebook) as u32)
1032 .collect();
1033 let mut unique_centroids = ids.clone();
1034 unique_centroids.sort_unstable();
1035 unique_centroids.dedup();
1036 let (location, size) = if vectors.is_empty() {
1037 (
1038 ObjectLocation {
1039 offset: 0,
1040 length: 0,
1041 checksum: None,
1042 },
1043 0,
1044 )
1045 } else {
1046 next.stores.objects.put(
1047 &vectors,
1048 &ids,
1049 &next.codebook,
1050 &next.residual_codebook,
1051 self.config.residual_bits,
1052 )?
1053 };
1054 commit_boundary("object_appended")?;
1055 let fde_location = if vectors.is_empty() {
1056 ObjectLocation {
1057 offset: 0,
1058 length: 0,
1059 checksum: None,
1060 }
1061 } else {
1062 next.stores.fde.put(&self.fde.encode_document(&vectors))?
1063 };
1064 commit_boundary("fde_appended")?;
1065 for &c in &unique_centroids {
1066 next.postings[c as usize].insert(id.clone());
1067 }
1068 if let Some(ann) = next.fde_ann.as_mut() {
1069 if let Some(&point_id) = ann.base.by_id.get(&id) {
1070 ann.tombstones.insert(point_id);
1071 }
1072 if vectors.is_empty() {
1073 ann.delta.remove(&id);
1074 } else {
1075 ann.delta.insert(id.clone());
1076 }
1077 }
1078 for (field, ann) in &mut next.named_ann {
1079 if let Some(&point) = ann.base.by_id.get(&id) {
1080 ann.tombstones.insert(point);
1081 }
1082 if fields.has_dense(field) {
1083 ann.delta.insert(id.clone());
1084 } else {
1085 ann.delta.remove(&id);
1086 }
1087 }
1088 next.documents.insert(
1089 id,
1090 DocumentRecord {
1091 centroid_ids: ids,
1092 unique_centroids,
1093 location,
1094 fde_location,
1095 metadata: document.metadata,
1096 tokens: vectors.len(),
1097 compressed_bytes: size,
1098 fields,
1099 storage_id: next.stores.id.unwrap_or(0),
1100 },
1101 );
1102 }
1103 self.commit(&s, next)
1104 }
1105 pub fn delete(&self, id: &str) -> Result<bool, IndexError> {
1106 let _writer = self.writer.lock().unwrap();
1107 let s = self.snapshot();
1108 if !s.documents.contains_key(id) {
1109 return Ok(false);
1110 }
1111 let mut next = (*s).clone();
1112 let d = next.documents.remove(id).unwrap();
1113 Arc::make_mut(&mut next.retrieval).remove(id);
1114 for c in d.unique_centroids {
1115 next.postings[c as usize].remove(id);
1116 }
1117 if let Some(ann) = next.fde_ann.as_mut() {
1118 if let Some(&point_id) = ann.base.by_id.get(id) {
1119 ann.tombstones.insert(point_id);
1120 }
1121 ann.delta.remove(id);
1122 }
1123 for ann in next.named_ann.values_mut() {
1124 if let Some(&point) = ann.base.by_id.get(id) {
1125 ann.tombstones.insert(point);
1126 }
1127 ann.delta.remove(id);
1128 }
1129 self.commit(&s, next)?;
1130 Ok(true)
1131 }
1132 pub fn query(
1134 &self,
1135 vectors: &[Vector],
1136 top_k: usize,
1137 candidates: Option<usize>,
1138 ) -> Result<Vec<Hit>, IndexError> {
1139 self.validate(vectors)?;
1140 if top_k == 0 {
1141 return Err(IndexError::Invalid("top_k must be positive".into()));
1142 }
1143 let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1144 let cap = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1148 let s = self.snapshot();
1149 let approximate = self.exact_fde_scores_capped(&s, &normalized, Some(cap))?;
1150 self.rescore(&s, &normalized, approximate, top_k, candidates)
1151 }
1152 pub fn hnsw_ready(&self) -> bool {
1154 let s = self.snapshot();
1155 s.fde_ann
1156 .as_ref()
1157 .is_some_and(|ann| ann.generation == s.generation)
1158 }
1159
1160 pub fn query_auto(
1163 &self,
1164 vectors: &[Vector],
1165 top_k: usize,
1166 candidates: Option<usize>,
1167 ef_search: usize,
1168 ) -> Result<Vec<Hit>, IndexError> {
1169 self.query_auto_with_backend(vectors, top_k, candidates, ef_search)
1170 .map(|(hits, _)| hits)
1171 }
1172
1173 pub fn query_auto_with_backend(
1175 &self,
1176 vectors: &[Vector],
1177 top_k: usize,
1178 candidates: Option<usize>,
1179 ef_search: usize,
1180 ) -> Result<(Vec<Hit>, &'static str), IndexError> {
1181 self.validate(vectors)?;
1182 if top_k == 0 || ef_search == 0 {
1183 return Err(IndexError::Invalid(
1184 "top_k and ef_search must be positive".into(),
1185 ));
1186 }
1187 let normalized: Vec<_> = vectors.iter().map(|v| normalize(v)).collect();
1188 let count = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1189 let s = self.snapshot();
1190 let (approximate, backend) = if s
1191 .fde_ann
1192 .as_ref()
1193 .is_some_and(|ann| ann.generation == s.generation)
1194 {
1195 (
1196 self.ann_fde_scores(&s, &self.fde.encode_query(&normalized), count, ef_search)?,
1197 "hnsw",
1198 )
1199 } else {
1200 (
1201 self.exact_fde_scores_capped(&s, &normalized, Some(count))?,
1202 "muvera",
1203 )
1204 };
1205 Ok((
1206 self.rescore(&s, &normalized, approximate, top_k, candidates)?,
1207 backend,
1208 ))
1209 }
1210 pub fn query_with_centroid_pruning(
1213 &self,
1214 vectors: &[Vector],
1215 top_k: usize,
1216 candidates: usize,
1217 rerank_candidates: usize,
1218 ) -> Result<Vec<Hit>, IndexError> {
1219 self.validate(vectors)?;
1220 check_pruning_shape(top_k, candidates, rerank_candidates)?;
1221 let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1222 let s = self.snapshot();
1223 let approximate = self.exact_fde_scores_capped(&s, &normalized, Some(candidates))?;
1224 self.prune_and_rescore(
1225 &s,
1226 &normalized,
1227 approximate,
1228 top_k,
1229 candidates,
1230 rerank_candidates,
1231 )
1232 }
1233 pub fn query_with_fde_ann_and_pruning(
1236 &self,
1237 vectors: &[Vector],
1238 top_k: usize,
1239 candidates: usize,
1240 rerank_candidates: usize,
1241 ef_search: usize,
1242 ) -> Result<Vec<Hit>, IndexError> {
1243 self.validate(vectors)?;
1244 if ef_search == 0 {
1245 return Err(IndexError::Invalid("ef_search must be positive".into()));
1246 }
1247 check_pruning_shape(top_k, candidates, rerank_candidates)?;
1248 let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1249 let query_fde = self.fde.encode_query(&normalized);
1250 let s = self.snapshot();
1251 let approximate = self.ann_fde_scores(&s, &query_fde, candidates, ef_search)?;
1252 self.prune_and_rescore(
1253 &s,
1254 &normalized,
1255 approximate,
1256 top_k,
1257 candidates,
1258 rerank_candidates,
1259 )
1260 }
1261 fn prune_and_rescore(
1262 &self,
1263 s: &State,
1264 normalized: &[Vector],
1265 approximate: Vec<(String, f32)>,
1266 top_k: usize,
1267 candidates: usize,
1268 rerank_candidates: usize,
1269 ) -> Result<Vec<Hit>, IndexError> {
1270 let broad: Vec<_> = approximate.into_iter().take(candidates).collect();
1271 let pruned = centroid_prune(s, normalized, broad, rerank_candidates);
1272 self.rescore(s, normalized, pruned, top_k, Some(rerank_candidates))
1273 }
1274 fn exact_fde_scores(&self, normalized: &[Vector]) -> Result<Vec<(String, f32)>, IndexError> {
1275 let s = self.snapshot();
1276 self.exact_fde_scores_capped(&s, normalized, None)
1277 }
1278
1279 fn exact_fde_scores_capped(
1285 &self,
1286 s: &State,
1287 normalized: &[Vector],
1288 cap: Option<usize>,
1289 ) -> Result<Vec<(String, f32)>, IndexError> {
1290 self.exact_fde_scores_filtered(s, normalized, cap, None)
1291 }
1292 fn exact_fde_scores_filtered(
1293 &self,
1294 s: &State,
1295 normalized: &[Vector],
1296 cap: Option<usize>,
1297 eligible: Option<&HashSet<&str>>,
1298 ) -> Result<Vec<(String, f32)>, IndexError> {
1299 if !s.documents.values().any(|d| d.tokens > 0) {
1300 return Ok(Vec::new());
1301 }
1302 let query_fde = self.fde.encode_query(normalized);
1303 let fde_dimension = self.fde.output_dimension();
1304 let approximate_results: Result<Vec<_>, io::Error> = s
1305 .documents
1306 .par_iter()
1307 .filter(|(id, d)| d.tokens > 0 && eligible.is_none_or(|ids| ids.contains(id.as_str())))
1308 .map(|(id, record)| {
1309 Ok((
1310 id.clone(),
1311 dot(
1312 &query_fde,
1313 FixedVectorStore::get(
1314 s.record_fde(record),
1315 record.fde_location,
1316 fde_dimension,
1317 )?,
1318 ),
1319 ))
1320 })
1321 .collect();
1322 let mut approximate = approximate_results?;
1323 let n = approximate.len();
1324 let by_desc =
1325 |a: &(String, f32), b: &(String, f32)| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0));
1326 match cap {
1327 Some(k) if k < n => {
1328 approximate.select_nth_unstable_by(k, by_desc);
1329 approximate.truncate(k);
1330 approximate.par_sort_unstable_by(by_desc);
1331 }
1332 _ => {
1333 approximate.par_sort_unstable_by(by_desc);
1334 }
1335 }
1336 Ok(approximate)
1337 }
1338 pub fn exact_fde_candidates(
1340 &self,
1341 vectors: &[Vector],
1342 count: usize,
1343 ) -> Result<Vec<CandidateHit>, IndexError> {
1344 self.validate(vectors)?;
1345 if count == 0 {
1346 return Err(IndexError::Invalid(
1347 "candidate count must be positive".into(),
1348 ));
1349 }
1350 let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1351 Ok(self
1352 .exact_fde_scores(&normalized)?
1353 .into_iter()
1354 .take(count)
1355 .map(|(id, score)| CandidateHit { id, score })
1356 .collect())
1357 }
1358 pub fn build_fde_ann(&self, m: usize, ef_construct: usize) -> Result<usize, IndexError> {
1360 self.build_ann(None, m, ef_construct)
1361 }
1362 pub fn build_dense_ann(
1363 &self,
1364 field: &str,
1365 m: usize,
1366 ef_construct: usize,
1367 ) -> Result<usize, IndexError> {
1368 self.build_ann(Some(field), m, ef_construct)
1369 }
1370 fn build_ann(
1371 &self,
1372 field: Option<&str>,
1373 m: usize,
1374 ef_construct: usize,
1375 ) -> Result<usize, IndexError> {
1376 if !(1..=128).contains(&m) || !(1..=65_536).contains(&ef_construct) {
1377 return Err(IndexError::Invalid(
1378 "HNSW m must be in 1..=128 and ef_construct in 1..=65536".into(),
1379 ));
1380 }
1381 let s = self.snapshot();
1382 let dimension = if let Some(field) = field {
1383 s.retrieval.dense_dimension(field)?
1384 } else {
1385 self.fde.output_dimension()
1386 };
1387 let mut ids: Vec<_> = s
1388 .documents
1389 .iter()
1390 .filter(|(_, d)| field.map_or(d.tokens > 0, |f| d.fields.has_dense(f)))
1391 .map(|(id, _)| id.clone())
1392 .collect();
1393 ids.sort();
1394 let mut hnsw = HNSWIndex::new(DistanceMetric::Dot, m, ef_construct, 16, dimension);
1395 const CHUNK: usize = 4096;
1400 for (chunk_idx, chunk_ids) in ids.chunks(CHUNK).enumerate() {
1401 let base = chunk_idx * CHUNK;
1402 let entries: Vec<(u64, Vec<f32>)> = chunk_ids
1403 .iter()
1404 .enumerate()
1405 .map(|(offset, id)| {
1406 let vector = if let Some(field) = field {
1407 self.dense_vector(&s, id, field)?
1408 } else {
1409 FixedVectorStore::get(
1410 s.record_fde(&s.documents[id]),
1411 s.documents[id].fde_location,
1412 dimension,
1413 )?
1414 }
1415 .to_vec();
1416 Ok::<_, IndexError>(((base + offset) as u64, vector))
1417 })
1418 .collect::<Result<Vec<_>, _>>()?;
1419 hnsw.par_insert_batch(&entries).map_err(|error| {
1420 IndexError::Invalid(format!("HNSW par_insert_batch failed: {error}"))
1421 })?;
1422 }
1423 if !ids.is_empty() {
1426 hnsw.reorder_rcm();
1427 }
1428 let built_generation = s.generation;
1429 drop(s);
1430 commit_boundary("ann_built_before_publish")?;
1431 #[cfg(test)]
1432 transaction_tests::before_ann_publish();
1433 let _writer = self.writer.lock().unwrap();
1434 let s = self.snapshot();
1435 if s.generation != built_generation {
1436 return Err(IndexError::Invalid(format!(
1437 "index generation moved during HNSW build ({} -> {}); retry",
1438 built_generation, s.generation,
1439 )));
1440 }
1441 let count = ids.len();
1442 let by_id = ids
1443 .iter()
1444 .enumerate()
1445 .map(|(idx, id)| (id.clone(), idx as u64))
1446 .collect();
1447 let mut next = (*s).clone();
1448 let ann = FdeAnn {
1449 base: Arc::new(FdeAnnBase {
1450 index: hnsw,
1451 ids,
1452 by_id,
1453 field: field.map(str::to_owned),
1454 }),
1455 delta: HashSet::new(),
1456 tombstones: HashSet::new(),
1457 generation: built_generation,
1458 };
1459 if let Some(field) = field {
1460 next.named_ann.insert(field.to_owned(), ann);
1461 } else {
1462 next.fde_ann = Some(ann);
1463 }
1464 *self.state.write().unwrap() = Arc::new(next);
1465 Ok(count)
1466 }
1467 pub fn query_with_fde_ann(
1468 &self,
1469 vectors: &[Vector],
1470 top_k: usize,
1471 candidates: Option<usize>,
1472 ef_search: usize,
1473 ) -> Result<Vec<Hit>, IndexError> {
1474 self.validate(vectors)?;
1475 if top_k == 0 || ef_search == 0 {
1476 return Err(IndexError::Invalid(
1477 "top_k and ef_search must be positive".into(),
1478 ));
1479 }
1480 let normalized: Vec<_> = vectors.iter().map(|v| normalize(v)).collect();
1481 let query_fde = self.fde.encode_query(&normalized);
1482 let s = self.snapshot();
1483 let count = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1484 let approximate = self.ann_fde_scores(&s, &query_fde, count, ef_search)?;
1485 self.rescore(&s, &normalized, approximate, top_k, candidates)
1486 }
1487 fn ann_fde_scores(
1488 &self,
1489 s: &State,
1490 query_fde: &Vector,
1491 count: usize,
1492 ef_search: usize,
1493 ) -> Result<Vec<(String, f32)>, IndexError> {
1494 let ann = s.fde_ann.as_ref().ok_or_else(|| {
1495 IndexError::Invalid("FDE ANN is not built; call /v1/fde/index".into())
1496 })?;
1497 self.ann_scores(s, ann, query_fde, count, ef_search)
1498 }
1499 fn ann_scores(
1500 &self,
1501 s: &State,
1502 ann: &FdeAnn,
1503 query_fde: &Vector,
1504 count: usize,
1505 ef_search: usize,
1506 ) -> Result<Vec<(String, f32)>, IndexError> {
1507 if ann.generation != s.generation {
1508 return Err(IndexError::Invalid(
1509 "FDE ANN generation is stale; rebuild".into(),
1510 ));
1511 }
1512 let mut scores = Vec::new();
1513 if ann.base.ids.len() > ann.tombstones.len() {
1514 let base_count = count
1516 .saturating_add(ann.tombstones.len())
1517 .min(ann.base.ids.len());
1518 let options = SearchRuntimeOptions {
1519 ef_search: Some(ef_search.max(base_count)),
1520 ..SearchRuntimeOptions::default()
1521 };
1522 let points = ann
1523 .base
1524 .index
1525 .search_with_options(query_fde, base_count, &options)
1526 .map_err(|error| IndexError::Invalid(format!("HNSW search failed: {error}")))?;
1527 for point in points {
1528 if ann.tombstones.contains(&point.id) {
1529 continue;
1530 }
1531 let id = ann
1532 .base
1533 .ids
1534 .get(point.id as usize)
1535 .ok_or_else(|| IndexError::Invalid("invalid HNSW point id".into()))?;
1536 if !s.documents.contains_key(id) || ann.delta.contains(id) {
1537 return Err(IndexError::Invalid(
1538 "FDE ANN overlay is inconsistent; rebuild".into(),
1539 ));
1540 }
1541 scores.push((id.clone(), -point.sort_key));
1542 }
1543 }
1544 if !ann.delta.is_empty() {
1545 for id in &ann.delta {
1546 let record = s
1547 .documents
1548 .get(id)
1549 .ok_or_else(|| IndexError::Invalid("missing delta document".into()))?;
1550 let vector = if let Some(field) = &ann.base.field {
1551 self.dense_vector(s, id, field)?
1552 } else {
1553 FixedVectorStore::get(
1554 s.record_fde(record),
1555 record.fde_location,
1556 self.fde.output_dimension(),
1557 )?
1558 };
1559 scores.push((id.clone(), dot(query_fde, vector)));
1560 }
1561 }
1562 let by_score =
1563 |a: &(String, f32), b: &(String, f32)| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0));
1564 if scores.len() > count {
1565 scores.select_nth_unstable_by(count, by_score);
1566 scores.truncate(count);
1567 }
1568 scores.sort_unstable_by(by_score);
1569 Ok(scores)
1570 }
1571 pub fn ann_fde_candidates(
1573 &self,
1574 vectors: &[Vector],
1575 count: usize,
1576 ef_search: usize,
1577 ) -> Result<Vec<CandidateHit>, IndexError> {
1578 self.validate(vectors)?;
1579 if count == 0 || ef_search == 0 {
1580 return Err(IndexError::Invalid(
1581 "candidate count and ef_search must be positive".into(),
1582 ));
1583 }
1584 let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1585 let query_fde = self.fde.encode_query(&normalized);
1586 let s = self.snapshot();
1587 Ok(self
1588 .ann_fde_scores(&s, &query_fde, count, ef_search)?
1589 .into_iter()
1590 .map(|(id, score)| CandidateHit { id, score })
1591 .collect())
1592 }
1593 pub fn query_with_probes(
1594 &self,
1595 vectors: &[Vector],
1596 top_k: usize,
1597 candidates: Option<usize>,
1598 probes: usize,
1599 ) -> Result<Vec<Hit>, IndexError> {
1600 self.validate(vectors)?;
1601 if top_k == 0 {
1602 return Err(IndexError::Invalid("top_k must be positive".into()));
1603 }
1604 let normalized: Vec<_> = vectors.iter().map(|vector| normalize(vector)).collect();
1605 let s = self.snapshot();
1606 if s.codebook.is_empty() {
1607 return Err(IndexError::Invalid("index is untrained".into()));
1608 }
1609 let interaction: Vec<Vec<f32>> = normalized
1611 .iter()
1612 .map(|q| s.codebook.iter().map(|c| dot(q, c)).collect())
1613 .collect();
1614 let mut selected = HashSet::new();
1615 for row in &interaction {
1616 let mut scored: Vec<_> = row.iter().copied().enumerate().collect();
1617 scored.sort_by(|a, b| b.1.total_cmp(&a.1));
1618 for &(c, _) in scored.iter().take(probes.max(1)) {
1619 selected.insert(c);
1620 }
1621 }
1622 let mut candidate_ids = HashSet::new();
1623 for c in selected {
1624 candidate_ids.extend(s.postings[c].iter().cloned());
1625 }
1626 let mut approx: Vec<_> = candidate_ids
1627 .into_iter()
1628 .map(|id| {
1629 let d = &s.documents[&id];
1630 let score = interaction
1631 .iter()
1632 .map(|row| {
1633 d.unique_centroids
1634 .iter()
1635 .map(|&c| row[c as usize])
1636 .fold(f32::NEG_INFINITY, f32::max)
1637 })
1638 .sum::<f32>();
1639 (id, score)
1640 })
1641 .collect();
1642 approx.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1643 let mut hits = self.rescore(&s, &normalized, approx, top_k, candidates)?;
1644 for hit in &mut hits {
1645 hit.fde_score = None;
1646 }
1647 Ok(hits)
1648 }
1649 fn rescore(
1650 &self,
1651 s: &State,
1652 normalized: &[Vector],
1653 approximate: Vec<(String, f32)>,
1654 top_k: usize,
1655 candidates: Option<usize>,
1656 ) -> Result<Vec<Hit>, IndexError> {
1657 if approximate.is_empty() {
1658 return Ok(Vec::new());
1659 }
1660 let count = candidates.unwrap_or(top_k.saturating_mul(8)).max(top_k);
1661 use std::sync::OnceLock;
1665 static TIMING: OnceLock<bool> = OnceLock::new();
1666 let timing = *TIMING.get_or_init(|| std::env::var("MULTIVECTOR_TIMING").is_ok());
1667 let decode_ns = std::sync::atomic::AtomicU64::new(0);
1668 let maxsim_ns = std::sync::atomic::AtomicU64::new(0);
1669 thread_local! {
1676 static DECODE_SCRATCH: std::cell::RefCell<Vec<f32>> =
1677 const { std::cell::RefCell::new(Vec::new()) };
1678 }
1679 let approximate_slice: &[(String, f32)] = approximate.as_slice();
1686 let scored: Vec<(usize, f32)> = approximate_slice
1687 .par_iter()
1688 .take(count)
1689 .enumerate()
1690 .map(|(idx, (id, _))| -> Result<(usize, f32), io::Error> {
1691 let record = &s.documents[id];
1692 let t0 = if timing {
1693 Some(std::time::Instant::now())
1694 } else {
1695 None
1696 };
1697 let (score, t1) = DECODE_SCRATCH.with(
1698 |cell| -> Result<(f32, Option<std::time::Instant>), io::Error> {
1699 let mut scratch = cell.borrow_mut();
1700 let dim = CompressedVectorStore::decode_into(
1701 s.record_objects(record),
1702 record.location,
1703 &s.codebook,
1704 &s.residual_codebook,
1705 &mut scratch,
1706 )?;
1707 let t1 = if timing {
1708 Some(std::time::Instant::now())
1709 } else {
1710 None
1711 };
1712 Ok((maxsim_flat(normalized, &scratch, dim), t1))
1713 },
1714 )?;
1715 let t2 = if timing {
1716 Some(std::time::Instant::now())
1717 } else {
1718 None
1719 };
1720 if let (Some(t0), Some(t1), Some(t2)) = (t0, t1, t2) {
1721 decode_ns.fetch_add(
1722 t1.duration_since(t0).as_nanos() as u64,
1723 std::sync::atomic::Ordering::Relaxed,
1724 );
1725 maxsim_ns.fetch_add(
1726 t2.duration_since(t1).as_nanos() as u64,
1727 std::sync::atomic::Ordering::Relaxed,
1728 );
1729 }
1730 Ok((idx, score))
1731 })
1732 .collect::<Result<Vec<_>, io::Error>>()?;
1733 if timing {
1734 let d = decode_ns.load(std::sync::atomic::Ordering::Relaxed);
1735 let m = maxsim_ns.load(std::sync::atomic::Ordering::Relaxed);
1736 eprintln!(
1737 "[timing] candidates={count} decode_us={} maxsim_us={} (per-thread aggregate)",
1738 d / 1000,
1739 m / 1000
1740 );
1741 }
1742 let mut scored = scored;
1747 let n = scored.len();
1748 let by_score_desc = |a: &(usize, f32), b: &(usize, f32)| {
1749 b.1.total_cmp(&a.1)
1750 .then_with(|| approximate_slice[a.0].0.cmp(&approximate_slice[b.0].0))
1751 };
1752 if top_k < n {
1753 scored.select_nth_unstable_by(top_k, by_score_desc);
1754 scored.truncate(top_k);
1755 }
1756 scored.sort_unstable_by(by_score_desc);
1757 let hits: Vec<Hit> = scored
1758 .into_iter()
1759 .map(|(idx, score)| {
1760 let (id, fde) = &approximate_slice[idx];
1761 let record = &s.documents[id];
1762 Hit {
1763 id: id.clone(),
1764 score,
1765 fde_score: Some(*fde),
1766 metadata: record.metadata.clone(),
1767 }
1768 })
1769 .collect();
1770 Ok(hits)
1771 }
1772 fn new_segment(&self, generation: u64) -> Result<Arc<SegmentStores>, IndexError> {
1773 let mut id = generation
1774 .checked_add(1)
1775 .ok_or_else(|| IndexError::Invalid("generation exhausted".into()))?;
1776 let parent = self.root.join("segments");
1777 fs::create_dir_all(&parent)?;
1778 let root = loop {
1779 let path = parent.join(id.to_string());
1780 match fs::create_dir(&path) {
1781 Ok(()) => break path,
1782 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
1783 id = id
1784 .checked_add(1)
1785 .ok_or_else(|| IndexError::Invalid("segment IDs exhausted".into()))?;
1786 }
1787 Err(e) => return Err(e.into()),
1788 }
1789 };
1790 let stores = Arc::new(SegmentStores {
1791 objects: CompressedVectorStore::new(root.join("objects"))?,
1792 fde: FixedVectorStore::new(root.join("fde"))?,
1793 root,
1794 id: Some(id),
1795 retired: std::sync::atomic::AtomicBool::new(true),
1796 });
1797 Ok(stores)
1798 }
1799 fn rotate_segment(&self, next: &mut State) -> Result<(), IndexError> {
1800 let stores = self.new_segment(next.generation)?;
1801 let bounds = SegmentBoundaries {
1804 objects: next.object_bytes().len() as u64,
1805 fde: next.fde_bytes().len() as u64,
1806 };
1807 next.sealed.insert(
1808 next.stores.id.unwrap_or(0),
1809 Arc::new(SegmentSnapshot {
1810 stores: Arc::clone(&next.stores),
1811 objects: next.objects_map.take(),
1812 fde: next.fde_map.take(),
1813 bounds,
1814 }),
1815 );
1816 if self.durability == Durability::Fsync {
1817 for path in [
1818 stores.root.join("objects"),
1819 stores.root.join("fde"),
1820 stores.root.clone(),
1821 self.root.join("segments"),
1822 ] {
1823 File::open(path)?.sync_all()?;
1824 }
1825 File::open(&self.root)?.sync_all()?;
1826 }
1827 next.stores = stores;
1828 Ok(())
1829 }
1830 pub fn seal(&self) -> Result<(), IndexError> {
1831 let _writer = self.writer.lock().unwrap();
1832 let s = self.snapshot();
1833 let mut next = (*s).clone();
1834 self.rotate_segment(&mut next)?;
1835 self.commit(&s, next)
1836 }
1837 pub fn compact(&self) -> Result<serde_json::Value, IndexError> {
1840 let s = self.snapshot();
1841 let before = s.stores.objects.len()?
1842 + s.stores.fde.len()?
1843 + s.sealed
1844 .values()
1845 .map(|v| Ok::<_, io::Error>(v.stores.objects.len()? + v.stores.fde.len()?))
1846 .collect::<Result<Vec<_>, _>>()?
1847 .iter()
1848 .sum::<u64>();
1849 let stores = self.new_segment(s.generation)?;
1850 let mut next = (*s).clone();
1851 next.stores = Arc::clone(&stores);
1852 next.objects_map = None;
1853 next.fde_map = None;
1854 next.sealed.clear();
1855 for document in next.documents.values_mut() {
1856 if document.tokens > 0 {
1857 document.location = stores
1858 .objects
1859 .copy_record(s.record_objects(document), document.location)?;
1860 document.fde_location = stores
1861 .fde
1862 .copy_record(s.record_fde(document), document.fde_location)?;
1863 }
1864 let source = s.record_fde(document);
1865 Arc::make_mut(&mut document.fields).relocate(source, &stores.fde)?;
1866 document.storage_id = stores.id.unwrap();
1867 }
1868 if self.durability == Durability::Fsync {
1869 for path in [
1870 stores.root.join("objects"),
1871 stores.root.join("fde"),
1872 stores.root.clone(),
1873 self.root.join("segments"),
1874 ] {
1875 File::open(path)?.sync_all()?;
1876 }
1877 File::open(&self.root)?.sync_all()?;
1878 }
1879 commit_boundary("compaction_copied")?;
1880 let _writer = self.writer.lock().unwrap();
1881 if self.snapshot().generation != s.generation {
1882 return Err(IndexError::Invalid(
1883 "generation changed during compaction; retry".into(),
1884 ));
1885 }
1886 let result = self.commit(&s, next);
1887 if result.is_ok() || matches!(result, Err(IndexError::CommitUncertain(_))) {
1888 stores
1889 .retired
1890 .store(false, std::sync::atomic::Ordering::Relaxed);
1891 }
1892 if result.is_ok() {
1893 s.stores
1894 .retired
1895 .store(true, std::sync::atomic::Ordering::Relaxed);
1896 for old in s.sealed.values() {
1897 old.stores
1898 .retired
1899 .store(true, std::sync::atomic::Ordering::Relaxed);
1900 }
1901 }
1902 result?;
1903 let after = stores.objects.len()? + stores.fde.len()?;
1904 Ok(
1905 serde_json::json!({"generation":s.generation+1,"bytes_before":before,"bytes_after":after,"bytes_reclaimed":before.saturating_sub(after)}),
1906 )
1907 }
1908
1909 pub fn stats(&self) -> IndexStats {
1910 let s = self.snapshot();
1911 IndexStats {
1912 documents: s.documents.len(),
1913 generation: s.generation,
1914 token_vectors: s.documents.values().map(|d| d.tokens).sum(),
1915 compressed_bytes: s.documents.values().map(|d| d.compressed_bytes).sum(),
1916 centroids: s.codebook.len(),
1917 residual_bits: self.config.residual_bits,
1918 trained: !s.codebook.is_empty(),
1919 fde_dimension: self.fde.output_dimension(),
1920 fde_ann_nodes: s.fde_ann.as_ref().map_or(0, |ann| {
1921 ann.base.ids.len() - ann.tombstones.len() + ann.delta.len()
1922 }),
1923 fde_ann_base_nodes: s.fde_ann.as_ref().map_or(0, |ann| ann.base.ids.len()),
1924 fde_ann_delta_documents: s.fde_ann.as_ref().map_or(0, |ann| ann.delta.len()),
1925 fde_ann_tombstones: s.fde_ann.as_ref().map_or(0, |ann| ann.tombstones.len()),
1926 fde_encoding_version: self.fde.encoding_version(),
1927 storage_segments: s.sealed.len() + 1,
1928 dense_ann_fields: s
1929 .named_ann
1930 .iter()
1931 .map(|(field, ann)| {
1932 (
1933 field.clone(),
1934 ann.base.ids.len() - ann.tombstones.len() + ann.delta.len(),
1935 )
1936 })
1937 .collect(),
1938 }
1939 }
1940 pub fn score_uncompressed(
1942 &self,
1943 query: &[Vector],
1944 document: &[Vector],
1945 ) -> Result<f32, IndexError> {
1946 self.validate(query)?;
1947 self.validate(document)?;
1948 let query: Vec<_> = query.iter().map(|v| normalize(v)).collect();
1949 let document: Vec<_> = document.iter().map(|v| normalize(v)).collect();
1950 let flat: Vec<_> = document.into_iter().flatten().collect();
1951 Ok(maxsim_flat(&query, &flat, self.config.dimension))
1952 }
1953 pub fn score_compressed(&self, query: &[Vector], id: &str) -> Result<f32, IndexError> {
1955 self.validate(query)?;
1956 let query: Vec<_> = query.iter().map(|v| normalize(v)).collect();
1957 let s = self.snapshot();
1958 let record = s
1959 .documents
1960 .get(id)
1961 .ok_or_else(|| IndexError::Invalid(format!("unknown document: {id}")))?;
1962 let document = CompressedVectorStore::decode(
1963 s.record_objects(record),
1964 record.location,
1965 &s.codebook,
1966 &s.residual_codebook,
1967 )?;
1968 Ok(maxsim_flat(&query, &document.values, document.dimension))
1969 }
1970}
1971
1972fn validate_config_size(c: &IndexConfig) -> Result<(), IndexError> {
1973 const MAX_VALUES: usize = 16 * 1024 * 1024;
1975 let bounded = |n: Option<usize>| n.is_some_and(|n| n <= MAX_VALUES);
1976 let buckets = 1usize << c.fde_ksim;
1977 if c.centroids > u32::MAX as usize
1978 || !bounded(c.dimension.checked_mul(c.centroids))
1979 || !bounded(
1980 buckets
1981 .checked_mul(c.fde_projected)
1982 .and_then(|n| n.checked_mul(c.fde_repetitions)),
1983 )
1984 || !bounded(
1985 c.fde_projected
1986 .checked_add(c.fde_ksim)
1987 .and_then(|n| n.checked_mul(c.dimension))
1988 .and_then(|n| n.checked_mul(c.fde_repetitions)),
1989 )
1990 || !bounded(buckets.checked_mul(c.dimension))
1991 {
1992 return Err(IndexError::Invalid(
1993 "configuration exceeds checked 64 MiB workspace limit".into(),
1994 ));
1995 }
1996 Ok(())
1997}
1998
1999fn validate_codebooks(
2000 c: &IndexConfig,
2001 centers: &[Vector],
2002 residuals: &[f32],
2003 has_docs: bool,
2004) -> Result<(), IndexError> {
2005 if centers.is_empty() && residuals.is_empty() && !has_docs {
2006 return Ok(());
2007 }
2008 if centers.len() != c.centroids
2009 || residuals.len() != 1usize << c.residual_bits
2010 || centers
2011 .iter()
2012 .any(|v| v.len() != c.dimension || v.iter().any(|x| !x.is_finite()))
2013 || residuals.iter().any(|x| !x.is_finite())
2014 {
2015 return Err(IndexError::Invalid("invalid persisted codebook".into()));
2016 }
2017 Ok(())
2018}
2019
2020fn check_pruning_shape(
2021 top_k: usize,
2022 candidates: usize,
2023 rerank_candidates: usize,
2024) -> Result<(), IndexError> {
2025 if top_k == 0 || rerank_candidates < top_k || candidates < rerank_candidates {
2026 return Err(IndexError::Invalid(
2027 "require top_k > 0 and candidates >= rerank_candidates >= top_k".into(),
2028 ));
2029 }
2030 Ok(())
2031}
2032fn centroid_prune(
2033 s: &State,
2034 query: &[Vector],
2035 candidates: Vec<(String, f32)>,
2036 survivors: usize,
2037) -> Vec<(String, f32)> {
2038 let interaction: Vec<Vec<f32>> = query
2039 .iter()
2040 .map(|q| s.codebook.iter().map(|centroid| dot(q, centroid)).collect())
2041 .collect();
2042 let mut scored: Vec<_> = candidates
2043 .into_par_iter()
2044 .map(|(id, fde_score)| {
2045 let document = &s.documents[&id];
2046 let score = interaction
2047 .iter()
2048 .map(|row| {
2049 document
2050 .unique_centroids
2051 .iter()
2052 .map(|¢roid| row[centroid as usize])
2053 .fold(f32::NEG_INFINITY, f32::max)
2054 })
2055 .sum::<f32>();
2056 (id, fde_score, score)
2057 })
2058 .collect();
2059 scored.par_sort_unstable_by(|a, b| b.2.total_cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
2060 scored.truncate(survivors);
2061 scored
2062 .into_iter()
2063 .map(|(id, fde_score, _)| (id, fde_score))
2064 .collect()
2065}
2066fn nearest(vector: &Vector, centroids: &[Vector]) -> usize {
2067 centroids
2068 .iter()
2069 .enumerate()
2070 .min_by(|(_, a), (_, b)| distance(vector, a).total_cmp(&distance(vector, b)))
2071 .unwrap()
2072 .0
2073}
2074fn distance(a: &[f32], b: &[f32]) -> f32 {
2075 a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
2076}
2077fn train_scalar_codebook(values: &[f32], levels: usize, iterations: usize) -> Vec<f32> {
2078 let mut sorted = values.to_vec();
2079 sorted.sort_unstable_by(|a, b| a.total_cmp(b));
2080 let mut centers: Vec<_> = (0..levels)
2081 .map(|i| sorted[((2 * i + 1) * sorted.len() / (2 * levels)).min(sorted.len() - 1)])
2082 .collect();
2083 for _ in 0..iterations {
2084 let mut sums = vec![0.; levels];
2085 let mut counts = vec![0usize; levels];
2086 for &value in values {
2087 let index = centers
2088 .iter()
2089 .enumerate()
2090 .min_by(|(_, a), (_, b)| (value - **a).abs().total_cmp(&(value - **b).abs()))
2091 .unwrap()
2092 .0;
2093 sums[index] += value;
2094 counts[index] += 1;
2095 }
2096 for i in 0..levels {
2097 if counts[i] > 0 {
2098 centers[i] = sums[i] / counts[i] as f32;
2099 }
2100 }
2101 }
2102 centers.sort_unstable_by(|a, b| a.total_cmp(b));
2103 centers
2104}
2105
2106#[cfg(test)]
2107#[path = "transaction_tests.rs"]
2108mod transaction_tests;