sqlite_graphrag/constants/search.rs
1//! Retrieval tuning and the agent-native surface vocabulary.
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/// Jaccard threshold above which two memories are considered fuzzy duplicates.
8pub const DEDUP_FUZZY_THRESHOLD: f64 = 0.8;
9
10/// Cosine distance threshold below which two memories are semantic duplicates.
11pub const DEDUP_SEMANTIC_THRESHOLD: f32 = 0.1;
12
13/// Maximum number of hops allowed in graph traversals.
14pub const MAX_GRAPH_HOPS: u32 = 2;
15
16/// Minimum relationship weight required for traversal inclusion.
17pub const MIN_RELATION_WEIGHT: f64 = 0.3;
18
19/// Default traversal depth for `related` when `--hops` is omitted.
20pub const DEFAULT_MAX_HOPS: u32 = 2;
21
22/// Default minimum weight filter applied during graph traversal.
23pub const DEFAULT_MIN_WEIGHT: f64 = 0.3;
24
25/// Default weight assigned to newly created relationships.
26pub const DEFAULT_RELATION_WEIGHT: f64 = 0.5;
27
28/// Default `k` used by `recall` when the caller omits `--k`.
29pub const DEFAULT_K_RECALL: usize = 10;
30
31/// Default `k` for memory KNN searches when the caller omits `--k`.
32pub const K_MEMORIES_DEFAULT: usize = 10;
33
34/// Default `k` for entity KNN searches during graph expansion.
35pub const K_ENTITIES_SEARCH: usize = 5;
36
37/// Default `k` constant used by Reciprocal Rank Fusion in `hybrid-search`.
38pub const RRF_K_DEFAULT: u32 = 60;
39
40/// Maximum result count from the recursive graph CTE in `recall`.
41pub const K_GRAPH_MATCHES_LIMIT: usize = 20;
42
43/// Default `--limit` for `list` in a HUMAN format, when the caller omits it.
44///
45/// Bounds the text rendering only. Under `--format json` an omitted `--limit`
46/// means the whole corpus, because a machine consumer that asked for no ceiling
47/// must not silently receive a page — that asymmetry is the whole of GAP-SG-201.
48///
49/// Declared as 100 until v1.2.7 and referenced by nothing, while `list` carried
50/// a bare `50` in its body: a constant that documented a default the code did
51/// not use is worse than no constant, since it invites a reader to trust it.
52pub const K_LIST_TEXT_DEFAULT_LIMIT: usize = 50;
53
54/// Default `--limit` for `graph entities` when omitted.
55pub const K_GRAPH_ENTITIES_DEFAULT_LIMIT: usize = 50;
56
57/// Default `--limit` for `related` when omitted.
58///
59/// Same value as [`DEFAULT_K_RECALL`], which `related` used until v1.2.7 — a
60/// borrowed name that tied this command's default to `recall`'s `-k` by accident
61/// rather than by intent. Tuning one would silently have moved the other.
62pub const K_RELATED_DEFAULT_LIMIT: usize = 10;
63
64/// Default `--limit` for `history` when omitted.
65pub const K_HISTORY_DEFAULT_LIMIT: usize = 20;
66
67/// Maximum edges pulled when `deep-research` expands the graph around its hits.
68pub const K_DEEP_RESEARCH_GRAPH_EDGES_LIMIT: usize = 50;
69
70/// Default weight for the vector contribution in the `hybrid-search` RRF formula.
71pub const WEIGHT_VEC_DEFAULT: f64 = 1.0;
72
73/// Default weight for the BM25 text contribution in the `hybrid-search` RRF formula.
74pub const WEIGHT_FTS_DEFAULT: f64 = 1.0;
75
76/// GAP-SG-142: envelope members searched, in order, for the primary result
77/// array reshaped by [`crate::agent_surface`].
78///
79/// The list is ordered from most to least specific so an envelope that carries
80/// several arrays (for example `recall`, which exposes `direct_matches`,
81/// `graph_matches` and the merged `results`) is reshaped on the member callers
82/// actually consume. A payload matching none of these falls back to its first
83/// array member.
84///
85/// `nodes` precedes `entities` because the `graph` envelope carries both and
86/// `nodes` is the canonical one there; `entities` is its v1.0.66 alias and is
87/// listed in [`AGENT_SURFACE_ALIAS_ARRAYS`]. Reshaping the alias while leaving
88/// the canonical member untouched is precisely the failure that table closes.
89/// `types` is last and is the only member here named after what it holds rather
90/// than after its role. It belongs to `graph entity-types` (v1.2.8), whose whole
91/// envelope is that one array; without the entry the fallback still elected it,
92/// but as a guess, and `--select type` was then resolved against the top-level
93/// members and refused with a suggestion identical to what the caller typed.
94pub const AGENT_SURFACE_RESULT_KEYS: &[&str] = &[
95 "results", "items", "nodes", "entities", "memories", "hits", "rows", "matches", "data", "types",
96];
97
98/// GAP-SG-142: derived result arrays suppressed once the agent-native surface
99/// reshapes their canonical source.
100///
101/// Each entry is `(subcommand, canonical member, members that merely restate
102/// it)`: `list` clones `items` into `memories`, `graph export` clones `nodes`
103/// into `entities`, `recall` publishes `results` as the concatenation of
104/// `direct_matches` and `graph_matches`, and `related` clones `results` into
105/// `related_memories`.
106///
107/// The subcommand is part of the key because "derived" is a property of one
108/// command's envelope, not of a member name. `results` means a concatenation in
109/// `recall` and a clone in `related`, and in `hybrid-search` it means neither:
110/// there `graph_expansion` skips every id already present in `results`, so
111/// `results` and `graph_matches` are DISJOINT and carry different types
112/// (`HybridSearchItem` against `RecallItem`). Matching on the member name alone
113/// deleted a set no other member restated — and one that
114/// `docs/schemas/hybrid-search.schema.json` lists under `required`, so the
115/// suppression produced an envelope invalid against this project's own schema.
116/// `hybrid-search` is absent from this table by construction, which is what
117/// keeps that from happening again.
118///
119/// Suppression only removes members that are actually present, so a declared
120/// member the envelope never carried is a silent no-op and is never reported as
121/// removed.
122///
123/// The surface reshapes exactly one array per envelope, so leaving a genuinely
124/// derived member in place shipped the unfiltered, unsorted, unprojected copy
125/// right next to the shaped one — the redundancy the projection exists to
126/// remove, and a meta record (`sort`, `output_count`) that contradicted half the
127/// payload. Those are therefore dropped whenever a knob is set.
128///
129/// Without any knob the surface is a no-op and nothing is removed, so the public
130/// v1.0.66 alias contract stays intact byte for byte for every existing caller.
131pub const AGENT_SURFACE_ALIAS_ARRAYS: &[(&str, &str, &[&str])] = &[
132 ("list", "items", &["memories"]),
133 ("graph", "nodes", &["entities"]),
134 ("recall", "results", &["direct_matches", "graph_matches"]),
135 ("related", "results", &["related_memories"]),
136];
137
138/// GAP-SG-230: field spellings the agent-native surface treats as ONE key.
139///
140/// Each entry pairs a SCOPE with a synonym group: every spelling in the group
141/// names the same field, so a caller that asks for any member is asking for
142/// whichever member the payload actually carries. Read the applicable groups
143/// through [`agent_surface_field_synonym_groups`], never directly.
144///
145/// The first group is the entity type. `graph entities`,
146/// `memory-entities`, `read --with-graph` and `deep-research.graph_context` emit
147/// it as `entity_type`; `graph --format json` and `graph --format ndjson` emit it
148/// as `type` (`NodeOut` and `NdjsonNode` both carry
149/// `#[serde(rename = "type")] r#type`). A caller that learned the spelling on one
150/// surface got `unresolved_keys: ["entity_type"]`, `vocabulary_partial: true` and
151/// `exit 0` on its sibling — a silent miss, which is the failure class the whole
152/// agent-native gate exists to remove. The INPUT side already closed this
153/// asymmetry: `src/storage/entities/mod.rs` declares
154/// `#[serde(alias = "type")] pub entity_type`, and
155/// `docs/schemas/entities-input.schema.json` documents `type` as a synonym in
156/// prose. This table is the output half of that same contract.
157///
158/// # GAP-SG-274: the scope column
159///
160/// The first member of each entry lists the
161/// [`crate::cli::Commands::agent_surface_slug`] values the group applies to; an
162/// EMPTY list means "every command", which is what the entity-type group needs
163/// since `memory-entities`, `read` and `deep-research` report no slug at all.
164///
165/// The column exists because one group is true of a command in one output mode
166/// and false of the same command in another. `kind` is that group. In `NodeOut`
167/// (the json snapshot) `kind: String` is the deprecated alias of the entity
168/// type — `src/commands/graph_export/tests.rs` asserts
169/// `json["kind"] == json["type"]`. In `NdjsonNode` (the ndjson stream)
170/// `kind: &'static str` is the LINE DISCRIMINATOR, valued `"node"`, `"edge"` or
171/// `"summary"`. Declaring it a synonym for BOTH would make `--filter
172/// kind=concept` reach edge and summary lines, and `--select type` answer
173/// `"edge"` for an edge — the mistake [`AGENT_SURFACE_ALIAS_ARRAYS`] narrates for
174/// member names, repeated one layer down on field names.
175///
176/// Until the slug distinguished the two modes there was no way to say "here and
177/// not there", so `kind` was excluded from the table ENTIRELY and the caller who
178/// spelled the entity type the way the json snapshot spells it got a silent miss
179/// on every sibling surface. Now `agent_surface_slug` reports `graph-ndjson` for
180/// the stream and `graph` for every other form of the command — the same
181/// distinction [`crate::cli::Commands::streams`] computes — so the group is
182/// declared exactly where it holds: under `graph`, never under `graph-ndjson`.
183pub const AGENT_SURFACE_FIELD_SYNONYMS: &[(&[&str], &[&str])] = &[
184 (&[], &["entity_type", "type"]),
185 (&["graph"], &["kind", "entity_type", "type"]),
186];
187
188/// GAP-SG-274: the synonym groups that apply to `command`, in table order.
189///
190/// `command` is the slug [`crate::cli::Commands::agent_surface_slug`] reported
191/// for this invocation, or `None` when the surface was never told which
192/// subcommand emitted the envelope. `None` selects the unscoped groups alone,
193/// which is the fail-safe reading: a command that does not identify itself gets
194/// the synonyms that are true everywhere and none of the ones that are true only
195/// somewhere.
196pub fn agent_surface_field_synonym_groups(
197 command: Option<&str>,
198) -> impl Iterator<Item = &'static [&'static str]> + '_ {
199 AGENT_SURFACE_FIELD_SYNONYMS
200 .iter()
201 .filter(move |(scope, _)| {
202 scope.is_empty() || command.is_some_and(|slug| scope.contains(&slug))
203 })
204 .map(|(_, group)| *group)
205}
206
207/// DEFAULT cap on `hybrid-search --with-graph` graph matches.
208///
209/// ACTIVE by default, unlike the `recall` flag of the same name, which defaults
210/// to unbounded. `hybrid-search` had no cap at all: `graph_expansion` walks
211/// outward from the fused results AND from the five entities nearest the query
212/// embedding, then materialises every memory it reaches with a 300-character
213/// snippet each. A `--k 3` query over a dense neighbourhood measured a 1 112 925
214/// byte envelope — the caller asked for three results and got a megabyte.
215///
216/// A finite default is the only honest shape here: the flag caps a set the
217/// caller never sized, so leaving it unbounded means the envelope is bounded by
218/// the graph rather than by the request. 50 keeps a genuinely useful
219/// neighbourhood while holding the envelope in the tens of kilobytes.
220///
221/// Read it through [`hybrid_search_max_graph_results`], never directly.
222pub const DEFAULT_HYBRID_MAX_GRAPH_RESULTS: usize = 50;
223
224/// Graph-match ceiling for `hybrid-search`: the `--max-graph-results` flag, then
225/// XDG `search.hybrid.max_graph_results`, then
226/// [`DEFAULT_HYBRID_MAX_GRAPH_RESULTS`].
227///
228/// `0` disables the cap at either layer, which is how a caller opts back into
229/// the unbounded pre-v1.2.2 envelope. Returns `None` for that case so the
230/// traversal loop can skip the check entirely.
231pub fn hybrid_search_max_graph_results(flag: Option<usize>) -> Option<usize> {
232 let resolved = flag
233 .or_else(|| {
234 crate::config::get_setting("search.hybrid.max_graph_results")
235 .ok()
236 .flatten()
237 .and_then(|v| v.parse::<usize>().ok())
238 })
239 .unwrap_or(DEFAULT_HYBRID_MAX_GRAPH_RESULTS);
240 (resolved > 0).then_some(resolved)
241}
242
243/// Elements sampled when the surface builds the key vocabulary for a SUGGESTION.
244///
245/// GAP-SG-202: this bounds the suggestion only, never the resolution. Deciding
246/// whether a requested key exists scans every element, because the scan is a
247/// pointer walk per element with no allocation and a wrong `absent` verdict
248/// would refuse a legitimate request. Listing the alternatives is the expensive
249/// half — it collects names — and it only runs once a key has already failed,
250/// so a sample is enough to name a near miss.
251pub const K_VOCABULARY_SAMPLE_ELEMENTS: usize = 64;
252
253/// Hard ceiling on distinct key names collected for a suggestion.
254///
255/// The envelope is caller-influenced, so the collector is a public parser: the
256/// memory rules forbid sizing an allocation from untrusted input without a
257/// ceiling. Reaching it costs a shorter suggestion list, never a refusal.
258pub const K_VOCABULARY_MAX_KEYS: usize = 512;
259
260/// Alternatives named in a refusal message.
261///
262/// Three is what a caller can act on at a glance; a longer list reads as a dump
263/// of the schema rather than as a correction.
264pub const K_VOCABULARY_MAX_SUGGESTIONS: usize = 3;
265
266/// Jaro-Winkler similarity below which a candidate is not offered as a fix.
267///
268/// Jaro-Winkler rather than plain edit distance because it rewards a shared
269/// prefix, and a mistyped key name almost always keeps its prefix — `body_length`
270/// against `body`, `entity_type` against `entity`.
271pub const VOCABULARY_SUGGESTION_MIN_SIMILARITY: f64 = 0.6;
272
273/// Default cap on emitted result elements (`--max-items`). `0` means no cap,
274/// preserving the pre-GAP-SG-142 envelope byte for byte.
275pub const DEFAULT_AGENT_SURFACE_MAX_ITEMS: usize = 0;
276
277/// Default cap on string length in characters (`--truncate-content`).
278/// `0` disables content truncation.
279pub const DEFAULT_AGENT_SURFACE_TRUNCATE_CONTENT: usize = 0;
280
281/// Default cap on the serialized envelope in bytes (`--max-output-bytes`).
282/// `0` disables the ceiling.
283pub const DEFAULT_AGENT_SURFACE_MAX_OUTPUT_BYTES: usize = 0;
284
285/// Inclusive upper bound for `-k`/`--k` on every retrieval command.
286///
287/// Kept at the historical `sqlite-vec` knn ceiling so the message an operator
288/// gets does not change: values above it used to surface a leaky engine error
289/// (`k value in knn query too large, provided 10000 and the limit is 4096`).
290pub const K_QUERY_RANGE_MAX: usize = 4_096;
291
292/// Inclusive upper bound for `--limit` on commands that page over stored rows.
293///
294/// Separate from [`K_QUERY_RANGE_MAX`] because `export --limit` ships a default
295/// of 100_000, so the retrieval ceiling would be a breaking change there. These
296/// limits reach SQLite as a `LIMIT` clause, where the row count bounds the work
297/// no matter what the operator asks for; the ceiling exists to reject absurd
298/// input at parse time rather than to protect memory.
299pub const K_LIST_LIMIT_MAX: usize = 1_000_000;
300
301/// Inclusive upper bound for `--max-hops` and `--depth` on graph traversal.
302///
303/// The breadth-first walks carry visited sets, so a huge value terminates at
304/// the graph diameter rather than running away. The bound is here to keep the
305/// surface honest, and because a request for more than sixty-four hops is a
306/// typo in every real corpus.
307pub const K_MAX_HOPS_CEILING: u32 = 64;
308
309/// Inclusive upper bound for `deep-research --max-sub-queries`.
310///
311/// Unlike the other ceilings this one guards spend, not memory: each sub-query
312/// is a separate REST round trip, so an unbounded value bills the operator for
313/// an unbounded fan-out.
314pub const K_MAX_SUB_QUERIES_CEILING: usize = 64;