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}