bench: make the vector stage real, and measure BM25 vs vector vs hybrid (Tier 4b)
CI / test (push) Failing after 2s

Every LongMemEval number this project has published measured BM25 alone. The
bench passed zero-vector embeddings with vector_weight=0.0, so the HNSW/vector
stage — the thing the README credits for retrieval quality — contributed
nothing and was never tested.

An optional `embeddings` feature loads all-MiniLM-L6-v2 via candle and encodes
the corpus for real. It is off by default and nothing in the shipped crates
depends on it, so a project that advertises no heavyweight dependencies keeps
that property; without the feature the bench behaves exactly as before.

Full haystack, n=500, turn-level:

                          Hit@1    Hit@5   Hit@10      MRR
    BM25 only             53.8%    75.0%    81.6%   0.6320
    Vector only           36.0%    71.8%    81.6%   0.5027
    Hybrid 0.7/0.3        44.4%    79.2%    86.0%   0.5868

Session-level, hybrid leads outright: 88.2 / 95.8 / 97.8 / 0.9158.

The hybrid claim holds for depth and not for precision@1. Hybrid is the best
configuration at Hit@5 and Hit@10 at both granularities — turn-level Hit@5 gains
4.2 points over BM25 and 7.4 over vector-only, which is the result that justifies
running two stages at all. But BM25 alone still leads turn-level Hit@1 and MRR,
so fusing buys deeper recall and pays at rank 1. Callers assembling five memories
of context want hybrid; callers taking a single top hit are better served by BM25
today. The 0.7/0.3 weights are the documented default, not a searched optimum.

omni-cortex's four-signal ablation found the same direction independently — there,
adding BM25 to a dense retriever raised nDCG@5 while lowering Hit@1 and MRR. Two
codebases, two fusion schemes, same trade.

Vector-only trailing BM25 at every turn-level cutoff except Hit@10 is stated
plainly rather than buried: LongMemEval questions share heavy vocabulary with
their evidence turns, which is close to the best case for lexical matching, and
MiniLM at 384-d is a small model.

Implementation notes:
  - Texts are deduplicated before encoding. The haystack sessions are drawn from
    a shared pool, so 500 questions x 493.5 turns collapses to 190,015 unique
    strings — the difference between encoding the corpus once and per question.
  - `embeddings-cuda` adds the GPU path, and it is not a convenience: 190k texts
    take ~13 min on an RTX 5060 Ti, while the same work on 8 CPU cores was still
    unfinished after 30 minutes. The device is selected at runtime with a CPU
    fallback, so a machine without CUDA still works.
  - Mean-pooling is masked and the output L2-normalised, which is the published
    recipe for this checkpoint (not the [CLS] pooler).

One measurement wrinkle, recorded rather than smoothed over: on the oracle
variant BM25-only reads 84.2% Hit@5 with real embedding vectors present against
84.4% with zero vectors — one question of 500 changes rank, MRR identical at
0.6597. On the full haystack the two agree exactly. Weight 0.0 evidently does not
make the vector stage bit-for-bit absent from candidate selection on a small
corpus.

Verified on the Linux dev host: 49 groups / 1659 passed / 0 failed, clippy clean
under -D warnings, fmt clean, with and without the feature.
This commit is contained in:
Omar Sobh
2026-08-07 07:22:20 -07:00
parent 7d6e269bf3
commit c913cd1cbf
5 changed files with 396 additions and 21 deletions
@@ -0,0 +1,164 @@
//! Optional MiniLM sentence embedder for the LongMemEval bench.
//!
//! Compiled only under the `embeddings` feature, so the default build of a
//! project that prides itself on having no heavyweight dependencies stays
//! exactly as it was. Without it the bench runs BM25-only, as it always has.
//!
//! Loads `sentence-transformers/all-MiniLM-L6-v2` — the same checkpoint
//! omni-cortex uses — and produces 384-d mean-pooled, L2-normalised sentence
//! embeddings, which is the published recipe for this model (mean over token
//! states weighted by the attention mask, *not* the `[CLS]` pooler output).
use std::collections::HashMap;
use std::path::Path;
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config, HiddenAct};
use tokenizers::Tokenizer;
/// Sequences encoded per forward pass. Larger batches amortise the transformer
/// call; 64 keeps peak memory modest while still saturating a CPU.
const BATCH: usize = 64;
/// A loaded MiniLM encoder.
pub struct Embedder {
model: BertModel,
tokenizer: Tokenizer,
device: Device,
}
impl Embedder {
/// Load from a directory holding `model.safetensors` and `tokenizer.json`.
///
/// `config.json` is read when present; otherwise the published MiniLM-L6-v2
/// architecture constants are used, which are pinned rather than guessed.
pub fn load(dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
// CUDA when the feature is on and a device is actually present; the CPU
// path is correct but roughly two orders of magnitude slower, which is
// the difference between minutes and most of a day on the full haystack.
let device = match Device::new_cuda(0) {
Ok(d) => {
eprintln!("Embedder: CUDA device 0");
d
}
Err(e) => {
eprintln!("Embedder: CPU ({e})");
Device::Cpu
}
};
let weights = dir.join("model.safetensors");
let tok_path = dir.join("tokenizer.json");
let config: Config = match std::fs::read_to_string(dir.join("config.json")) {
Ok(raw) => serde_json::from_str(&raw)?,
Err(_) => Config {
vocab_size: 30_522,
hidden_size: 384,
num_hidden_layers: 6,
num_attention_heads: 12,
intermediate_size: 1_536,
hidden_act: HiddenAct::Gelu,
hidden_dropout_prob: 0.0,
max_position_embeddings: 512,
type_vocab_size: 2,
initializer_range: 0.02,
layer_norm_eps: 1e-12,
pad_token_id: 0,
position_embedding_type: Default::default(),
use_cache: false,
classifier_dropout: None,
model_type: None,
},
};
let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[weights], DType::F32, &device)? };
let model = BertModel::load(vb, &config)?;
let tokenizer = Tokenizer::from_file(&tok_path).map_err(|e| e.to_string())?;
Ok(Self {
model,
tokenizer,
device,
})
}
/// Encode `texts` into 384-d unit vectors, in order.
fn encode_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, Box<dyn std::error::Error>> {
let mut tk = self.tokenizer.clone();
let tk = tk
.with_padding(Some(tokenizers::PaddingParams::default()))
.with_truncation(Some(tokenizers::TruncationParams {
max_length: 512,
..Default::default()
}))
.map_err(|e| e.to_string())?;
let encodings = tk
.encode_batch(texts.to_vec(), true)
.map_err(|e| e.to_string())?;
let ids: Vec<u32> = encodings
.iter()
.flat_map(|e| e.get_ids().to_vec())
.collect();
let mask: Vec<u32> = encodings
.iter()
.flat_map(|e| e.get_attention_mask().to_vec())
.collect();
let (b, l) = (encodings.len(), encodings[0].get_ids().len());
let ids = Tensor::from_vec(ids, (b, l), &self.device)?;
let mask = Tensor::from_vec(mask, (b, l), &self.device)?;
let type_ids = ids.zeros_like()?;
let hidden = self.model.forward(&ids, &type_ids, Some(&mask))?;
// Mean-pool over real tokens only: sum(hidden * mask) / sum(mask).
let mask_f = mask.to_dtype(DType::F32)?.unsqueeze(2)?;
let summed = hidden.broadcast_mul(&mask_f)?.sum(1)?;
let counts = mask_f.sum(1)?.clamp(1e-9, f32::INFINITY)?;
let pooled = summed.broadcast_div(&counts)?;
// L2-normalise so cosine similarity is a plain dot product.
let norm = pooled
.sqr()?
.sum_keepdim(1)?
.sqrt()?
.clamp(1e-12, f32::INFINITY)?;
let normed = pooled.broadcast_div(&norm)?;
Ok(normed.to_vec2::<f32>()?)
}
/// Encode every distinct string in `texts` once, returning a lookup map.
///
/// LongMemEval's haystack sessions are drawn from a shared pool, so the same
/// turn text recurs across many questions. Deduplicating before encoding is
/// the difference between encoding the corpus once and encoding it per
/// question.
pub fn encode_unique(
&self,
texts: impl IntoIterator<Item = String>,
) -> Result<HashMap<String, Vec<f32>>, Box<dyn std::error::Error>> {
let mut unique: Vec<String> = texts.into_iter().collect();
unique.sort_unstable();
unique.dedup();
let total = unique.len();
eprintln!("Embedding {total} unique texts with MiniLM (batch {BATCH})...");
let mut out = HashMap::with_capacity(total);
for (n, chunk) in unique.chunks(BATCH).enumerate() {
let refs: Vec<&str> = chunk.iter().map(String::as_str).collect();
let vecs = self.encode_batch(&refs)?;
for (text, v) in chunk.iter().zip(vecs) {
out.insert(text.clone(), v);
}
if n % 50 == 0 {
eprint!("\r [{}/{}] embedded...", (n * BATCH).min(total), total);
}
}
eprintln!("\r [{total}/{total}] embedded. ");
Ok(out)
}
}