#![cfg(feature = "pq-fastscan")]
use laurus::storage::file::FileStorageConfig;
use laurus::storage::{StorageConfig, StorageFactory};
use laurus::vector::HnswOption;
use laurus::vector::Vector;
use laurus::vector::core::distance::DistanceMetric;
use laurus::vector::core::quantization::QuantizationMethod;
use laurus::vector::index::pq_codebook::read_pq_codebook;
use laurus::{DataValue, Document, Engine, FieldOption, Schema};
use tempfile::TempDir;
const DIM: usize = 32;
const M: usize = 4;
fn make_vectors(count: usize, seed: u64) -> Vec<Vector> {
let mut state = seed;
(0..count)
.map(|_| {
let data: Vec<f32> = (0..DIM)
.map(|_| {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((state >> 33) as f32 / u32::MAX as f32) * 2.0 - 1.0
})
.collect();
Vector::new(data)
})
.collect()
}
fn fastscan_schema(pq_codebook_path: Option<&str>) -> Schema {
Schema::builder()
.add_field(
"embedding",
FieldOption::Hnsw(HnswOption {
dimension: DIM,
distance: DistanceMetric::Euclidean,
m: 8,
ef_construction: 32,
quantizer: QuantizationMethod::ProductQuantizationFastScan { subvector_count: M },
pq_codebook_path: pq_codebook_path.map(str::to_string),
..HnswOption::default()
}),
)
.build()
}
#[tokio::test(flavor = "multi_thread")]
async fn fastscan_train_then_reopen_commits_with_the_shared_codebook() -> laurus::Result<()> {
let dir = TempDir::new().unwrap();
let storage = StorageFactory::create(StorageConfig::File(FileStorageConfig::new(dir.path())))?;
{
let engine = Engine::new(storage.clone(), fastscan_schema(Some("embedding.pqcb"))).await?;
let doc = Document::builder()
.add_field(
"embedding",
DataValue::Vector(make_vectors(1, 7)[0].data.to_vec()),
)
.build();
engine.put_document("doc-early", doc).await?;
let err = engine.commit().await.unwrap_err();
assert!(
err.to_string().contains("train"),
"an untrained configured codebook must point at the training step: {err}"
);
}
let fresh_dir = TempDir::new().unwrap();
let fresh_storage = StorageFactory::create(StorageConfig::File(FileStorageConfig::new(
fresh_dir.path(),
)))?;
let trainer = Engine::new(
fresh_storage.clone(),
fastscan_schema(Some("embedding.pqcb")),
)
.await?;
let info = trainer.train_pq_codebook("embedding", &make_vectors(400, 0x0F0F), None)?;
assert_eq!(info.path, "embedding.pqcb");
assert_eq!(info.subvector_count, M);
assert_eq!(
info.centroids, 16,
"a FastScan field must train a k=16 codebook, not the standard 256"
);
assert_eq!(info.sub_dimension, DIM / M);
assert!(fresh_storage.file_exists("vector/embedding.pqcb"));
drop(trainer);
let vector_ns =
laurus::storage::prefixed::PrefixedStorage::new("vector", fresh_storage.clone());
let loaded = read_pq_codebook(&vector_ns, "embedding.pqcb")?;
assert_eq!(loaded.params.k, 16);
let engine = Engine::new(
fresh_storage.clone(),
fastscan_schema(Some("embedding.pqcb")),
)
.await?;
for (i, v) in make_vectors(5, 0xBEEF).into_iter().enumerate() {
let doc = Document::builder()
.add_field("embedding", DataValue::Vector(v.data.to_vec()))
.build();
engine.put_document(&format!("d{i}"), doc).await?;
}
engine.commit().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn k256_codebook_on_fastscan_field_fails_the_commit() -> laurus::Result<()> {
let dir = TempDir::new().unwrap();
let storage = StorageFactory::create(StorageConfig::File(FileStorageConfig::new(dir.path())))?;
let pq_schema = Schema::builder()
.add_field(
"embedding",
FieldOption::Hnsw(HnswOption {
dimension: DIM,
distance: DistanceMetric::Euclidean,
m: 8,
ef_construction: 32,
quantizer: QuantizationMethod::ProductQuantization { subvector_count: M },
pq_codebook_path: Some("embedding.pqcb".to_string()),
..HnswOption::default()
}),
)
.build();
let trainer = Engine::new(storage.clone(), pq_schema).await?;
let info = trainer.train_pq_codebook("embedding", &make_vectors(400, 0x0F0F), None)?;
assert_eq!(info.centroids, 256);
drop(trainer);
let engine = Engine::new(storage.clone(), fastscan_schema(Some("embedding.pqcb"))).await?;
for (i, v) in make_vectors(20, 0xBEEF).into_iter().enumerate() {
let doc = Document::builder()
.add_field("embedding", DataValue::Vector(v.data.to_vec()))
.build();
engine.put_document(&format!("d{i}"), doc).await?;
}
let err = engine.commit().await.unwrap_err();
let message = err.to_string();
assert!(
message.contains("k = 256") && message.contains("k = 16"),
"the k mismatch must be named: {message}"
);
Ok(())
}