use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use turbovec::IdMapIndex;
pub const SHARD_VECTORS: usize = 65536;
const SHARD_VECTORS_ENV: &str = "SEMLITH_SHARD_VECTORS";
const SHARD_DIR: &str = "index";
const SHARD_EXT: &str = "tvim";
fn shard_capacity() -> usize {
std::env::var(SHARD_VECTORS_ENV)
.ok()
.and_then(|v| v.parse().ok())
.filter(|n| *n > 0)
.unwrap_or(SHARD_VECTORS)
}
pub const INDEX_MEMORY_MB: usize = 512;
pub const INDEX_MEMORY_ENV: &str = "SEMLITH_INDEX_MEMORY";
pub fn budget_mb() -> usize {
index_budget_bytes() / 1024 / 1024
}
fn index_budget_bytes() -> usize {
std::env::var(INDEX_MEMORY_ENV)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or(INDEX_MEMORY_MB)
* 1024
* 1024
}
pub fn resident_bytes(n: usize, dim: usize, bit_width: usize) -> usize {
let codes = dim * bit_width / 8;
n * (2 * codes + 4 + 8 + 24)
}
pub enum Allowlist {
All,
Empty,
Subset(Vec<u64>),
}
pub enum VectorIndex {
Single(Box<Single>),
Sharded(Sharded),
}
impl VectorIndex {
pub fn open(dir: &Path, dim: usize, bit_width: usize, prefer_sharded: bool) -> Result<Self> {
let sharded = Sharded::open(dir, dim, bit_width)?;
if !sharded.shards.is_empty() {
return Ok(VectorIndex::Sharded(sharded));
}
let single = Single::open(dir, dim, bit_width);
if single.path.exists() || !prefer_sharded {
return Ok(VectorIndex::Single(Box::new(single)));
}
Ok(VectorIndex::Sharded(sharded))
}
pub fn exists(&self) -> bool {
match self {
VectorIndex::Single(s) => s.path.exists(),
VectorIndex::Sharded(s) => !s.shards.is_empty(),
}
}
pub fn is_resident(&self) -> bool {
match self {
VectorIndex::Single(s) => s.resident.is_some(),
VectorIndex::Sharded(s) => s.shards.iter().any(|s| s.resident.is_some()),
}
}
pub fn resident_len(&self) -> Option<usize> {
match self {
VectorIndex::Single(s) => s.resident.as_ref().map(|i| i.len()),
VectorIndex::Sharded(s) => s.fully_resident_len(),
}
}
pub fn evict(&mut self) {
match self {
VectorIndex::Single(s) => s.resident = None,
VectorIndex::Sharded(s) => s.evict_all(),
}
}
pub fn prepare(&mut self) -> Result<()> {
match self {
VectorIndex::Single(s) => {
s.get()?.prepare();
}
VectorIndex::Sharded(s) => s.prepare()?,
}
Ok(())
}
pub fn add(&mut self, vectors: &[f32], ids: &[u64]) -> Result<()> {
match self {
VectorIndex::Single(s) => s
.get()?
.add_with_ids(vectors, ids)
.map_err(|e| anyhow::anyhow!("{e:?}")),
VectorIndex::Sharded(s) => s.add(vectors, ids),
}
}
pub fn remove(&mut self, id: u64) -> Result<()> {
match self {
VectorIndex::Single(s) => {
s.get()?.remove(id);
}
VectorIndex::Sharded(s) => s.remove(id)?,
}
Ok(())
}
pub fn contains(&mut self, id: u64) -> Result<bool> {
match self {
VectorIndex::Single(s) => Ok(s.get()?.contains(id)),
VectorIndex::Sharded(s) => s.contains(id),
}
}
pub fn search(
&mut self,
vector: &[f32],
depth: usize,
allowlist: &Allowlist,
) -> Result<(Vec<f32>, Vec<u64>)> {
if matches!(allowlist, Allowlist::Empty) || depth == 0 {
return Ok((Vec::new(), Vec::new()));
}
match self {
VectorIndex::Single(s) => {
let index = s.get()?;
if index.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let list = match allowlist {
Allowlist::Subset(ids) => Some(ids.as_slice()),
_ => None,
};
index
.search_with_allowlist(vector, depth, list)
.map_err(|e| anyhow::anyhow!("searching the vector index: {e}"))
}
VectorIndex::Sharded(s) => s.search(vector, depth, allowlist),
}
}
pub fn save(&mut self) -> Result<()> {
match self {
VectorIndex::Single(s) => s.save(),
VectorIndex::Sharded(s) => s.save(),
}
}
pub fn max_resident(&self) -> Option<usize> {
match self {
VectorIndex::Single(_) => None,
VectorIndex::Sharded(s) => Some(s.max_resident()),
}
}
pub fn evictions(&self) -> u64 {
match self {
VectorIndex::Single(_) => 0,
VectorIndex::Sharded(s) => s.evictions(),
}
}
pub fn shards(&self) -> usize {
match self {
VectorIndex::Single(s) => usize::from(s.path.exists()),
VectorIndex::Sharded(s) => s.shards.len(),
}
}
pub fn clean(&self) {
match self {
VectorIndex::Single(s) => {
let _ = std::fs::remove_file(s.path.with_extension("tv.tmp"));
}
VectorIndex::Sharded(s) => s.clean(),
}
}
}
pub struct Single {
path: PathBuf,
dim: usize,
bit_width: usize,
resident: Option<IdMapIndex>,
}
impl Single {
fn open(dir: &Path, dim: usize, bit_width: usize) -> Self {
Self {
path: dir.join("index.tv"),
dim,
bit_width,
resident: None,
}
}
fn get(&mut self) -> Result<&mut IdMapIndex> {
if self.resident.is_none() {
self.resident = Some(load_or_new(&self.path, self.dim, self.bit_width)?);
}
Ok(self.resident.as_mut().unwrap())
}
fn save(&mut self) -> Result<()> {
let tmp = self.path.with_extension("tv.tmp");
let path = self.path.clone();
self.get()?
.write(&tmp)
.with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, &path)?;
crate::home::tighten_file(&path);
Ok(())
}
}
struct Shard {
start_id: u64,
path: PathBuf,
resident: Option<IdMapIndex>,
dirty: bool,
last_used: u64,
}
pub struct Sharded {
dir: PathBuf,
dim: usize,
bit_width: usize,
capacity: usize,
shards: Vec<Shard>,
max_resident: usize,
clock: u64,
evictions: u64,
}
impl Sharded {
fn open(store_dir: &Path, dim: usize, bit_width: usize) -> Result<Self> {
let dir = store_dir.join(SHARD_DIR);
let mut shards = Vec::new();
if dir.is_dir() {
for entry in std::fs::read_dir(&dir)
.with_context(|| format!("reading {}", dir.display()))?
.flatten()
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some(SHARD_EXT) {
continue;
}
let Some(start_id) = path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<u64>().ok())
else {
continue;
};
shards.push(Shard {
start_id,
path,
resident: None,
dirty: false,
last_used: 0,
});
}
}
shards.sort_by_key(|s| s.start_id);
let capacity = shard_capacity();
let full = resident_bytes(capacity, dim, bit_width).max(1);
Ok(Self {
dir,
dim,
bit_width,
capacity,
shards,
max_resident: (index_budget_bytes() / full).max(1),
clock: 0,
evictions: 0,
})
}
pub fn max_resident(&self) -> usize {
self.max_resident
}
pub fn evictions(&self) -> u64 {
self.evictions
}
fn shard_path(&self, start_id: u64) -> PathBuf {
self.dir.join(format!("{start_id:016}.{SHARD_EXT}"))
}
fn locate(&self, id: u64) -> Option<usize> {
match self.shards.partition_point(|s| s.start_id <= id) {
0 => None,
n => Some(n - 1),
}
}
fn load(&mut self, at: usize) -> Result<&mut IdMapIndex> {
self.clock += 1;
self.shards[at].last_used = self.clock;
if self.shards[at].resident.is_none() {
self.make_room(at);
let loaded = load_or_new(&self.shards[at].path, self.dim, self.bit_width)?;
self.shards[at].resident = Some(loaded);
}
Ok(self.shards[at].resident.as_mut().unwrap())
}
fn make_room(&mut self, keep: usize) {
loop {
let resident: Vec<usize> = (0..self.shards.len())
.filter(|i| self.shards[*i].resident.is_some())
.collect();
if resident.len() < self.max_resident {
return;
}
let Some(&coldest) = resident
.iter()
.filter(|i| **i != keep && !self.shards[**i].dirty)
.min_by_key(|i| self.shards[**i].last_used)
else {
return;
};
self.shards[coldest].resident = None;
self.evictions += 1;
}
}
fn evict_all(&mut self) {
for shard in &mut self.shards {
if !shard.dirty {
shard.resident = None;
}
}
}
fn fully_resident_len(&self) -> Option<usize> {
if self.shards.is_empty() || self.shards.iter().any(|s| s.resident.is_none()) {
return None;
}
Some(
self.shards
.iter()
.map(|s| s.resident.as_ref().map_or(0, |i| i.len()))
.sum(),
)
}
fn prepare(&mut self) -> Result<()> {
let warm = self.max_resident.min(self.shards.len());
for at in (self.shards.len() - warm)..self.shards.len() {
self.load(at)?.prepare();
}
Ok(())
}
fn add(&mut self, vectors: &[f32], ids: &[u64]) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
let mut offset = 0;
while offset < ids.len() {
let at = self.open_shard(ids[offset])?;
let room = self.capacity.saturating_sub(self.load(at)?.len());
let take = room.min(ids.len() - offset);
let dim = self.dim;
self.load(at)?
.add_with_ids(
&vectors[offset * dim..(offset + take) * dim],
&ids[offset..offset + take],
)
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
self.shards[at].dirty = true;
offset += take;
}
Ok(())
}
fn open_shard(&mut self, next_id: u64) -> Result<usize> {
if let Some(last) = self.shards.len().checked_sub(1)
&& self.load(last)?.len() < self.capacity
{
return Ok(last);
}
let path = self.shard_path(next_id);
self.clock += 1;
self.make_room(self.shards.len());
self.shards.push(Shard {
start_id: next_id,
path,
resident: Some(
IdMapIndex::new(self.dim, self.bit_width).map_err(|e| anyhow::anyhow!("{e:?}"))?,
),
dirty: true,
last_used: self.clock,
});
Ok(self.shards.len() - 1)
}
fn remove(&mut self, id: u64) -> Result<()> {
let Some(at) = self.locate(id) else {
return Ok(());
};
if self.load(at)?.remove(id) {
self.shards[at].dirty = true;
}
Ok(())
}
fn contains(&mut self, id: u64) -> Result<bool> {
match self.locate(id) {
Some(at) => Ok(self.load(at)?.contains(id)),
None => Ok(false),
}
}
fn search(
&mut self,
vector: &[f32],
depth: usize,
allowlist: &Allowlist,
) -> Result<(Vec<f32>, Vec<u64>)> {
let mut merged: Vec<(f32, u64)> = Vec::new();
for at in 0..self.shards.len() {
let start = self.shards[at].start_id;
let end = self.shards.get(at + 1).map(|s| s.start_id);
let subset = match allowlist {
Allowlist::Empty => return Ok((Vec::new(), Vec::new())),
Allowlist::All => None,
Allowlist::Subset(ids) => {
let mine: Vec<u64> = ids
.iter()
.copied()
.filter(|id| *id >= start && end.is_none_or(|e| *id < e))
.collect();
if mine.is_empty() {
continue;
}
Some(mine)
}
};
let index = self.load(at)?;
if index.is_empty() {
continue;
}
let subset = subset.map(|ids| {
ids.into_iter()
.filter(|id| index.contains(*id))
.collect::<Vec<u64>>()
});
if subset.as_ref().is_some_and(|ids| ids.is_empty()) {
continue;
}
let (scores, ids) = index
.search_with_allowlist(vector, depth, subset.as_deref())
.map_err(|e| anyhow::anyhow!("searching a vector shard: {e}"))?;
merged.extend(scores.into_iter().zip(ids));
}
merged.sort_by(|a, b| b.0.total_cmp(&a.0));
merged.truncate(depth);
Ok(merged.into_iter().unzip())
}
fn save(&mut self) -> Result<()> {
if !self.shards.iter().any(|s| s.dirty) {
return Ok(());
}
crate::home::tighten_dir(&self.dir);
std::fs::create_dir_all(&self.dir)
.with_context(|| format!("creating {}", self.dir.display()))?;
for shard in &mut self.shards {
if !shard.dirty {
continue;
}
let Some(index) = shard.resident.as_ref() else {
continue;
};
let tmp = shard.path.with_extension("tmp");
index
.write(&tmp)
.with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, &shard.path)?;
crate::home::tighten_file(&shard.path);
shard.dirty = false;
}
Ok(())
}
fn clean(&self) {
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return;
};
for entry in entries.flatten() {
if entry.path().extension().and_then(|e| e.to_str()) == Some("tmp") {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
fn load_or_new(path: &Path, dim: usize, bit_width: usize) -> Result<IdMapIndex> {
if !path.exists() {
return IdMapIndex::new(dim, bit_width).map_err(|e| anyhow::anyhow!("{e:?}"));
}
let index = IdMapIndex::load(path).with_context(|| format!("loading {}", path.display()))?;
if let Some(d) = index.dim_opt()
&& d != dim
{
bail!("index is {d}-dimensional but the store's model produces {dim}; store is corrupt");
}
Ok(index)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("semlith-index-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn opening_reads_nothing() {
let dir = scratch("lazy");
std::fs::write(dir.join("index.tv"), b"not a turbovec file at all").unwrap();
let mut index = VectorIndex::open(&dir, 384, 4, false).unwrap();
assert!(index.exists(), "the file is there to be read");
assert!(!index.is_resident(), "opening loaded it anyway");
assert_eq!(index.resident_len(), None);
assert!(
index.contains(1).is_err(),
"a file that is not an index was accepted as one"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_absent_index_is_an_empty_one() {
let dir = scratch("absent");
let mut index = VectorIndex::open(&dir, 8, 4, false).unwrap();
assert!(!index.exists());
let (scores, ids) = index.search(&[0.0; 8], 5, &Allowlist::All).unwrap();
assert!(scores.is_empty() && ids.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
fn axis(dim: usize, i: usize) -> Vec<f32> {
let mut v = vec![0.0; dim];
v[i % dim] = 1.0;
v
}
#[test]
fn vectors_land_in_shards_and_the_shards_are_searchable() {
let dir = scratch("shards");
let dim = 8;
let mut index = VectorIndex::open(&dir, dim, 4, true).unwrap();
let VectorIndex::Sharded(ref mut sharded) = index else {
panic!("asked for shards, got one file")
};
sharded.capacity = 4;
for batch in 0..4 {
let ids: Vec<u64> = (0..3)
.map(|i| batch * 3 + i + 1)
.filter(|id| *id <= 10)
.collect();
if ids.is_empty() {
continue;
}
let flat: Vec<f32> = ids
.iter()
.flat_map(|id| axis(dim, *id as usize - 1))
.collect();
index.add(&flat, &ids).unwrap();
}
index.save().unwrap();
assert_eq!(index.shards(), 3, "10 vectors at 4 per shard is 3 shards");
let names: Vec<String> = std::fs::read_dir(dir.join(SHARD_DIR))
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(SHARD_EXT))
.collect();
assert!(
names.contains(&format!("{:016}.{SHARD_EXT}", 1u64)),
"a shard is named for its first id: {names:?}"
);
let mut reopened = VectorIndex::open(&dir, dim, 4, true).unwrap();
assert_eq!(reopened.shards(), 3);
for id in 1..=10u64 {
assert!(reopened.contains(id).unwrap(), "id {id} went missing");
let (_, hits) = reopened
.search(&axis(dim, id as usize - 1), 1, &Allowlist::All)
.unwrap();
assert!(
hits.iter()
.all(|h| (h - 1) % dim as u64 == (id - 1) % dim as u64),
"id {id} ranked {hits:?}, which is not on its axis"
);
}
let (_, hits) = reopened
.search(&axis(dim, 0), 5, &Allowlist::Subset(vec![9]))
.unwrap();
assert_eq!(hits, vec![9], "an allowlist of one returned {hits:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_budget_of_one_shard_still_searches_all_of_them() {
let dir = scratch("budget");
let dim = 8;
let mut index = VectorIndex::open(&dir, dim, 4, true).unwrap();
let VectorIndex::Sharded(ref mut sharded) = index else {
panic!("asked for shards")
};
sharded.capacity = 4;
let ids: Vec<u64> = (1..=12).collect();
let flat: Vec<f32> = ids
.iter()
.flat_map(|id| axis(dim, *id as usize - 1))
.collect();
index.add(&flat, &ids).unwrap();
index.save().unwrap();
assert_eq!(index.shards(), 3);
let mut reopened = VectorIndex::open(&dir, dim, 4, true).unwrap();
let VectorIndex::Sharded(ref mut sharded) = reopened else {
panic!("shards on disk must reopen as shards")
};
sharded.max_resident = 1;
let (_, hits) = reopened.search(&axis(dim, 2), 12, &Allowlist::All).unwrap();
assert!(
hits.len() >= 3,
"a budget of one shard returned {hits:?}, so shards were skipped rather than reloaded"
);
assert!(
reopened.evictions() >= 2,
"three shards were searched under a one-shard budget without a single eviction"
);
let VectorIndex::Sharded(ref s) = reopened else {
unreachable!()
};
assert_eq!(
s.shards.iter().filter(|s| s.resident.is_some()).count(),
1,
"more than the budget stayed resident"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn removing_rewrites_only_the_shard_holding_it() {
let dir = scratch("dirty");
let dim = 8;
let mut index = VectorIndex::open(&dir, dim, 4, true).unwrap();
let VectorIndex::Sharded(ref mut sharded) = index else {
panic!("asked for shards")
};
sharded.capacity = 4;
let ids: Vec<u64> = (1..=8).collect();
let flat: Vec<f32> = ids
.iter()
.flat_map(|id| axis(dim, *id as usize - 1))
.collect();
index.add(&flat, &ids).unwrap();
index.save().unwrap();
assert_eq!(index.shards(), 2);
let stamp = |name: u64| {
std::fs::metadata(dir.join(SHARD_DIR).join(format!("{name:016}.{SHARD_EXT}")))
.unwrap()
.modified()
.unwrap()
};
let first_before = stamp(1);
let second_before = stamp(5);
std::thread::sleep(std::time::Duration::from_millis(1100));
let mut reopened = VectorIndex::open(&dir, dim, 4, true).unwrap();
reopened.remove(6).unwrap();
reopened.save().unwrap();
assert_eq!(stamp(1), first_before, "an untouched shard was rewritten");
assert!(
stamp(5) > second_before,
"the touched shard was not written"
);
assert!(!reopened.contains(6).unwrap());
assert!(reopened.contains(5).unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
}