sqlite_graphrag/embedder/passage.rs
1//! Passage/query embedding entry points.
2
3use super::*;
4use crate::errors::AppError;
5use crate::extract::llm_embedding::LlmEmbedding;
6use parking_lot::Mutex;
7use std::path::Path;
8
9/// Embeds a single passage for storage. Delegates to the configured LLM
10/// headless (claude code / codex). Returns a vector of the active
11/// dimensionality.
12pub fn embed_passage(embedder: &Mutex<LlmEmbedding>, text: &str) -> Result<Vec<f32>, AppError> {
13 let client = apply_query_timeout_if_needed(clone_client(embedder));
14 let result = client.embed_passage(text)?;
15 validate_dim(result)
16}
17
18/// Embeds a single query for similarity search. Same model and dim as
19/// `embed_passage`; the only difference is the LLM-side prompt prefix
20/// that the headless invocation uses to disambiguate.
21pub fn embed_query(embedder: &Mutex<LlmEmbedding>, text: &str) -> Result<Vec<f32>, AppError> {
22 let client = apply_query_timeout_if_needed(clone_client(embedder));
23 let result = client.embed_query(text)?;
24 validate_dim(result)
25}
26
27/// Embeds a batch of passages with token-count-aware batching.
28///
29/// Kept for API compatibility; since v1.0.79 it routes through the
30/// bounded parallel fan-out with conservative defaults.
31pub fn embed_passages_controlled(
32 embedder: &Mutex<LlmEmbedding>,
33 texts: &[&str],
34 _token_counts: &[usize],
35) -> Result<Vec<Vec<f32>>, AppError> {
36 if texts.is_empty() {
37 return Ok(Vec::new());
38 }
39 let owned: Vec<String> = texts.iter().map(|t| t.to_string()).collect();
40 embed_texts_parallel(embedder, &owned, 1, chunk_embed_batch_size())
41}
42
43/// Embed passage local.
44pub fn embed_passage_local(models_dir: &Path, text: &str) -> Result<Vec<f32>, AppError> {
45 let _slot_guard = acquire_llm_slot_for_embedding()?;
46 let embedder = get_embedder(models_dir)?;
47 embed_passage(embedder, text)
48}
49
50/// v1.0.89 (BUG-SKIP-EMBED): reads `--skip-embedding-on-failure` / runtime_config
51/// (flag > XDG; product env is not read).
52/// Returns `true` when the user opted to persist with NULL embedding on failure.
53pub fn should_skip_embedding_on_failure() -> bool {
54 crate::runtime_config::skip_embedding_on_failure()
55}
56
57/// v1.0.89 (BUG-SKIP-EMBED + GAP-EMBED-PROPAGATION): embed a passage
58/// honouring both `--llm-backend` and `--skip-embedding-on-failure`.
59///
60/// On success returns `Ok(Some(vec))`. On failure:
61/// - if `--skip-embedding-on-failure` is active, logs a warning and returns `Ok(None)`
62/// - otherwise propagates the error (exit 11)
63pub fn embed_passage_or_skip(
64 models_dir: &Path,
65 text: &str,
66 choice: Option<crate::cli::LlmBackendChoice>,
67) -> Result<Option<Vec<f32>>, AppError> {
68 match embed_passage_with_choice(models_dir, text, choice) {
69 Ok((v, _backend)) => Ok(Some(v)),
70 Err(AppError::Validation(msg)) => Err(AppError::Validation(msg)),
71 Err(e) => {
72 if should_skip_embedding_on_failure() {
73 tracing::warn!(
74 error = %e,
75 "embedding failed but --skip-embedding-on-failure is active; persisting with NULL embedding"
76 );
77 Ok(None)
78 } else {
79 Err(e)
80 }
81 }
82 }
83}
84
85/// BUG-003 / v1.0.85: split of `embed_passage_local` that reports the
86/// resolved [`LlmBackendKind`] based on the ACTUAL
87/// [`LlmEmbedding::flavour`] of the embedder constructed. When
88/// `LlmEmbedding::detect_available` substitutes claude for a missing
89/// codex, the operator sees the truth in `envelope.backend_invoked`.
90pub fn embed_passage_local_resolved(
91 models_dir: &Path,
92 text: &str,
93) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
94 let _slot_guard = acquire_llm_slot_for_embedding()?;
95 let embedder = get_embedder(models_dir)?;
96 let v = embed_passage(embedder, text)?;
97 let kind = match embedder.lock().flavour() {
98 crate::extract::llm_embedding::EmbeddingFlavour::Codex => LlmBackendKind::Codex,
99 crate::extract::llm_embedding::EmbeddingFlavour::Claude => LlmBackendKind::Claude,
100 crate::extract::llm_embedding::EmbeddingFlavour::Opencode => LlmBackendKind::Opencode,
101 };
102 Ok((v, kind))
103}
104
105/// Embed query local.
106pub fn embed_query_local(models_dir: &Path, text: &str) -> Result<Vec<f32>, AppError> {
107 let _slot_guard = acquire_llm_slot_for_embedding()?;
108 let embedder = get_embedder(models_dir)?;
109 embed_query(embedder, text)
110}
111
112// =============================================================================
113// v1.0.82 (GAP-003): wrappers que aceitam a escolha do CLI
114// (`crate::cli::LlmBackendChoice`) e a traduzem em uma chain para
115// `embed_with_fallback`. Centralizam a propagação do flag `--llm-backend`
116// nos 6 comandos que produzem embedding (`remember`, `edit`, `ingest`,
117// `enrich`, `recall`, `hybrid-search`).
118// =============================================================================
119
120/// Embed a single passage using the LLM backend selected by the user via
121/// `--llm-backend`. Routes to `embed_with_fallback` so failures fall
122/// through to the next backend in the chain before giving up.
123///
124/// When `choice` is `None` (e.g. a sub-command that does not yet
125/// expose the flag), behaviour matches `embed_passage_local` — the
126/// active embedder from `LlmEmbedding::detect_available` decides the
127/// backend.
128pub fn embed_passage_with_choice(
129 models_dir: &Path,
130 text: &str,
131 choice: Option<crate::cli::LlmBackendChoice>,
132) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
133 let _slot_guard = acquire_llm_slot_for_embedding()?;
134 match choice {
135 None => {
136 let embedder = get_embedder(models_dir)?;
137 embed_passage(embedder, text).map(|v| (v, LlmBackendKind::None))
138 }
139 Some(choice) => embed_with_fallback(models_dir, text, &choice.to_chain(), false),
140 }
141}
142
143/// v1.0.93: embedding with `EmbeddingBackendChoice` awareness. When the
144/// embedding backend is `Openrouter` or `Auto` with a live client, the
145/// chain prepends `OpenRouter` before the LLM subprocess backends.
146pub fn embed_passage_with_embedding_choice(
147 models_dir: &Path,
148 text: &str,
149 embedding_backend: crate::cli::EmbeddingBackendChoice,
150 llm_backend: crate::cli::LlmBackendChoice,
151) -> Result<(Vec<f32>, LlmBackendKind), AppError> {
152 let _slot_guard = acquire_llm_slot_for_embedding()?;
153 let chain = embedding_backend.to_chain(llm_backend);
154 embed_with_fallback(models_dir, text, &chain, false)
155}
156
157/// failure, returns a structured `FallbackReason` so the caller can
158/// surface `vec_degraded` instead of a hard exit 11.
159///
160/// `None` matches the legacy `try_embed_query_with_fallback` path
161/// (uses the active embedder without an explicit chain).
162pub fn try_embed_query_with_choice(
163 models_dir: &Path,
164 text: &str,
165 choice: Option<crate::cli::LlmBackendChoice>,
166) -> Result<(Vec<f32>, LlmBackendKind), FallbackReason> {
167 match with_query_embed_fast(|| embed_passage_with_choice(models_dir, text, choice)) {
168 // GAP-004 / v1.0.85.1: when the chain terminates on
169 // (i.e. user passed
170 // or every preceding backend failed), returns
171 // instead of an error. Without this guard the
172 // empty vector would propagate to which
173 // aborts with exit 11 ("embedding has 0 dims, expected 64").
174 // The caller's contract is to surface a typed
175 // so and can route to FTS5-puro via
176 // the existing / envelope.
177 // Intercept the empty-vector success path and surface it as
178 // (introduced at v1.0.85 / ADR-0043
179 // for the symmetric LLM-returned-zero-dim case).
180 Ok((v, _backend)) if v.is_empty() => Err(FallbackReason::DimZero),
181 Ok((v, backend)) => Ok((v, backend)),
182 Err(e) => Err(classify_embedding_error(e)),
183 }
184}
185/// v1.0.93 (GAP-OR-INGEST): query embedding with `EmbeddingBackendChoice`
186/// awareness. Mirrors `try_embed_query_with_choice` but routes through
187/// `embed_passage_with_embedding_choice` so OpenRouter API is used when
188/// configured.
189pub fn try_embed_query_with_embedding_choice(
190 models_dir: &Path,
191 text: &str,
192 embedding_backend: crate::cli::EmbeddingBackendChoice,
193 llm_backend: crate::cli::LlmBackendChoice,
194) -> Result<(Vec<f32>, LlmBackendKind), FallbackReason> {
195 match with_query_embed_fast(|| {
196 embed_passage_with_embedding_choice(models_dir, text, embedding_backend, llm_backend)
197 }) {
198 Ok((v, _backend)) if v.is_empty() => Err(FallbackReason::DimZero),
199 Ok((v, backend)) => Ok((v, backend)),
200 Err(e) => Err(classify_embedding_error(e)),
201 }
202}
203
204/// call. Reads max-concurrency from `--llm-max-host-concurrency` /
205/// XDG `llm.max_host_concurrency` (default derived from `LLM_WORKER_RSS_MB`
206/// and available memory), and the wait timeout from XDG
207/// `llm.slot_wait_secs` (default 30s).
208///
209/// Returns `Ok(guard)` for happy path, `AppError::LockBusy` (exit 75)
210/// when no slot is available within the wait window, and
211/// `AppError::Validation` when the concurrency is 0.
212///
213/// Tests may force fail-fast via XDG/runtime slot wait of 0.
214pub(crate) fn acquire_llm_slot_for_embedding() -> Result<crate::llm_slots::LlmSlotGuard, AppError> {
215 use crate::constants::{CLI_LOCK_DEFAULT_WAIT_SECS, LLM_WORKER_RSS_MB};
216 let default_max = crate::llm_slots::default_max_concurrency() as usize;
217 let max = crate::runtime_config::llm_max_host_concurrency(default_max).max(1) as u32;
218 let wait_secs = if crate::runtime_config::llm_slot_no_wait() {
219 0
220 } else {
221 crate::runtime_config::llm_slot_wait_secs(CLI_LOCK_DEFAULT_WAIT_SECS)
222 };
223 let _ = LLM_WORKER_RSS_MB; // silence the unused import (used in default_max_concurrency)
224 // GAP-003 / ADR-0043: when the slot semaphore is contended beyond the
225 // backoff window (50 + 100 + 200 + 400 = 750ms total), return a
226 // marker message that `classify_embedding_error` maps to
227 // `FallbackReason::SlotExhausted` (discriminator `slot_exhausted`).
228 // The window is shorter than the legacy 30s timeout, so the operator
229 // observes FTS5-puro fallback quickly instead of after 30s of silence.
230 match crate::llm_slots::acquire_llm_slot(max, wait_secs) {
231 Ok(guard) => Ok(guard),
232 Err(e @ AppError::LockBusy { .. }) if wait_secs > 0 => Err(AppError::Embedding(
233 crate::i18n::validation::embedding_slot_exhausted(&e),
234 )),
235 Err(e) => Err(e),
236 }
237}