Skip to main content

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