Skip to main content

sqlite_graphrag/constants/
embedding.rs

1//! Embedding dimensionality, batching and vector-cache tuning.
2//!
3//! Split out of the former single-file `constants.rs` in v1.2.5;
4//! every item is re-exported by the parent module, so `crate::constants::X`
5//! resolves exactly as before.
6
7/// Default embedding vector dimensionality for a NEWLY created database.
8///
9/// Sized for `qwen/qwen3-embedding-8b`, the model the OpenRouter REST backend
10/// uses today. Matryoshka Representation Learning (MRL, arXiv 2205.13147) lets
11/// a prefix of the native vector stand on its own, so 1024 is a real truncation
12/// point rather than a lossy resize.
13///
14/// This value governs `init` only. An existing database keeps the width
15/// recorded in `schema_meta.dim`, which [`crate::storage::connection`] adopts on
16/// every open — so raising this default can never silently reinterpret vectors
17/// already on disk. Widening a populated database is a deliberate migration
18/// that must re-embed every row; the previous default was 384, generated
19/// against `multilingual-e5-small`.
20///
21/// Precedence for the active dim is documented on [`embedding_dim`].
22pub const DEFAULT_EMBEDDING_DIM: usize = 1024;
23
24// `DEFAULT_QUERY_EMBED_TIMEOUT_SECS` lived here until v1.2.3, at 3 seconds.
25// Its doc justified the short budget with "dead OAuth falls back to FTS
26// quickly" — a chain the product no longer has. The mechanism that consumed it
27// (`apply_query_timeout_if_needed`, `with_timeout_secs`) went out with the
28// headless backends, leaving a constant nothing read and a documented XDG key
29// nothing honoured, while the operator kept being told a per-query budget
30// existed. There is now ONE embedding budget, resolved as
31// `--openrouter-timeout` > XDG `embedding.timeout_secs` >
32// `DEFAULT_EMBEDDING_HTTP_TIMEOUT_SECS`, and it governs reads and writes alike.
33
34/// Accepted range for any embedding dimensionality, override or recorded.
35///
36/// Declared once because the bound is checked on the CLI/XDG override, on the
37/// value adopted from `schema_meta`, and in the warning text. Three separate
38/// literals would be three chances to drift.
39pub const EMBEDDING_DIM_RANGE: std::ops::RangeInclusive<usize> = 8..=4096;
40
41/// Active embedding dimensionality for this process. `0` means unresolved.
42static ACTIVE_EMBEDDING_DIM: std::sync::atomic::AtomicUsize =
43    std::sync::atomic::AtomicUsize::new(0);
44
45/// Resolves the active embedding dimensionality (single source of truth).
46///
47/// Precedence (G-T-XDG-04):
48/// 1. CLI `--embedding-dim` / XDG `embedding.dim` via [`crate::runtime_config`];
49/// 2. the value recorded via [`set_active_embedding_dim`] — from `schema_meta`;
50/// 3. [`DEFAULT_EMBEDDING_DIM`].
51pub fn embedding_dim() -> usize {
52    if let Some(dim) = embedding_dim_from_runtime() {
53        return dim;
54    }
55    let active = ACTIVE_EMBEDDING_DIM.load(std::sync::atomic::Ordering::Acquire);
56    if active != 0 {
57        return active;
58    }
59    DEFAULT_EMBEDDING_DIM
60}
61
62/// Reads the CLI `--embedding-dim` flag or the XDG key `embedding.dim`.
63///
64/// Values outside [`EMBEDDING_DIM_RANGE`] are rejected with a warning rather
65/// than clamped: a clamped width would still mismatch the stored vectors, and
66/// `cosine_similarity` reports a dimension mismatch as `0.0` with no error, so
67/// the search would go quiet instead of failing.
68pub fn embedding_dim_from_runtime() -> Option<usize> {
69    let n = crate::runtime_config::embedding_dim_override()? as usize;
70    if EMBEDDING_DIM_RANGE.contains(&n) {
71        Some(n)
72    } else {
73        tracing::warn!(
74            value = n,
75            min = *EMBEDDING_DIM_RANGE.start(),
76            max = *EMBEDDING_DIM_RANGE.end(),
77            "embedding.dim override out of range; ignoring"
78        );
79        None
80    }
81}
82
83/// Records the dimensionality found in the opened database (`schema_meta.dim`).
84///
85/// Out-of-range values are ignored. A CLI flag or XDG override still wins over
86/// this value — see the precedence documented on [`embedding_dim`].
87pub fn set_active_embedding_dim(dim: usize) {
88    if EMBEDDING_DIM_RANGE.contains(&dim) {
89        ACTIVE_EMBEDDING_DIM.store(dim, std::sync::atomic::Ordering::Release);
90    }
91}
92
93/// Batch size for `fastembed` encoding calls.
94pub const FASTEMBED_BATCH_SIZE: usize = 32;
95
96/// GAP-SG-141 (B1): how many `ReEmbed` queue rows a single claim takes.
97///
98/// Deliberately aligned with the 32-item chunk width that
99/// [`crate::embedder::embed_passages_parallel_shared`] uses
100/// internally on the OpenRouter path: with 32 or fewer texts that function
101/// issues exactly ONE serial REST call, so one claim becomes one request.
102/// Raising this above 32 splits the claim into several requests again and
103/// buys nothing; the range clamp below still allows it for hosts that
104/// deliberately trade request count for claim overhead.
105///
106/// Override via XDG `enrich.reembed_claim_batch`.
107pub const DEFAULT_REEMBED_CLAIM_BATCH: usize = 32;
108
109/// Accepted range for `enrich.reembed_claim_batch`.
110///
111/// The floor of 1 degenerates to the historical one-row-per-claim behaviour.
112/// The ceiling bounds how many rows a single worker can strand in
113/// `processing` if it dies mid-batch.
114pub const REEMBED_CLAIM_BATCH_RANGE: std::ops::RangeInclusive<usize> = 1..=256;
115
116/// Prefix prepended to bodies before embedding as required by E5 models.
117pub const PASSAGE_PREFIX: &str = "passage: ";
118
119/// Prefix prepended to queries before embedding as required by E5 models.
120pub const QUERY_PREFIX: &str = "query: ";
121
122/// Maximum tokens accepted by an embedding input before chunking.
123pub const EMBEDDING_MAX_TOKENS: usize = 512;
124
125/// Maximum token count for a SINGLE embedding request input (GAP-SG-02).
126///
127/// The `qwen/qwen3-embedding-8b` model used by the OpenRouter backend accepts
128/// roughly 32K tokens of context. This ceiling rejects an input above a safe
129/// margin BEFORE the HTTP request, using the conservative cl100k_base proxy in
130/// [`crate::tokenizer::count_tokens`] (which emits at least as many tokens as
131/// Qwen for the same text). Distinct from [`EMBEDDING_MAX_TOKENS`] (512), which
132/// is the per-chunk ceiling that drives chunking.
133pub const EMBEDDING_REQUEST_MAX_TOKENS: usize = 30_000;
134
135/// Default total per-request budget, in seconds, for an OpenRouter embeddings
136/// HTTP call when neither `--openrouter-timeout` nor XDG
137/// `embedding.timeout_secs` supplies a value (GAP-SG-141 B3).
138///
139/// Deliberately far below the chat-side budget: an embeddings response is a
140/// fixed-size vector, so a call that has not returned within this window is
141/// stalled rather than slow.
142pub const DEFAULT_EMBEDDING_HTTP_TIMEOUT_SECS: u64 = 30;
143
144/// Lower bound on Tokio worker threads for the shared embedding runtime
145/// (GAP-SG-141 B2).
146///
147/// Two threads keep a blocking `block_on` caller from starving the reactor on a
148/// single-core host, which is the historical hard-coded value.
149pub const EMBED_RUNTIME_MIN_WORKER_THREADS: usize = 2;
150
151/// Upper bound on Tokio worker threads for the shared embedding runtime
152/// (GAP-SG-141 B2).
153///
154/// The runtime only drives HTTP polling, so worker threads past this point add
155/// scheduling overhead without adding throughput; the concurrency that matters
156/// is the request fan-out (`--rest-concurrency`, `--llm-parallelism`), not the
157/// reactor width.
158pub const EMBED_RUNTIME_MAX_WORKER_THREADS: usize = 8;
159
160/// DEFAULT entry ceiling for the process-wide entity-embedding cache.
161///
162/// The cache used to be an unbounded `HashMap`: a long `ingest` over a corpus
163/// with many distinct entity names grew it for the whole invocation with no
164/// eviction, so its memory was bounded only by the corpus. At 1024 dims one
165/// vector costs ~4 KiB, so 10 000 entries is ~40 MiB — the point where the
166/// cache stops paying for itself.
167///
168/// Read it through [`entity_embed_cache_max_entries`], never directly.
169pub const ENTITY_EMBED_CACHE_MAX_ENTRIES: usize = 10_000;
170
171/// Entity-cache entry ceiling: XDG `embedding.entity_cache_max_entries` or
172/// [`ENTITY_EMBED_CACHE_MAX_ENTRIES`]. `0` falls back to the default.
173pub fn entity_embed_cache_max_entries() -> usize {
174    crate::config::get_setting("embedding.entity_cache_max_entries")
175        .ok()
176        .flatten()
177        .and_then(|v| v.parse::<usize>().ok())
178        .filter(|n| *n > 0)
179        .unwrap_or(ENTITY_EMBED_CACHE_MAX_ENTRIES)
180}
181
182/// DEFAULT time-to-live, in seconds, of one entity-embedding cache entry.
183///
184/// A cached vector is only valid while the embedding model and dimensionality
185/// behind it are unchanged. The key already carries both, so the TTL guards the
186/// other axis: a long-running drain must not keep a vector alive for hours after
187/// its source text stopped being relevant.
188///
189/// Read it through [`entity_embed_cache_ttl_secs`], never directly.
190pub const ENTITY_EMBED_CACHE_TTL_SECS: u64 = 3_600;
191
192/// Entity-cache TTL in seconds: XDG `embedding.entity_cache_ttl_secs` or
193/// [`ENTITY_EMBED_CACHE_TTL_SECS`]. `0` falls back to the default.
194pub fn entity_embed_cache_ttl_secs() -> u64 {
195    crate::config::get_setting("embedding.entity_cache_ttl_secs")
196        .ok()
197        .flatten()
198        .and_then(|v| v.parse::<u64>().ok())
199        .filter(|n| *n > 0)
200        .unwrap_or(ENTITY_EMBED_CACHE_TTL_SECS)
201}
202
203/// Extra timeout, in seconds, granted per item beyond the first in a batched
204/// embedding call (GAP-4).
205///
206/// The base budget is the already-configurable `embedding.timeout_secs`; this
207/// is only how that budget scales with batch width, so it takes no key of its
208/// own — a second knob governing the same deadline would be two ways to say
209/// one thing.
210pub const EMBED_TIMEOUT_PER_EXTRA_BATCH_ITEM_SECS: u64 = 15;
211
212/// Pause, in milliseconds, before retrying a query embedding that lost the race
213/// for an LLM slot.
214///
215/// Long enough for a sibling invocation to finish and release its slot, short
216/// enough to stay inside the query path's own budget. Contention backoff, not a
217/// deadline, so it takes no XDG key.
218pub const EMBED_SLOT_RETRY_DELAY_MS: u64 = 750;
219
220/// Lowest REST fan-out width the batched passage embedder will use
221/// (v1.2.8, plan step 6).
222///
223/// One means serial: a single batch is one REST call, so the `JoinSet` would
224/// only add latency. Zero would mean "no worker", which is not a narrower
225/// fan-out but an absent one, so the floor is a refusal and not a preference.
226pub const MIN_EMBED_PASSAGE_FAN_OUT: usize = 1;
227
228/// Highest REST fan-out width the batched passage embedder will use
229/// (v1.2.8, plan step 6).
230///
231/// Kept inside the range Cloudflare tolerates in front of OpenRouter, and
232/// deliberately equal to [`crate::constants::MAX_ENRICH_REST_CONCURRENCY`]:
233/// both bound concurrent requests against the SAME host-scoped quota, whose key
234/// lives once in `~/.config/sqlite-graphrag/config.toml` and is spent by every
235/// folder on the machine. The joint ceiling from
236/// [`crate::constants::joint_parallelism_ceiling`] still applies on top, because
237/// only the PRODUCT of process count and per-process width describes the load.
238pub const MAX_EMBED_PASSAGE_FAN_OUT: usize = 16;