Make the embedding model swappable via env for A/B testing

Trialing Qwen3-Embedding-0.6B (1024-dim, instruct-prefixed queries)
against nomic required code changes at every hardcoded seam; now it's a
config flip plus a reembed_embeddings run.

- EMBEDDING_DIM env (default 768) replaces every hardcoded dim check:
  daily summary / calendar / search / location DAOs, Ollama batch
  validation, reembed_embeddings
- entities gains the dim guard it never had — a wrong-dim vector
  silently kills dedup/recall (cosine over mismatched lengths is 0),
  so store None and warn instead
- embed_query / embed_document split with EMBED_QUERY_PREFIX /
  EMBED_DOCUMENT_PREFIX (literal \n expanded): retrieval models treat
  the two sides differently — nomic wants search_query:/search_document:,
  Qwen3 wants Instruct:...\nQuery: on queries only. All query-side
  call sites and all corpus writers now declare their side.
- document the contract in CLAUDE.md: change the model or any of these
  vars → re-run reembed_embeddings or search is garbage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-06-11 21:40:40 -04:00
parent b1493f5aca
commit efd05db523
12 changed files with 159 additions and 67 deletions
+8
View File
@@ -645,6 +645,14 @@ OPENROUTER_APP_TITLE=ImageApi # Optional attribution header
# re-embedding — mixed vector spaces break similarity search. # re-embedding — mixed vector spaces break similarity search.
LLM_BACKEND=ollama LLM_BACKEND=ollama
# Embedding model contract. Corpus and queries must be embedded by the same
# model with matching prefixes — after changing the embed model or any of
# these, run `cargo run --bin reembed_embeddings` (all tables) or search is
# garbage. Prefix values may contain a literal \n (expanded to a newline).
EMBEDDING_DIM=768 # 768 = nomic-embed-text v1.5; 1024 = Qwen3-Embedding-0.6B
EMBED_QUERY_PREFIX= # nomic: "search_query: " | Qwen3: "Instruct: <task>\nQuery: "
EMBED_DOCUMENT_PREFIX= # nomic: "search_document: " | Qwen3: leave empty
# llama.cpp / llama-swap (used when LLM_BACKEND=llamacpp). OpenAI-compatible # llama.cpp / llama-swap (used when LLM_BACKEND=llamacpp). OpenAI-compatible
# proxy hosting one or more llama-server processes. Chat models receive # proxy hosting one or more llama-server processes. Chat models receive
# images directly via content-parts (all models assumed vision-capable). # images directly via content-parts (all models assumed vision-capable).
+33 -15
View File
@@ -535,7 +535,7 @@ impl InsightGenerator {
// (`LLM_BACKEND` switch). Must match the backend that populated the // (`LLM_BACKEND` switch). Must match the backend that populated the
// daily-summary embeddings or similarity search will be garbage. // daily-summary embeddings or similarity search will be garbage.
let query_embedding = let query_embedding =
crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), &query).await?; crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query).await?;
// Search for similar daily summaries with time-based weighting // Search for similar daily summaries with time-based weighting
// This prioritizes summaries temporally close to the query date // This prioritizes summaries temporally close to the query date
@@ -601,7 +601,7 @@ impl InsightGenerator {
// Must use the same backend that populated the daily-summary // Must use the same backend that populated the daily-summary
// embeddings or similarity search is garbage (see embed_one docs). // embeddings or similarity search is garbage (see embed_one docs).
let query_embedding = let query_embedding =
crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), query).await?; crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), query).await?;
let mut summary_dao = self let mut summary_dao = self
.daily_summary_dao .daily_summary_dao
@@ -687,7 +687,7 @@ impl InsightGenerator {
let calendar_cx = parent_cx.with_span(span); let calendar_cx = parent_cx.with_span(span);
let query_embedding = if let Some(loc) = location { let query_embedding = if let Some(loc) = location {
match crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), loc).await { match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), loc).await {
Ok(emb) => Some(emb), Ok(emb) => Some(emb),
Err(e) => { Err(e) => {
log::warn!("Failed to generate embedding for location '{}': {}", loc, e); log::warn!("Failed to generate embedding for location '{}': {}", loc, e);
@@ -859,7 +859,8 @@ impl InsightGenerator {
}; };
let query_embedding = let query_embedding =
match crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), &query_text).await { match crate::ai::embed_query(&self.ollama, self.llamacpp.as_deref(), &query_text).await
{
Ok(emb) => emb, Ok(emb) => emb,
Err(e) => { Err(e) => {
log::warn!("Failed to generate search embedding: {}", e); log::warn!("Failed to generate search embedding: {}", e);
@@ -2942,17 +2943,34 @@ Return ONLY the summary, nothing else."#,
// Generate embedding for name + description (best-effort) via the // Generate embedding for name + description (best-effort) via the
// configured local backend. // configured local backend.
let embed_text = format!("{} {}", name, description); let embed_text = format!("{} {}", name, description);
let embedding: Option<Vec<u8>> = let embedding: Option<Vec<u8>> = match crate::ai::embed_document(
match crate::ai::embed_one(&self.ollama, self.llamacpp.as_deref(), &embed_text).await { &self.ollama,
Ok(vec) => { self.llamacpp.as_deref(),
let bytes: Vec<u8> = vec.iter().flat_map(|f| f.to_le_bytes()).collect(); &embed_text,
Some(bytes) )
} .await
Err(e) => { {
log::warn!("Embedding generation failed for entity '{}': {}", name, e); // The entities table has no dim check at the DAO layer, and a
None // wrong-dim vector silently kills dedup/recall (cosine over
} // mismatched lengths is 0) — guard here, store None instead.
}; Ok(vec) if vec.len() == crate::ai::embedding_dim() => {
let bytes: Vec<u8> = vec.iter().flat_map(|f| f.to_le_bytes()).collect();
Some(bytes)
}
Ok(vec) => {
log::warn!(
"Entity '{}' embedding has {} dims (expected {}) — storing without embedding",
name,
vec.len(),
crate::ai::embedding_dim()
);
None
}
Err(e) => {
log::warn!("Embedding generation failed for entity '{}': {}", name, e);
None
}
};
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
let insert = InsertEntity { let insert = InsertEntity {
+8 -6
View File
@@ -43,14 +43,16 @@ impl LocalLlm {
) )
} }
/// Embed one string via the `LLM_BACKEND`-selected client. /// Embed a search query (applies `EMBED_QUERY_PREFIX`). Callers must
pub async fn embed(&self, text: &str) -> Result<Vec<f32>> { /// pick query vs document — retrieval models treat the two sides
super::embed_one(&self.ollama, self.llamacpp.as_deref(), text).await /// differently and an unmarked embed invites prefix-mismatch bugs.
pub async fn embed_query(&self, text: &str) -> Result<Vec<f32>> {
super::embed_query(&self.ollama, self.llamacpp.as_deref(), text).await
} }
/// Embed a batch via the `LLM_BACKEND`-selected client. /// Embed corpus text (applies `EMBED_DOCUMENT_PREFIX`).
pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> { pub async fn embed_document(&self, text: &str) -> Result<Vec<f32>> {
super::embed_many(&self.ollama, self.llamacpp.as_deref(), texts).await super::embed_document(&self.ollama, self.llamacpp.as_deref(), text).await
} }
/// Single-shot local text generation via the `LLM_BACKEND`-selected /// Single-shot local text generation via the `LLM_BACKEND`-selected
+51
View File
@@ -75,6 +75,57 @@ pub fn local_backend_is_llamacpp() -> bool {
) )
} }
/// Expected embedding dimensionality, env-overridable via `EMBEDDING_DIM`
/// (default 768, nomic-embed-text). Every store/query dim check reads this —
/// swapping to a different-dim model (e.g. Qwen3-Embedding-0.6B at 1024) is
/// then a config flip plus a `reembed_embeddings` run, not a code change.
/// Cached for the process lifetime; a flip requires a restart anyway since
/// the corpus must be re-embedded with it.
pub fn embedding_dim() -> usize {
static DIM: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*DIM.get_or_init(|| {
std::env::var("EMBEDDING_DIM")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(768)
})
}
/// Read an embedding prefix from the environment. `.env` values can't hold
/// real newlines, so a literal `\n` in the value is expanded — Qwen3-style
/// query instructions need one ("Instruct: ...\nQuery: ").
fn embed_prefix(key: &str) -> String {
std::env::var(key)
.map(|v| v.replace("\\n", "\n"))
.unwrap_or_default()
}
/// Embed a search query. Applies `EMBED_QUERY_PREFIX` (default empty) —
/// retrieval models distinguish query-side from document-side text:
/// nomic v1.5 wants `search_query: `, Qwen3-Embedding wants
/// `Instruct: <task>\nQuery: `. Must pair with the document prefix the
/// corpus was embedded with or similarity degrades.
pub async fn embed_query(
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
text: &str,
) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_QUERY_PREFIX"), text);
embed_one(ollama, llamacpp, &prefixed).await
}
/// Embed corpus text (the stored side of retrieval). Applies
/// `EMBED_DOCUMENT_PREFIX` (default empty; nomic v1.5 wants
/// `search_document: `, Qwen3-Embedding wants none).
pub async fn embed_document(
ollama: &OllamaClient,
llamacpp: Option<&LlamaCppClient>,
text: &str,
) -> anyhow::Result<Vec<f32>> {
let prefixed = format!("{}{}", embed_prefix("EMBED_DOCUMENT_PREFIX"), text);
embed_one(ollama, llamacpp, &prefixed).await
}
/// Embed a batch of strings via the configured local backend. Routes /// Embed a batch of strings via the configured local backend. Routes
/// through llama-swap when `LLM_BACKEND=llamacpp` (and a client is /// through llama-swap when `LLM_BACKEND=llamacpp` (and a client is
/// configured), else Ollama. See [`local_backend_is_llamacpp`] for the /// configured), else Ollama. See [`local_backend_is_llamacpp`] for the
+5 -4
View File
@@ -1046,13 +1046,14 @@ Analyze the image and use specific details from both the visual content and the
} }
}; };
// Validate embedding dimensions (should be 768 for nomic-embed-text:v1.5) // Validate embedding dimensions (EMBEDDING_DIM; 768 for nomic-embed-text:v1.5)
for (i, embedding) in embeddings.iter().enumerate() { for (i, embedding) in embeddings.iter().enumerate() {
if embedding.len() != 768 { if embedding.len() != crate::ai::embedding_dim() {
log::warn!( log::warn!(
"Unexpected embedding dimensions for item {}: {} (expected 768)", "Unexpected embedding dimensions for item {}: {} (expected {})",
i, i,
embedding.len() embedding.len(),
crate::ai::embedding_dim()
); );
} }
} }
+2 -1
View File
@@ -87,7 +87,8 @@ async fn main() -> Result<()> {
); );
match tokio::task::block_in_place(|| { match tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async { llm.embed(&text).await }) tokio::runtime::Handle::current()
.block_on(async { llm.embed_document(&text).await })
}) { }) {
Ok(emb) => Some(emb), Ok(emb) => Some(emb),
Err(e) => { Err(e) => {
+1 -1
View File
@@ -64,7 +64,7 @@ async fn main() -> Result<()> {
async move { async move {
let mut embeddings = Vec::new(); let mut embeddings = Vec::new();
for query in &queries { for query in &queries {
match llm.embed(query).await { match llm.embed_document(query).await {
Ok(emb) => embeddings.push(Some(emb)), Ok(emb) => embeddings.push(Some(emb)),
Err(e) => { Err(e) => {
pb_for_warn.println(format!("embedding failed for '{}': {}", query, e)); pb_for_warn.println(format!("embedding failed for '{}': {}", query, e));
+8 -7
View File
@@ -141,7 +141,7 @@ async fn embed_with_truncation(llm: &LocalLlm, text: &str) -> Result<(Vec<f32>,
let mut text = text.to_string(); let mut text = text.to_string();
let mut truncated = false; let mut truncated = false;
loop { loop {
match llm.embed(&text).await { match llm.embed_document(&text).await {
Ok(emb) => return Ok((emb, truncated)), Ok(emb) => return Ok((emb, truncated)),
Err(e) Err(e)
if e.to_string().contains("too large to process") && text.chars().count() > 64 => if e.to_string().contains("too large to process") && text.chars().count() > 64 =>
@@ -194,14 +194,15 @@ async fn reembed_table(
} }
}; };
// The whole pipeline (DAO checks, stored corpora) assumes 768 dims. // The whole pipeline (DAO checks, stored corpora) assumes
// A different dim means the active backend is not serving a // EMBEDDING_DIM dims. A mismatch means the active embed slot is not
// nomic-compatible model — stop rather than corrupt the table. // serving the configured model — stop rather than corrupt the table.
anyhow::ensure!( anyhow::ensure!(
new_emb.len() == 768, new_emb.len() == image_api::ai::embedding_dim(),
"backend returned {}-dim embedding (expected 768) — '{}' is not \ "backend returned {}-dim embedding (expected {}) — '{}' does not \
serving a nomic-embed-text-v1.5-compatible model", match the configured EMBEDDING_DIM",
new_emb.len(), new_emb.len(),
image_api::ai::embedding_dim(),
llm.embedding_model_version() llm.embedding_model_version()
); );
+13 -10
View File
@@ -222,11 +222,12 @@ impl CalendarEventDao for SqliteCalendarEventDao {
// Validate embedding dimensions if provided // Validate embedding dimensions if provided
if let Some(ref emb) = event.embedding if let Some(ref emb) = event.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
emb.len() emb.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -293,7 +294,7 @@ impl CalendarEventDao for SqliteCalendarEventDao {
for event in events { for event in events {
// Validate embedding if provided // Validate embedding if provided
if let Some(ref emb) = event.embedding if let Some(ref emb) = event.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
log::warn!( log::warn!(
"Skipping event with invalid embedding dimensions: {}", "Skipping event with invalid embedding dimensions: {}",
@@ -385,10 +386,11 @@ impl CalendarEventDao for SqliteCalendarEventDao {
trace_db_call(context, "query", "find_similar_events", |_span| { trace_db_call(context, "query", "find_similar_events", |_span| {
let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao"); let mut conn = self.connection.lock().expect("Unable to get CalendarEventDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -461,10 +463,11 @@ impl CalendarEventDao for SqliteCalendarEventDao {
// Step 2: If query embedding provided, rank by semantic similarity // Step 2: If query embedding provided, rank by semantic similarity
if let Some(query_emb) = query_embedding { if let Some(query_emb) = query_embedding {
if query_emb.len() != 768 { if query_emb.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_emb.len() query_emb.len(),
crate::ai::embedding_dim()
)); ));
} }
+12 -9
View File
@@ -150,10 +150,11 @@ impl DailySummaryDao for SqliteDailySummaryDao {
.expect("Unable to get DailySummaryDao"); .expect("Unable to get DailySummaryDao");
// Validate embedding dimensions // Validate embedding dimensions
if summary.embedding.len() != 768 { if summary.embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
summary.embedding.len() summary.embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -202,10 +203,11 @@ impl DailySummaryDao for SqliteDailySummaryDao {
trace_db_call(context, "query", "find_similar_summaries", |_span| { trace_db_call(context, "query", "find_similar_summaries", |_span| {
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -299,10 +301,11 @@ impl DailySummaryDao for SqliteDailySummaryDao {
trace_db_call(context, "query", "find_similar_summaries_with_time_weight", |_span| { trace_db_call(context, "query", "find_similar_summaries_with_time_weight", |_span| {
let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao"); let mut conn = self.connection.lock().expect("Unable to get DailySummaryDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
+5 -4
View File
@@ -216,11 +216,12 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
// Validate embedding dimensions if provided (rare for location data) // Validate embedding dimensions if provided (rare for location data)
if let Some(ref emb) = location.embedding if let Some(ref emb) = location.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
emb.len() emb.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -292,7 +293,7 @@ impl LocationHistoryDao for SqliteLocationHistoryDao {
for location in locations { for location in locations {
// Validate embedding if provided (rare) // Validate embedding if provided (rare)
if let Some(ref emb) = location.embedding if let Some(ref emb) = location.embedding
&& emb.len() != 768 && emb.len() != crate::ai::embedding_dim()
{ {
log::warn!( log::warn!(
"Skipping location with invalid embedding dimensions: {}", "Skipping location with invalid embedding dimensions: {}",
+13 -10
View File
@@ -189,10 +189,11 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.expect("Unable to get SearchHistoryDao"); .expect("Unable to get SearchHistoryDao");
// Validate embedding dimensions (REQUIRED for searches) // Validate embedding dimensions (REQUIRED for searches)
if search.embedding.len() != 768 { if search.embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid embedding dimensions: {} (expected 768)", "Invalid embedding dimensions: {} (expected {})",
search.embedding.len() search.embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -245,7 +246,7 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
conn.transaction::<_, anyhow::Error, _>(|conn| { conn.transaction::<_, anyhow::Error, _>(|conn| {
for search in searches { for search in searches {
// Validate embedding (REQUIRED) // Validate embedding (REQUIRED)
if search.embedding.len() != 768 { if search.embedding.len() != crate::ai::embedding_dim() {
log::warn!( log::warn!(
"Skipping search with invalid embedding dimensions: {}", "Skipping search with invalid embedding dimensions: {}",
search.embedding.len() search.embedding.len()
@@ -325,10 +326,11 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
.lock() .lock()
.expect("Unable to get SearchHistoryDao"); .expect("Unable to get SearchHistoryDao");
if query_embedding.len() != 768 { if query_embedding.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_embedding.len() query_embedding.len(),
crate::ai::embedding_dim()
)); ));
} }
@@ -406,10 +408,11 @@ impl SearchHistoryDao for SqliteSearchHistoryDao {
// Step 2: If query embedding provided, rank by semantic similarity // Step 2: If query embedding provided, rank by semantic similarity
if let Some(query_emb) = query_embedding { if let Some(query_emb) = query_embedding {
if query_emb.len() != 768 { if query_emb.len() != crate::ai::embedding_dim() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Invalid query embedding dimensions: {} (expected 768)", "Invalid query embedding dimensions: {} (expected {})",
query_emb.len() query_emb.len(),
crate::ai::embedding_dim()
)); ));
} }