use crate::error::{Error, Result};
use crate::index::meta::DocMeta;
use crate::index::sparse::{Bm25, DEFAULT_B, DEFAULT_K1};
use crate::index::{dense, serialize_sections, Index};
use crate::metric::Metric;
use crate::resource::{DocId, ResourceKind};
use crate::url_key::UrlKey;
use crate::url_key::UrlKeyExt as _;
use compact_str::CompactString;
use smallvec::SmallVec;
use std::collections::HashMap;
use std::path::Path;
use url::Url;
#[derive(Clone, Debug)]
pub struct Document {
pub url: Url,
pub kind: ResourceKind,
pub content_hash: u64,
pub title: Option<CompactString>,
pub snippet: CompactString,
pub lang: Option<CompactString>,
pub tags: SmallVec<[CompactString; 4]>,
pub terms: Vec<CompactString>,
pub vector: Vec<f32>,
pub edges: Vec<UrlKey>,
pub fetched_at_ms: u64,
pub etag: Option<CompactString>,
pub pinned: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpsertOutcome {
Added,
Updated,
Unchanged,
}
#[derive(Debug)]
pub struct IndexBuilder {
dim: usize,
metric: Metric,
embedder_id: u64,
k1: f32,
b: f32,
meta: Vec<DocMeta>,
dense: Vec<f32>,
doc_terms: Vec<Vec<CompactString>>,
edges: Vec<Vec<UrlKey>>,
url_index: HashMap<UrlKey, DocId>,
}
impl IndexBuilder {
#[must_use]
pub fn new(dim: usize, metric: Metric, embedder_id: u64) -> Self {
Self {
dim,
metric,
embedder_id,
k1: DEFAULT_K1,
b: DEFAULT_B,
meta: Vec::new(),
dense: Vec::new(),
doc_terms: Vec::new(),
edges: Vec::new(),
url_index: HashMap::new(),
}
}
#[must_use]
pub fn with_bm25(mut self, k1: f32, b: f32) -> Self {
self.k1 = k1;
self.b = b;
self
}
#[must_use]
#[allow(clippy::too_many_arguments)] pub(crate) fn from_parts(
dim: usize,
metric: Metric,
embedder_id: u64,
k1: f32,
b: f32,
meta: Vec<DocMeta>,
dense: Vec<f32>,
doc_terms: Vec<Vec<CompactString>>,
edges: Vec<Vec<UrlKey>>,
url_index: HashMap<UrlKey, DocId>,
) -> Self {
Self {
dim,
metric,
embedder_id,
k1,
b,
meta,
dense,
doc_terms,
edges,
url_index,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.meta.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.meta.is_empty()
}
#[must_use]
pub fn dim(&self) -> usize {
self.dim
}
#[must_use]
pub fn embedder_id(&self) -> u64 {
self.embedder_id
}
#[must_use]
pub fn is_unchanged(&self, url: &url::Url, content_hash: u64) -> bool {
let key = UrlKey::from_url(url);
self.url_index
.get(&key)
.is_some_and(|&id| self.meta[id as usize].content_hash == content_hash)
}
#[must_use]
pub fn to_index(&self) -> Index {
let bm25 = crate::index::sparse::Bm25::build(&self.doc_terms, self.k1, self.b);
Index::from_owned(
self.dim,
self.metric,
self.embedder_id,
bm25,
self.meta.clone(),
self.dense.clone(),
self.edges.clone(),
self.url_index.clone(),
)
}
pub fn upsert(&mut self, doc: Document) -> Result<UpsertOutcome> {
if doc.vector.len() != self.dim {
return Err(Error::DimMismatch {
index: self.dim,
query: doc.vector.len(),
});
}
let url_key = UrlKey::from_url(&doc.url);
let doc_len = doc.terms.len() as u32;
let mut vector = doc.vector;
if self.metric.normalizes() {
dense::l2_normalize(&mut vector);
}
let mut record = DocMeta {
url: crate::url_key::canonicalize(&doc.url),
url_key,
kind: doc.kind,
content_hash: doc.content_hash,
doc_len,
title: doc.title,
snippet: doc.snippet,
lang: doc.lang,
tags: doc.tags,
fetched_at_ms: doc.fetched_at_ms,
pinned: doc.pinned,
etag: doc.etag,
};
if let Some(&id) = self.url_index.get(&url_key) {
let id_usize = id as usize;
if self.meta[id_usize].content_hash == doc.content_hash {
return Ok(UpsertOutcome::Unchanged);
}
record.pinned = record.pinned || self.meta[id_usize].pinned;
self.meta[id_usize] = record;
let start = id_usize * self.dim;
self.dense[start..start + self.dim].copy_from_slice(&vector);
self.doc_terms[id_usize] = doc.terms;
self.edges[id_usize] = doc.edges;
Ok(UpsertOutcome::Updated)
} else {
let id = self.meta.len() as DocId;
self.url_index.insert(url_key, id);
self.meta.push(record);
self.dense.extend_from_slice(&vector);
self.doc_terms.push(doc.terms);
self.edges.push(doc.edges);
Ok(UpsertOutcome::Added)
}
}
pub fn remove(&mut self, url: &Url) -> bool {
let key = UrlKey::from_url(url);
let Some(removed_id) = self.url_index.remove(&key) else {
return false;
};
let id = removed_id as usize;
let last = self.meta.len() - 1;
self.meta.swap_remove(id);
self.doc_terms.swap_remove(id);
self.edges.swap_remove(id);
let dim = self.dim;
if id != last {
let (head, tail) = self.dense.split_at_mut(last * dim);
head[id * dim..id * dim + dim].copy_from_slice(&tail[..dim]);
let moved_key = self.meta[id].url_key;
self.url_index.insert(moved_key, id as DocId);
}
self.dense.truncate(last * dim);
true
}
pub fn touch(&mut self, url: &Url, now_ms: u64) -> bool {
let key = UrlKey::from_url(url);
match self.url_index.get(&key) {
Some(&id) => {
self.meta[id as usize].fetched_at_ms = now_ms;
true
}
None => false,
}
}
pub fn set_pinned(&mut self, url_prefix: &str, pinned: bool) -> usize {
let mut changed = 0;
for doc in &mut self.meta {
if doc.url.starts_with(url_prefix) && doc.pinned != pinned {
doc.pinned = pinned;
changed += 1;
}
}
changed
}
#[cfg(feature = "crawl")]
#[must_use]
pub(crate) fn edge_lists(&self) -> &[Vec<UrlKey>] {
&self.edges
}
#[cfg(any(feature = "crawl", test))]
#[must_use]
pub(crate) fn documents(&self) -> &[DocMeta] {
&self.meta
}
#[must_use]
pub fn build(self) -> Index {
let bm25 = Bm25::build(&self.doc_terms, self.k1, self.b);
Index::from_owned(
self.dim,
self.metric,
self.embedder_id,
bm25,
self.meta,
self.dense,
self.edges,
self.url_index,
)
}
#[must_use]
pub fn serialize(&self) -> Vec<u8> {
let bm25 = Bm25::build(&self.doc_terms, self.k1, self.b);
serialize_sections(
self.dim,
self.metric,
self.embedder_id,
&self.meta,
&self.dense,
&self.edges,
&bm25,
)
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
Ok(graph_r::fsio::write_atomic(
path.as_ref(),
&[&self.serialize()],
)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(url: &str, hash: u64, terms: &[&str], vec: Vec<f32>) -> Document {
Document {
url: Url::parse(url).unwrap(),
kind: ResourceKind::Html,
content_hash: hash,
title: Some(CompactString::from("t")),
snippet: CompactString::from("s"),
lang: Some(CompactString::from("en")),
tags: SmallVec::new(),
terms: terms.iter().map(|t| CompactString::from(*t)).collect(),
vector: vec,
edges: Vec::new(),
fetched_at_ms: 0,
etag: None,
pinned: false,
}
}
#[test]
fn upsert_dedups_by_canonical_url() {
let mut b = IndexBuilder::new(2, Metric::Cosine, 1);
assert_eq!(
b.upsert(doc("https://x.dev/a", 1, &["x"], vec![1.0, 0.0]))
.unwrap(),
UpsertOutcome::Added
);
assert_eq!(
b.upsert(doc("HTTPS://x.dev/a/", 1, &["x"], vec![1.0, 0.0]))
.unwrap(),
UpsertOutcome::Unchanged
);
assert_eq!(b.len(), 1);
}
#[test]
fn upsert_updates_on_content_change() {
let mut b = IndexBuilder::new(2, Metric::Cosine, 1);
b.upsert(doc("https://x.dev/a", 1, &["x"], vec![1.0, 0.0]))
.unwrap();
assert_eq!(
b.upsert(doc("https://x.dev/a", 2, &["y"], vec![0.0, 1.0]))
.unwrap(),
UpsertOutcome::Updated
);
assert_eq!(b.len(), 1);
}
#[test]
fn upsert_rejects_wrong_dimension() {
let mut b = IndexBuilder::new(3, Metric::Cosine, 1);
let err = b
.upsert(doc("https://x.dev/a", 1, &["x"], vec![1.0, 0.0]))
.unwrap_err();
assert!(matches!(err, Error::DimMismatch { index: 3, query: 2 }));
}
#[test]
fn build_yields_searchable_index() {
let mut b = IndexBuilder::new(2, Metric::Cosine, 7);
b.upsert(doc("https://x.dev/a", 1, &["cat"], vec![1.0, 0.0]))
.unwrap();
b.upsert(doc("https://x.dev/b", 1, &["dog"], vec![0.0, 1.0]))
.unwrap();
let idx = b.build();
assert_eq!(idx.len(), 2);
assert_eq!(idx.dim(), 2);
}
#[test]
fn remove_keeps_state_consistent() {
let mut b = IndexBuilder::new(2, Metric::Cosine, 1);
b.upsert(doc("https://x.dev/a", 1, &["a"], vec![1.0, 0.0]))
.unwrap();
b.upsert(doc("https://x.dev/b", 1, &["b"], vec![0.0, 1.0]))
.unwrap();
b.upsert(doc("https://x.dev/c", 1, &["c"], vec![0.7, 0.7]))
.unwrap();
assert!(b.remove(&Url::parse("https://x.dev/b").unwrap()));
assert_eq!(b.len(), 2);
assert!(!b.remove(&Url::parse("https://x.dev/b").unwrap())); let idx = b.build();
assert_eq!(idx.len(), 2);
assert!(idx.contains(UrlKey::from_url(&Url::parse("https://x.dev/a").unwrap())));
assert!(idx.contains(UrlKey::from_url(&Url::parse("https://x.dev/c").unwrap())));
assert!(!idx.contains(UrlKey::from_url(&Url::parse("https://x.dev/b").unwrap())));
let dense = idx.dense().unwrap();
assert!((dense[2] - dense[3]).abs() < 1e-6);
}
#[test]
fn touch_updates_only_timestamp() {
let mut b = IndexBuilder::new(2, Metric::Cosine, 1);
b.upsert(doc("https://x.dev/a", 1, &["a"], vec![1.0, 0.0]))
.unwrap();
let url = Url::parse("https://x.dev/a").unwrap();
assert!(b.touch(&url, 12345));
assert_eq!(b.documents()[0].fetched_at_ms, 12345);
assert_eq!(b.documents()[0].content_hash, 1); assert!(!b.touch(&Url::parse("https://x.dev/missing").unwrap(), 1));
}
#[test]
fn set_pinned_by_prefix_and_sticky_on_update() {
let mut b = IndexBuilder::new(2, Metric::Cosine, 1);
b.upsert(doc("https://x.dev/docs/a", 1, &["a"], vec![1.0, 0.0]))
.unwrap();
b.upsert(doc("https://x.dev/blog/b", 1, &["b"], vec![0.0, 1.0]))
.unwrap();
assert_eq!(b.set_pinned("https://x.dev/docs/", true), 1);
assert!(b.documents()[0].pinned);
assert!(!b.documents()[1].pinned);
b.upsert(doc("https://x.dev/docs/a", 2, &["a2"], vec![0.5, 0.5]))
.unwrap();
assert!(b.documents()[0].pinned, "pin must survive re-crawl");
}
}