use crate::embedding::static_distill::DocTokenEncoder;
use anyhow::{Context, Result};
use ndarray::Array2;
use std::path::Path;
const RESERVOIR_SEED: u64 = 0x51D3_C0DE_F02E_A7B1;
const KMEANS_SEED: u64 = 42;
struct SplitMix64(u64);
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn next_below(&mut self, bound: u64) -> u64 {
self.next_u64() % bound
}
}
pub struct CentroidTrainOptions {
pub num_centroids: usize,
pub sample_capacity: usize,
pub batch: usize,
}
impl Default for CentroidTrainOptions {
fn default() -> Self {
Self {
num_centroids: 8192,
sample_capacity: 1_000_000,
batch: 32,
}
}
}
struct Accumulator {
sample_capacity: usize,
reservoir: Vec<Vec<f32>>,
seen: u64,
rng: SplitMix64,
dims: Option<usize>,
}
impl Accumulator {
fn new(sample_capacity: usize) -> Self {
Self {
sample_capacity,
reservoir: Vec::new(),
seen: 0,
rng: SplitMix64::new(RESERVOIR_SEED),
dims: None,
}
}
fn ingest_batch(&mut self, encoder: &dyn DocTokenEncoder, texts: &[String]) -> Result<()> {
for (_ids, emb) in encoder.encode_with_ids(texts)? {
self.ingest_document(&emb)?;
}
Ok(())
}
fn ingest_document(&mut self, emb: &crate::embedding::colbert::TokenEmbeddings) -> Result<()> {
if emb.nrows() == 0 {
return Ok(());
}
let ncols = emb.ncols();
match self.dims {
None => self.dims = Some(ncols),
Some(d) => anyhow::ensure!(
d == ncols,
"train_centroids: inconsistent embedding width ({d} vs {ncols})"
),
}
for row in emb.rows() {
self.reservoir_sample(row.to_vec());
}
Ok(())
}
fn reservoir_sample(&mut self, item: Vec<f32>) {
if self.reservoir.len() < self.sample_capacity {
self.reservoir.push(item);
} else {
let j = self.rng.next_below(self.seen + 1);
if (j as usize) < self.sample_capacity {
self.reservoir[j as usize] = item;
}
}
self.seen += 1;
}
}
pub fn train_centroids(
encoder: &dyn DocTokenEncoder,
corpus: impl Iterator<Item = String>,
opts: &CentroidTrainOptions,
) -> Result<Array2<f32>> {
anyhow::ensure!(opts.batch > 0, "train_centroids: `batch` must be > 0");
anyhow::ensure!(
opts.num_centroids > 0,
"train_centroids: `num_centroids` must be > 0"
);
anyhow::ensure!(
opts.sample_capacity > 0,
"train_centroids: `sample_capacity` must be > 0"
);
let mut acc = Accumulator::new(opts.sample_capacity);
let mut batch_buf: Vec<String> = Vec::with_capacity(opts.batch);
for text in corpus {
batch_buf.push(text);
if batch_buf.len() == opts.batch {
acc.ingest_batch(encoder, &batch_buf)?;
batch_buf.clear();
}
}
if !batch_buf.is_empty() {
acc.ingest_batch(encoder, &batch_buf)?;
}
let dims = acc
.dims
.ok_or_else(|| anyhow::anyhow!("train_centroids: empty corpus produced no tokens"))?;
let n = acc.reservoir.len();
let mut flat = Array2::<f32>::zeros((n, dims));
for (i, row) in acc.reservoir.iter().enumerate() {
flat.row_mut(i)
.assign(&ndarray::ArrayView1::from(row.as_slice()));
}
drop(acc.reservoir);
let k = opts.num_centroids.min(n);
if k < opts.num_centroids {
tracing::warn!(
requested = opts.num_centroids,
sample = n,
"train_centroids: clamping k to sample size"
);
}
let mut kmeans_config = next_plaid::kmeans::default_config(k);
kmeans_config.seed = KMEANS_SEED;
let centroids = next_plaid::compute_centroids(&flat.view(), k, Some(kmeans_config), true)
.map_err(|e| anyhow::anyhow!("k-means over {n} sampled token embeddings failed: {e}"))?;
Ok(centroids)
}
pub fn save_centroids_npy(path: &Path, centroids: &Array2<f32>) -> Result<()> {
use ndarray_npy::WriteNpyExt;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(
".{}.{}.tmp",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("centroids"),
std::process::id()
));
let file = std::fs::File::create(&tmp)
.with_context(|| format!("failed to create temp file {}", tmp.display()))?;
centroids
.write_npy(file)
.context("failed to write centroids npy")?;
std::fs::rename(&tmp, path)
.with_context(|| format!("failed to move centroids into place at {}", path.display()))?;
Ok(())
}
pub fn load_centroids_npy(path: &Path) -> Result<Array2<f32>> {
use ndarray_npy::ReadNpyExt;
let file = std::fs::File::open(path)
.with_context(|| format!("failed to open centroids file {}", path.display()))?;
Array2::<f32>::read_npy(file)
.with_context(|| format!("failed to read centroids npy at {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embedding::colbert::TokenEmbeddings;
use crate::embedding::static_distill::DocTokenEncoder;
use anyhow::Result as AnyResult;
use ndarray::Array2 as NdArray2;
struct FakeEncoder {
dims: usize,
vocab_size: usize,
}
impl DocTokenEncoder for FakeEncoder {
fn encode_with_ids(&self, texts: &[String]) -> AnyResult<Vec<(Vec<u32>, TokenEmbeddings)>> {
Ok(texts
.iter()
.map(|text| {
let bytes = text.as_bytes();
let mut emb = NdArray2::<f32>::zeros((bytes.len(), self.dims));
let ids: Vec<u32> = bytes
.iter()
.enumerate()
.map(|(i, &b)| {
let id = u32::from(b - b'a');
emb[[i, id as usize % self.dims]] = 1.0;
id
})
.collect();
(ids, emb)
})
.collect())
}
fn vocab_size(&self) -> usize {
self.vocab_size
}
}
#[test]
fn trains_requested_number_of_centroids_with_right_dim() {
let encoder = FakeEncoder {
dims: 4,
vocab_size: 4,
};
let corpus: Vec<String> = (0..50).map(|_| "abcdabcdabcd".to_string()).collect();
let opts = CentroidTrainOptions {
num_centroids: 4,
sample_capacity: 10_000,
batch: 8,
};
let c = train_centroids(&encoder, corpus.into_iter(), &opts).unwrap();
assert_eq!(c.ncols(), 4);
assert_eq!(c.nrows(), 4);
}
#[test]
fn clamps_k_to_sample_size() {
let encoder = FakeEncoder {
dims: 4,
vocab_size: 4,
};
let corpus = vec!["abcd".to_string()];
let opts = CentroidTrainOptions {
num_centroids: 64,
sample_capacity: 10_000,
batch: 8,
};
let c = train_centroids(&encoder, corpus.into_iter(), &opts).unwrap();
assert!(
c.nrows() >= 1 && c.nrows() <= 4,
"got {} centroids",
c.nrows()
);
assert_eq!(c.ncols(), 4);
}
#[test]
fn empty_corpus_errors() {
let encoder = FakeEncoder {
dims: 4,
vocab_size: 4,
};
let err = train_centroids(
&encoder,
Vec::<String>::new().into_iter(),
&CentroidTrainOptions::default(),
)
.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("empty"),
"got: {err}"
);
}
#[test]
fn save_is_atomic_and_readable_back() {
use ndarray_npy::ReadNpyExt;
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("frozen_centroids.npy");
let c = NdArray2::<f32>::from_shape_fn((3, 4), |(i, j)| (i * 4 + j) as f32);
save_centroids_npy(&path, &c).unwrap();
let back: NdArray2<f32> = NdArray2::read_npy(std::fs::File::open(&path).unwrap()).unwrap();
assert_eq!(back, c);
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter(|e| e.as_ref().unwrap().path() != path)
.collect();
assert!(leftovers.is_empty(), "temp files left: {leftovers:?}");
let loaded = load_centroids_npy(&path).unwrap();
assert_eq!(loaded, c);
}
#[test]
fn training_is_deterministic() {
let encoder = FakeEncoder {
dims: 4,
vocab_size: 4,
};
let corpus: Vec<String> = (0..30).map(|i| "abcd".repeat(1 + i % 3)).collect();
let opts = CentroidTrainOptions {
num_centroids: 3,
sample_capacity: 1_000,
batch: 8,
};
let a = train_centroids(&encoder, corpus.clone().into_iter(), &opts).unwrap();
let b = train_centroids(&encoder, corpus.into_iter(), &opts).unwrap();
assert_eq!(a, b, "fixed seeds must make training reproducible");
}
}