Skip to main content

sqlite_graphrag/embedder/batch/
passages.rs

1//! Multi-passage embedding entry points.
2//!
3//! The single public way a caller hands a corpus of passages to the embedder:
4//! the shared `Arc` implementation that drives the OpenRouter REST batch API
5//! (v1.0.93 GAP-OR-INGEST / GAP-SG-147).
6
7use super::fan_out::{chunk_ranges, fan_out_chunk, reassemble_ordered};
8use crate::embedder::{
9    is_openrouter_initialized, shared_runtime, LlmBackendKind, OPENROUTER_CLIENT,
10};
11use crate::errors::AppError;
12use std::path::Path;
13use std::sync::Arc;
14use tokio::task::JoinSet;
15
16/// GAP-OPENROUTER-REST-CONCURRENCY: result of one bounded fan-out chunk —
17/// the chunk index paired with the batch embedding result, used to restore
18/// input order after out-of-order `JoinSet` completion.
19type EmbedChunkResult = (usize, Result<Vec<Vec<f32>>, AppError>);
20
21/// Embeds many passages with `EmbeddingBackendChoice` awareness (GAP-SG-147).
22///
23/// THIS IS THE ONLY MULTI-PASSAGE ENTRY POINT. v1.0.93 (GAP-OR-INGEST): when
24/// the resolved chain starts with `OpenRouter` and the client is initialised,
25/// it uses the HTTP batch API (`embed_batch`) — no LLM slot consumed, ~200ms
26/// per batch.
27///
28/// # Why the corpus arrives as an `Arc<[String]>`
29///
30/// A BORROWED slice cannot be handed to the `'static` fan-out tasks, so an
31/// entry point taking `&[String]` has to clone the entire corpus on every
32/// call: a 36k-passage backfill copies every string before a single request
33/// leaves the process. That borrowed-slice shim existed until v1.2.8 and was
34/// removed; this signature is the reason it was never needed.
35///
36/// Taking ownership through an `Arc<[String]>` lets the OpenRouter fan-out
37/// hand each task a refcount bump plus an index range instead of a cloned
38/// `Vec<String>` per chunk. `Arc::from(vec)` MOVES the string buffers into the
39/// `Arc` allocation — only the 24-byte headers are memcpy'd, never the heap
40/// data — so the same 36k-text backfill copies nothing. Callers that hold a
41/// `Vec<String>` pay one `Arc::from(vec)` and are done.
42///
43/// Chunk boundaries and ordering are unchanged: chunk `i` still covers
44/// `[i * chunk, min((i + 1) * chunk, len))` and `reassemble_ordered` still
45/// sorts on that same index.
46///
47/// # Why `local_batch_size` reaches only ONE branch
48///
49/// The name is deliberate: this value governs the LOCAL (subprocess) branch and
50/// is IGNORED under OpenRouter, which sizes its requests from XDG
51/// `embedding.batch_size` through `fan_out_chunk`. That is not an oversight,
52/// and "fixing" it would be a regression.
53///
54/// `adaptive_batch_for_dim`, which produces the value callers pass
55/// here, was calibrated against SUBPROCESS backends. Its failure mode is an LLM
56/// completing a prompt and truncating the JSON reply: at dim 384 with a fixed
57/// batch of 8, claude returned 3 of 8 items and codex timed out at 300s.
58/// Shrinking the batch as dimensionality grows is what keeps that from
59/// happening.
60///
61/// The REST path cannot fail that way. OpenRouter exposes a native batch
62/// embedding API whose response is structured API JSON, not a model completion,
63/// so there is no token budget to truncate.
64///
65/// The cost of unifying them is concrete: `adaptive_batch_for_dim(8, 1024)`
66/// resolves to `1` at this project's active dimensionality. Letting the
67/// dim-adaptive value win on the REST path would collapse every request to a
68/// single text and destroy the 32x batching win of GAP-SG-141.
69///
70/// `openrouter_branch_ignores_local_batch_size` in this module's tests fails if
71/// the OpenRouter branch ever starts reading this parameter.
72pub fn embed_passages_parallel_shared(
73    _models_dir: &Path,
74    texts: Arc<[String]>,
75    parallelism: usize,
76    _local_batch_size: usize,
77    backends: crate::cli::BackendChoice,
78) -> Result<Vec<Vec<f32>>, AppError> {
79    let crate::cli::BackendChoice {
80        llm: llm_backend,
81        embedding: embedding_backend,
82    } = backends;
83    let texts: &Arc<[String]> = &texts;
84    let chain = embedding_backend.to_chain(llm_backend);
85    if chain.first() == Some(&LlmBackendKind::OpenRouter) && is_openrouter_initialized() {
86        let client = OPENROUTER_CLIENT.get().ok_or_else(|| {
87            AppError::Embedding(
88                crate::i18n::validation::embedding_openrouter_client_not_initialised(),
89            )
90        })?;
91
92        // GAP-OPENROUTER-REST-CONCURRENCY: reuse the caller's `parallelism`
93        // as a bounded fan-out width, clamped to a Cloudflare-safe range.
94        // Small inputs stay serial — a single batch is one REST call, so the
95        // JoinSet overhead would only add latency.
96        // The joint cap also applies here: `--max-concurrency` bounds how many
97        // CLI processes run, this bounds how wide each one fans out, and only
98        // their PRODUCT describes the load on the host.
99        let k = parallelism
100            .clamp(
101                crate::constants::MIN_EMBED_PASSAGE_FAN_OUT,
102                crate::constants::MAX_EMBED_PASSAGE_FAN_OUT,
103            )
104            .min(crate::constants::joint_parallelism_ceiling())
105            .max(crate::constants::MIN_EMBED_PASSAGE_FAN_OUT);
106        // Same knob as the fan-out slice: a corpus that fits in ONE request has
107        // nothing to fan out, so the JoinSet would only add latency. Using a
108        // literal here meant a lowered `embedding.batch_size` still sent short
109        // corpora down the serial path, where the inner chunking then issued
110        // several SEQUENTIAL requests instead of parallel ones.
111        let chunk = fan_out_chunk();
112        if texts.len() <= chunk || k == 1 {
113            let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
114            // GAP-001 (v1.1.04): canonical nested-runtime guard.
115            // GAP-SG-270: preserve the origin-computed retry verdict instead of
116            // letting `?` unwrap it away through `From<EmbedError>`.
117            let vecs = match tokio::runtime::Handle::try_current() {
118                Ok(handle) => tokio::task::block_in_place(|| {
119                    handle.block_on(client.embed_batch(&refs, client.default_input_type()))
120                })
121                .map_err(crate::embedder::app_error_preserving_retry_class)?,
122                Err(_) => shared_runtime()?
123                    .block_on(client.embed_batch(&refs, client.default_input_type()))
124                    .map_err(crate::embedder::app_error_preserving_retry_class)?,
125            };
126            return Ok(vecs);
127        }
128
129        // `client` is a `&'static OpenRouterClient` (OPENROUTER_CLIENT is a
130        // static OnceLock), so it is Copy + Send + 'static and moves freely
131        // into each spawned task.
132        //
133        // GAP-SG-147: each task used to receive `chunk.to_vec()`, an owned
134        // copy of its slice, purely to satisfy the `'static` bound on
135        // `JoinSet::spawn`. Summed over the disjoint chunks that copied the
136        // entire corpus once per call. Now the task captures an `Arc` clone
137        // (a refcount bump) plus the chunk's index range and slices the shared
138        // allocation itself, so nothing is copied.
139        //
140        // GAP-001 (v1.1.04): canonical nested-runtime guard. The async block
141        // borrows `client`, `texts` and `k`, all of which remain valid for
142        // both branches.
143        let fan_out = async move {
144            let mut set: JoinSet<EmbedChunkResult> = JoinSet::new();
145            let mut parts: Vec<(usize, Vec<Vec<f32>>)> = Vec::new();
146
147            for (idx, range) in chunk_ranges(texts.len(), chunk).enumerate() {
148                if set.len() >= k {
149                    if let Some(joined) = set.join_next().await {
150                        let (cidx, res) = joined.map_err(|e| {
151                            AppError::Embedding(crate::i18n::validation::embedding_task_join_error(
152                                e,
153                            ))
154                        })?;
155                        parts.push((cidx, res?));
156                    }
157                }
158                let shared = Arc::clone(texts);
159                set.spawn(async move {
160                    let refs: Vec<&str> =
161                        shared[range.clone()].iter().map(|s| s.as_str()).collect();
162                    // GAP-SG-270: `EmbedChunkResult` carries `AppError`, and the
163                    // fan-out keeps the origin-computed `retry_class` inside it
164                    // so the enrich re-embed queue still reads the verdict.
165                    let r = client
166                        .embed_batch(&refs, client.default_input_type())
167                        .await
168                        .map_err(crate::embedder::app_error_preserving_retry_class);
169                    (idx, r)
170                });
171            }
172
173            while let Some(joined) = set.join_next().await {
174                let (cidx, res) = joined.map_err(|e| {
175                    AppError::Embedding(crate::i18n::validation::embedding_task_join_error(e))
176                })?;
177                parts.push((cidx, res?));
178            }
179
180            Ok::<Vec<Vec<f32>>, AppError>(reassemble_ordered(parts))
181        };
182        let vecs = match tokio::runtime::Handle::try_current() {
183            Ok(handle) => tokio::task::block_in_place(|| handle.block_on(fan_out))?,
184            Err(_) => shared_runtime()?.block_on(fan_out)?,
185        };
186        Ok(vecs)
187    } else {
188        Err(AppError::Embedding(
189            crate::i18n::validation::embedding_openrouter_client_not_initialised(),
190        ))
191    }
192}