use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use frankensearch_core::generation::{
ArtifactGenerationIdentityV1, EmbeddingIdentityBundleV1, QuantizationFormat,
};
use frankensearch_core::traits::IdentityBoundEmbedding;
use frankensearch_index::native_hnsw::{HnswParams, NativeHnswGenerationReceiptV2};
use frankensearch_index::{
FsviV2IdentityBinding, ValidatedFsviBytes, VectorIndex, VectorIndexWriter,
};
use super::{NativeAnnIndex, checkpoint, invalid};
use crate::{Cx, Embedder, IndexableDocument, SearchResult, VectorHit};
mod snapshot;
pub use snapshot::NativeReopenLimits;
#[cfg(feature = "quill")]
mod hybrid;
#[cfg(feature = "quill")]
pub use hybrid::{NativeBuiltHybridIndex, NativeHybridReopenLimits};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NativeBuildPrecision {
#[default]
F32,
F16,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum NativeBuildRetrieval {
#[default]
Exact,
Hnsw {
params: HnswParams,
seed: u64,
},
}
struct TierPlan {
embedder: Arc<dyn Embedder>,
identity: EmbeddingIdentityBundleV1,
precision: NativeBuildPrecision,
retrieval: NativeBuildRetrieval,
}
impl TierPlan {
fn new(embedder: Arc<dyn Embedder>) -> SearchResult<Self> {
let identity = embedder.identity()?.clone();
identity.validate()?;
if usize::try_from(identity.space.dimension).ok() != Some(embedder.dimension()) {
return Err(invalid(
"builder.dimension",
"mismatch",
"provider dimension disagrees with its identity",
));
}
Ok(Self {
embedder,
identity,
precision: NativeBuildPrecision::F32,
retrieval: NativeBuildRetrieval::Exact,
})
}
fn binding(
&self,
generation: &ArtifactGenerationIdentityV1,
) -> SearchResult<FsviV2IdentityBinding> {
self.admit(self.embedder.identity()?)?;
let mut identity = self.identity.clone();
"fsvi-v2".clone_into(&mut identity.storage.format);
"little-endian".clone_into(&mut identity.storage.endianness);
identity.storage.quantization = match self.precision {
NativeBuildPrecision::F32 => QuantizationFormat::F32,
NativeBuildPrecision::F16 => QuantizationFormat::F16,
};
FsviV2IdentityBinding::new(*generation, identity.freeze()?)
.map_err(|error| invalid("builder.binding", "rejected", &error.to_string()))
}
fn admit(&self, identity: &EmbeddingIdentityBundleV1) -> SearchResult<()> {
identity.validate()?;
if identity.fingerprint() != self.identity.fingerprint() {
return Err(invalid(
"builder.producer",
"changed",
"batch provider changed its frozen identity",
));
}
Ok(())
}
async fn write_batch(
&self,
cx: &Cx,
writer: &mut VectorIndexWriter,
documents: &[IndexableDocument],
) -> SearchResult<()> {
checkpoint(cx, "native_ann.builder.before_batch")?;
self.admit(self.embedder.identity()?)?;
let texts: Vec<_> = documents.iter().map(|doc| doc.content.as_str()).collect();
let response = self.embedder.embed_batch_bound(cx, &texts).await;
checkpoint(cx, "native_ann.builder.after_batch")?;
let response = response?;
if response.len() != documents.len() {
return Err(invalid(
"builder.batch",
"cardinality",
"one bound embedding is required per input document",
));
}
for bound in &response {
checkpoint(cx, "native_ann.builder.admit_output")?;
self.admit_output(bound)?;
}
for (document, bound) in documents.iter().zip(response) {
checkpoint(cx, "native_ann.builder.write_row")?;
writer.write_record(document.id.as_str(), &bound.values)?;
}
Ok(())
}
fn admit_output(&self, bound: &IdentityBoundEmbedding) -> SearchResult<()> {
bound.validate()?;
self.admit(&bound.identity)?;
if bound.values.len() != self.embedder.dimension()
|| bound.values.iter().any(|v| !v.is_finite())
{
return Err(invalid(
"builder.output",
"invalid",
"embedding dimensions and finite values must match the frozen producer",
));
}
Ok(())
}
}
pub struct NativeIndexBuilder {
directory: PathBuf,
generation: ArtifactGenerationIdentityV1,
fast: TierPlan,
quality: Option<TierPlan>,
batch_size: usize,
documents: Vec<IndexableDocument>,
}
impl NativeIndexBuilder {
pub fn new(
directory: impl AsRef<Path>,
generation: ArtifactGenerationIdentityV1,
fast: Arc<dyn Embedder>,
) -> SearchResult<Self> {
let directory = directory.as_ref();
let directory = if directory.is_absolute() {
directory.to_path_buf()
} else {
std::env::current_dir()?.join(directory)
};
let fast = TierPlan::new(fast)?;
fast.binding(&generation)?;
Ok(Self {
directory,
generation,
fast,
quality: None,
batch_size: 64,
documents: Vec::new(),
})
}
pub fn with_quality_embedder(mut self, embedder: Arc<dyn Embedder>) -> SearchResult<Self> {
let quality = TierPlan::new(embedder)?;
if self.fast.identity.input.doc_id_semantics != quality.identity.input.doc_id_semantics
|| self.fast.identity.space.kind != quality.identity.space.kind
{
return Err(invalid(
"builder.quality",
"incompatible",
"both tiers require one document-ID contract and semantic/control kind",
));
}
self.quality = Some(quality);
Ok(self)
}
pub fn with_batch_size(mut self, batch_size: usize) -> SearchResult<Self> {
if batch_size == 0 {
return Err(invalid(
"builder.batch_size",
"0",
"batch size must be positive",
));
}
self.batch_size = batch_size;
Ok(self)
}
#[must_use]
pub fn with_fast_storage(
mut self,
precision: NativeBuildPrecision,
retrieval: NativeBuildRetrieval,
) -> Self {
self.fast.precision = precision;
self.fast.retrieval = retrieval;
self
}
pub fn with_quality_storage(
mut self,
precision: NativeBuildPrecision,
retrieval: NativeBuildRetrieval,
) -> SearchResult<Self> {
let quality = self.quality.as_mut().ok_or_else(|| {
invalid(
"builder.quality",
"absent",
"configure the quality provider first",
)
})?;
quality.precision = precision;
quality.retrieval = retrieval;
Ok(self)
}
#[must_use]
pub fn add_document(mut self, document: IndexableDocument) -> Self {
self.documents.push(document);
self
}
#[must_use]
pub fn add_documents(mut self, documents: impl IntoIterator<Item = IndexableDocument>) -> Self {
self.documents.extend(documents);
self
}
pub async fn build(mut self, cx: &Cx) -> SearchResult<NativeBuiltIndex> {
checkpoint(cx, "native_ann.builder.start")?;
self.documents.sort_by(|a, b| a.id.cmp(&b.id));
for (position, doc) in self.documents.iter().enumerate() {
checkpoint(cx, "native_ann.builder.source")?;
if doc.id.is_empty() || (position > 0 && self.documents[position - 1].id == doc.id) {
return Err(invalid(
"builder.documents",
"empty-or-duplicate-id",
"source document IDs must be nonempty and unique",
));
}
}
let fast_binding = self.fast.binding(&self.generation)?;
let quality_binding = self
.quality
.as_ref()
.map(|tier| tier.binding(&self.generation))
.transpose()?;
for tier in std::iter::once(&self.fast).chain(self.quality.iter()) {
if let NativeBuildRetrieval::Hnsw { params, .. } = tier.retrieval {
params.validate()?;
}
}
checkpoint(cx, "native_ann.builder.create")?;
std::fs::create_dir(&self.directory)?;
let directory = std::fs::canonicalize(&self.directory)?;
let fast_path = directory.join("fast.fsvi");
let quality_path = directory.join("quality.fsvi");
let mut fast_writer = VectorIndex::create_v2(&fast_path, fast_binding.clone())?;
let mut quality_writer = quality_binding
.as_ref()
.map(|binding| VectorIndex::create_v2(&quality_path, binding.clone()))
.transpose()?;
for batch in self.documents.chunks(self.batch_size) {
self.fast.write_batch(cx, &mut fast_writer, batch).await?;
if let (Some(tier), Some(writer)) = (&self.quality, &mut quality_writer) {
tier.write_batch(cx, writer, batch).await?;
}
}
checkpoint(cx, "native_ann.builder.finish_vectors")?;
fast_writer.finish()?;
if let Some(writer) = quality_writer {
writer.finish()?;
}
let fast = finish_tier(cx, self.fast, fast_binding, fast_path, &self.documents)?;
let quality = match (self.quality, quality_binding) {
(Some(tier), Some(binding)) => Some(finish_tier(
cx,
tier,
binding,
quality_path,
&self.documents,
)?),
(None, None) => None,
_ => {
return Err(invalid(
"builder.quality",
"inconsistent",
"quality plan and binding must travel together",
));
}
};
checkpoint(cx, "native_ann.builder.complete")?;
Ok(NativeBuiltIndex {
directory,
documents: self.documents.into(),
fast,
quality,
})
}
}
pub struct NativeBuiltTier {
index: NativeAnnIndex,
embedder: Arc<dyn Embedder>,
producer_identity: EmbeddingIdentityBundleV1,
precision: NativeBuildPrecision,
binding: FsviV2IdentityBinding,
vector_path: PathBuf,
graph_path: Option<PathBuf>,
graph_receipt: Option<NativeHnswGenerationReceiptV2>,
}
impl NativeBuiltTier {
#[must_use]
pub const fn index(&self) -> &NativeAnnIndex {
&self.index
}
#[must_use]
pub fn embedder(&self) -> &dyn Embedder {
self.embedder.as_ref()
}
#[must_use]
pub const fn binding(&self) -> &FsviV2IdentityBinding {
&self.binding
}
#[must_use]
pub fn vector_path(&self) -> &Path {
&self.vector_path
}
#[must_use]
pub fn graph_path(&self) -> Option<&Path> {
self.graph_path.as_deref()
}
pub async fn search(&self, cx: &Cx, text: &str, k: usize) -> SearchResult<Vec<VectorHit>> {
self.index
.search_text(cx, self.embedder(), text, k, None)
.await
}
}
pub struct NativeBuiltIndex {
directory: PathBuf,
documents: Arc<[IndexableDocument]>,
fast: NativeBuiltTier,
quality: Option<NativeBuiltTier>,
}
impl NativeBuiltIndex {
#[must_use]
pub fn directory(&self) -> &Path {
&self.directory
}
#[must_use]
pub const fn fast(&self) -> &NativeBuiltTier {
&self.fast
}
#[must_use]
pub const fn quality(&self) -> Option<&NativeBuiltTier> {
self.quality.as_ref()
}
#[must_use]
pub fn documents(&self) -> &[IndexableDocument] {
&self.documents
}
#[must_use]
pub fn document(&self, id: &str) -> Option<&IndexableDocument> {
self.documents
.binary_search_by(|doc| doc.id.as_str().cmp(id))
.ok()
.map(|position| &self.documents[position])
}
}
fn finish_tier(
cx: &Cx,
plan: TierPlan,
binding: FsviV2IdentityBinding,
vector_path: PathBuf,
documents: &[IndexableDocument],
) -> SearchResult<NativeBuiltTier> {
checkpoint(cx, "native_ann.builder.admit_vectors")?;
let bytes: Arc<[u8]> = std::fs::read(&vector_path)?.into();
let owner = Arc::new(
ValidatedFsviBytes::from_arc(bytes, &binding)
.map_err(|error| invalid("builder.vector_admission", "rejected", &error.to_string()))?,
);
validate_source_membership(cx, &owner, documents)?;
let (index, graph_path, graph_receipt) = match plan.retrieval {
NativeBuildRetrieval::Exact => (NativeAnnIndex::exact(cx, owner)?, None, None),
NativeBuildRetrieval::Hnsw { params, seed } => {
let index = NativeAnnIndex::build(cx, owner, params, seed)?;
let graph_path = vector_path.with_extension("fshnsw");
let receipt = index.save(cx, &graph_path)?;
(index, Some(graph_path), Some(receipt))
}
};
index.admit_identity(plan.embedder.identity()?)?;
Ok(NativeBuiltTier {
index,
embedder: plan.embedder,
producer_identity: plan.identity,
precision: plan.precision,
binding,
vector_path,
graph_path,
graph_receipt,
})
}
fn validate_source_membership(
cx: &Cx,
owner: &ValidatedFsviBytes,
documents: &[IndexableDocument],
) -> SearchResult<()> {
if owner.record_count() != documents.len() || owner.live_count() != documents.len() {
return Err(invalid(
"builder.source_join",
"cardinality",
"admitted vector membership must equal the complete source cohort",
));
}
let mut seen = vec![false; documents.len()];
for physical in 0..owner.record_count() {
checkpoint(cx, "native_ann.builder.source_join")?;
let row = owner.row(physical)?;
let position = documents
.binary_search_by(|doc| doc.id.as_str().cmp(row.doc_id()))
.map_err(|_| {
invalid(
"builder.source_join",
"document-id",
"admitted vector row is absent from the source cohort",
)
})?;
if !row.flags().is_live() || std::mem::replace(&mut seen[position], true) {
return Err(invalid(
"builder.source_join",
"duplicate-or-deleted",
"every source document must map to exactly one live vector row",
));
}
}
Ok(())
}
#[cfg(test)]
mod tests;