feat(memory_tree): switch embed model to bge-m3 (1024-dim, 8K context) (#1174)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-05-04 00:09:34 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 644c5c8bd3
commit b8113e96a5
3 changed files with 53 additions and 16 deletions
@@ -85,7 +85,7 @@ mod tests {
fn ollama_chosen_when_endpoint_and_model_set() {
let (_tmp, mut cfg) = test_config();
cfg.memory_tree.embedding_endpoint = Some("http://localhost:11434".into());
cfg.memory_tree.embedding_model = Some("nomic-embed-text".into());
cfg.memory_tree.embedding_model = Some("bge-m3".into());
cfg.memory_tree.embedding_timeout_ms = Some(5000);
let e = build_embedder_from_config(&cfg).expect("Ollama path should build");
assert_eq!(e.name(), "ollama");
+21 -9
View File
@@ -2,15 +2,24 @@
//!
//! Produces a fixed-dimension vector per chunk / summary so retrieval can
//! rerank candidates by semantic similarity. Phase 4's default backend is a
//! local [Ollama](https://ollama.com) endpoint running `nomic-embed-text`;
//! local [Ollama](https://ollama.com) endpoint running `bge-m3`;
//! tests use the deterministic [`InertEmbedder`] so no network is required.
//!
//! Dimension is hard-coded at [`EMBEDDING_DIM`] (768) — matches the
//! nomic-embed-text output and keeps the blob layout on `mem_tree_chunks` /
//! Dimension is hard-coded at [`EMBEDDING_DIM`] (1024) — matches the
//! bge-m3 output and keeps the blob layout on `mem_tree_chunks` /
//! `mem_tree_summaries` consistent across providers. Mixing dimensions
//! mid-run would corrupt cosine comparisons; we catch that at the trait
//! level rather than deferring to retrieval-time diagnostics.
//!
//! NOTE: bge-m3 replaces the prior `nomic-embed-text` (768-dim, 2048
//! token context). Migration was driven by nomic's hard 2048-token
//! context cap causing long-chunk embed failures (chunker estimates
//! undercount BERT-WordPiece tokens by ~1.5-2× for HTML-derived
//! markdown, so 1500 chunker-tokens routinely exceed nomic's cap).
//! bge-m3 has a native 8192-token context. Existing `embedding` blobs
//! from the 768-dim era are invalid against the new dimension and
//! must be wiped or re-embedded.
//!
//! Write-time semantics: ingest + seal call [`Embedder::embed`] **before**
//! persisting the new row, so a provider error cascades into "don't write
//! this row". Legacy rows from Phases 1-3 predate embeddings and read back
@@ -30,11 +39,11 @@ pub use ollama::OllamaEmbedder;
/// Embedding dimensionality used across the memory tree.
///
/// Hard-coded to match `nomic-embed-text`; swapping providers requires a
/// matching dimension or the trait's post-call validation will bail. Any
/// change to this constant breaks on-disk compatibility with existing
/// Hard-coded to match `bge-m3`; swapping providers requires a matching
/// dimension or the trait's post-call validation will bail. Any change
/// to this constant breaks on-disk compatibility with existing
/// `mem_tree_chunks.embedding` / `mem_tree_summaries.embedding` blobs.
pub const EMBEDDING_DIM: usize = 768;
pub const EMBEDDING_DIM: usize = 1024;
/// Trait backing all Phase 4 embedders. Implementations MUST produce
/// exactly [`EMBEDDING_DIM`] floats per call — callers that persist the
@@ -206,9 +215,12 @@ mod tests {
#[test]
fn unpack_wrong_dim_errors() {
// Correct byte multiple, but wrong float count.
let bad = vec![0u8; 16]; // 4 floats, expected 768
let bad = vec![0u8; 16]; // 4 floats, expected EMBEDDING_DIM (1024)
let err = unpack_embedding(&bad).unwrap_err().to_string();
assert!(err.contains("expected 768"), "got {err}");
assert!(
err.contains(&format!("expected {EMBEDDING_DIM}")),
"got {err}"
);
}
#[test]
@@ -1,8 +1,9 @@
//! Ollama-backed embedder for Phase 4 (#710).
//!
//! Posts `{model, prompt}` to `{endpoint}/api/embeddings` and expects
//! Posts `{model, prompt, options: {num_ctx}}` to
//! `{endpoint}/api/embeddings` and expects
//! `{"embedding": [f32; EMBEDDING_DIM]}` back. Designed for a local
//! `ollama serve` hosting `nomic-embed-text`.
//! `ollama serve` hosting `bge-m3`.
//!
//! This is intentionally a tiny HTTP client — no retry, no pool caching,
//! no streaming. Phase 4 wants the simplest thing that works so we can
@@ -22,8 +23,10 @@ use super::{Embedder, EMBEDDING_DIM};
/// `local_ai` subsystem and the Ollama defaults.
pub const DEFAULT_ENDPOINT: &str = "http://localhost:11434";
/// Default embedding model — must output exactly [`EMBEDDING_DIM`] dims.
pub const DEFAULT_MODEL: &str = "nomic-embed-text";
/// Default embedding model — must output exactly [`EMBEDDING_DIM`]
/// (1024) dims. `bge-m3` is a multilingual BERT-family encoder with
/// native 8192-token context and 1024-dim output.
pub const DEFAULT_MODEL: &str = "bge-m3";
/// Default request timeout. Ollama's first-use latency is a few hundred
/// ms on a warm model; 10s absorbs a cold-model load on commodity
@@ -99,10 +102,28 @@ impl OllamaEmbedder {
}
}
/// Override Ollama's per-model `num_ctx` default. Ollama loads
/// embedding models with `num_ctx = 4096` (or whatever default the
/// model's modelfile carries) unless the request explicitly asks for
/// more. `bge-m3` natively supports 8192 tokens, and chunker-token
/// counts undercount BERT-WordPiece tokens by ~1.5-2× for HTML-derived
/// markdown — so a 1500-chunker-token chunk routinely produces 2500+
/// real tokens at embed time. Asking for 8192 unconditionally avoids
/// silent prompt truncation; on models that natively support less,
/// Ollama clamps `num_ctx` to the model's actual maximum, so this is
/// safe to over-request.
const EMBED_NUM_CTX: u32 = 8192;
#[derive(Serialize)]
struct EmbedRequest<'a> {
model: &'a str,
prompt: &'a str,
options: EmbedOptions,
}
#[derive(Serialize)]
struct EmbedOptions {
num_ctx: u32,
}
#[derive(Deserialize)]
@@ -127,6 +148,9 @@ impl Embedder for OllamaEmbedder {
let req = EmbedRequest {
model: &self.model,
prompt: text,
options: EmbedOptions {
num_ctx: EMBED_NUM_CTX,
},
};
let resp = self
.client
@@ -223,8 +247,9 @@ mod tests {
post(move |Json(body): Json<serde_json::Value>| {
let v = v_clone.clone();
async move {
assert_eq!(body["model"], "nomic-embed-text");
assert_eq!(body["model"], "bge-m3");
assert_eq!(body["prompt"], "hello world");
assert_eq!(body["options"]["num_ctx"], 8192);
Json(serde_json::json!({ "embedding": v }))
}
}),
@@ -262,7 +287,7 @@ mod tests {
let e = OllamaEmbedder::new(url, String::new(), 0);
let err = e.embed("hi").await.unwrap_err().to_string();
assert!(err.contains("3 dims"), "msg: {err}");
assert!(err.contains("expected 768"), "msg: {err}");
assert!(err.contains("expected 1024"), "msg: {err}");
}
#[tokio::test]