macrame/graph/vector_filter.rs
1//! Filtered vector search: strategies, the byte-budget cost model, and the
2//! planner that chooses between them (§5.3, D-007).
3//!
4//! A vector query rarely arrives naked. The caller wants the ten nearest
5//! neighbours of an embedding *among concepts reachable in two hops*, and the
6//! two access paths cannot be nested: the DiskANN index is opaque to SQL
7//! predicates, and the relational filter is opaque to the index. Composing them
8//! is a cost decision, and this module makes it arithmetically rather than by
9//! rule of thumb.
10//!
11//! # What changed in this cycle, and why the third strategy is gone
12//!
13//! §5.3 specified three strategies. `TwoPhaseTempTable` was to push the
14//! candidate id set into the vector query as an allow-list, staging it through a
15//! TEMP table. **Both of its mechanisms are absent from libSQL 0.9.30**, and
16//! both were measured rather than reasoned about:
17//!
18//! * `CREATE TEMP TABLE` on the read connection fails with `SQLITE_READONLY
19//! (8)`. `PRAGMA query_only = ON` (D-019) covers the TEMP database too, and
20//! D-019 is the runtime half of the write-serialization guarantee, so it is
21//! the strategy that gives way, not the pragma.
22//! * There is no allow-list to push into. `vector_top_k` refuses a fourth
23//! argument at runtime — *"too many arguments on vector_top_k() - max 3"* —
24//! and `vectorIndexSearch` in the bundled amalgamation rejects `argc != 3`
25//! before it looks at anything else.
26//!
27//! So the variant named an access path this engine does not offer, and the cost
28//! table priced an operation that cannot be issued. It is removed rather than
29//! kept as decoration: that is the precedent D-039 set with `louvain_communities`
30//! returning one community per node. If a future libSQL gains a constrained
31//! index walk, the variant comes back with a body, which is a smaller change
32//! than the confusion of shipping a name with nothing behind it.
33//!
34//! # Why the strategy can never change the answer
35//!
36//! `PostFilter` retrieves a generous `k′` from the index and then discards
37//! whatever fails the predicate. When the filter is tight the answer set falls
38//! off the end of `k′`, and the classic implementation returns four rows for a
39//! top-ten query without saying so. That is a silent wrong answer, which
40//! Doctrine II exists to prevent, so it is not merely documented here — it is
41//! detected. When a post-filtered pass comes back short *and* the underlying
42//! index scan was saturated, the planner cannot conclude the matches do not
43//! exist, so it escalates to the exact strategy and says so at `debug`.
44//!
45//! That gives the module its acceptance gate: the two strategies must agree, on
46//! every query, for every graph. Strategy is then a performance decision and
47//! nothing else, which is the only form in which a planner is safe.
48
49use crate::error::{DbError, Result};
50use crate::graph::builder::TraversalBuilder;
51use crate::vector::{declared_dimension, ModelName, VectorSearchResult};
52
53/// Strategy for combining vector search and graph traversal filters (§5.3).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[non_exhaustive]
56pub enum VectorFilterStrategy {
57 /// Vector top-k′ from the index first, then discard what fails the filter.
58 ///
59 /// Cheap when the filter is loose, because most of the k′ survives. Degrades
60 /// when it is tight, and [`FilteredVectorSearch`] escalates rather than
61 /// under-returning when it detects that it has.
62 PostFilter,
63 /// Candidate ids from the traversal first, then exact distances over just
64 /// those rows.
65 ///
66 /// Exact by construction — every candidate is scored, so nothing can fall
67 /// off the end of a k′. A brute-force scan over `F32_BLOB` with no index,
68 /// so it is priced by the candidate count.
69 PreFilterCTE,
70}
71
72/// What the counting probe learned about the candidate set.
73///
74/// The distinction is the point: SQLite has no histograms and `sqlite_stat1`
75/// carries average rows-per-key, which estimates an equality predicate and not
76/// multi-hop reachability. So the count is *measured*, by running the traversal
77/// under a cap — and a probe that hits its cap has not measured anything except
78/// that the set is too big to care about the exact size.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum CandidateCount {
82 /// The traversal returned this many ids, below the cap.
83 Exact(usize),
84 /// The probe hit its cap. The true count is at least this.
85 AtLeast(usize),
86}
87
88impl CandidateCount {
89 /// The number to compute with. For a capped probe this understates the true
90 /// count, which is the safe direction: it makes `PreFilterCTE` look cheaper
91 /// than it is, and `PreFilterCTE` is the exact strategy.
92 pub fn lower_bound(self) -> usize {
93 match self {
94 Self::Exact(n) | Self::AtLeast(n) => n,
95 }
96 }
97
98 pub fn is_capped(self) -> bool {
99 matches!(self, Self::AtLeast(_))
100 }
101}
102
103/// One planning decision, with the arithmetic that produced it (D-007).
104///
105/// Returned rather than only logged, so a caller — and more importantly a test —
106/// can assert on the choice instead of scraping `tracing` output.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct CostEstimate {
109 pub strategy: VectorFilterStrategy,
110 pub candidates: CandidateCount,
111 /// Bytes `PostFilter` is estimated to touch.
112 pub post_filter_bytes: usize,
113 /// Bytes `PreFilterCTE` is estimated to touch.
114 pub pre_filter_bytes: usize,
115 /// The inflated k′ `PostFilter` would request.
116 pub k_prime: usize,
117}
118
119/// Bytes of bookkeeping per candidate id carried through a filter pass.
120///
121/// A ULID is 26 characters and the `String` header is the rest. Deliberately an
122/// estimate of the payload, matching how `Subgraph::estimated_bytes` accounts
123/// for its own (D-047) — the two must use the same arithmetic or the budget
124/// means different things in different modules.
125const ID_BYTES: usize = 26 + std::mem::size_of::<String>();
126
127/// Bytes of a result row once materialized: the id plus the score.
128const ROW_BYTES: usize = ID_BYTES + std::mem::size_of::<f32>();
129
130/// Byte-budget cost model estimator for vector filter strategies (§5.3, D-007).
131///
132/// The 0.4.5–0.5.4 version of this type carried a `byte_budget` field it never
133/// read and branched on `candidate_count` against two hard-coded thresholds
134/// (500, 5000) — D-007's interface with none of D-007's mechanism. It now prices
135/// both strategies in bytes and takes the minimum, and `byte_budget` is a hard
136/// ceiling on the candidate set rather than an unused field.
137#[derive(Debug, Clone)]
138pub struct CostEstimator {
139 pub byte_budget: usize,
140 /// Corpus size: how many vectors the model holds. Sets selectivity, and so
141 /// the k′ inflation.
142 pub corpus: usize,
143 /// Bytes per stored vector, from the model's declared dimension (D-037).
144 pub vector_bytes: usize,
145}
146
147impl CostEstimator {
148 pub fn new(byte_budget: usize, corpus: usize, vector_bytes: usize) -> Self {
149 Self {
150 byte_budget,
151 corpus,
152 vector_bytes,
153 }
154 }
155
156 /// The k′ `PostFilter` must request to expect `k` survivors.
157 ///
158 /// Selectivity is `candidates / corpus`, so `k′ = k × corpus / candidates`.
159 /// Clamped to the corpus: asking the index for more rows than exist is not
160 /// an error but it is not an estimate either, and letting it run away makes
161 /// the cost comparison meaningless for tight filters — which is precisely
162 /// when the comparison matters.
163 pub fn k_prime(&self, k: usize, candidates: usize) -> usize {
164 if candidates == 0 || self.corpus == 0 {
165 return k;
166 }
167 let inflated = (k as u128 * self.corpus as u128) / candidates as u128;
168 (inflated.max(k as u128) as usize).min(self.corpus.max(k))
169 }
170
171 /// Price both strategies and take the minimum (§5.3, D-007).
172 ///
173 /// | Strategy | Estimated bytes |
174 /// |---|---|
175 /// | `PostFilter` | `k′ × (vector_bytes + row_bytes)` + the filter pass |
176 /// | `PreFilterCTE` | the filtered scan + `candidates × vector_bytes` |
177 ///
178 /// The filter pass is common to both — the traversal has to run either way —
179 /// so it appears in both rows and cancels out of the comparison. It is
180 /// included anyway, because the budget ceiling is checked against an absolute
181 /// figure and a cost model that omits a term it "knows" cancels is a cost
182 /// model that lies the moment someone adds a third strategy.
183 pub fn estimate(&self, k: usize, candidates: CandidateCount) -> Result<CostEstimate> {
184 let n = candidates.lower_bound();
185
186 // The hard ceiling of §5.4, applied to the candidate set regardless of
187 // strategy. A capped probe means the set is larger than this figure, so
188 // refusing on the lower bound is the conservative direction.
189 let candidate_bytes = n.saturating_mul(ID_BYTES);
190 if candidate_bytes > self.byte_budget {
191 return Err(DbError::SubgraphTooLarge {
192 n: candidate_bytes,
193 budget: self.byte_budget,
194 });
195 }
196
197 let filter_pass = candidate_bytes;
198 let k_prime = self.k_prime(k, n);
199
200 let post_filter_bytes = k_prime
201 .saturating_mul(self.vector_bytes.saturating_add(ROW_BYTES))
202 .saturating_add(filter_pass);
203 let pre_filter_bytes = n
204 .saturating_mul(self.vector_bytes.saturating_add(ROW_BYTES))
205 .saturating_add(filter_pass);
206
207 let strategy = if post_filter_bytes <= pre_filter_bytes {
208 VectorFilterStrategy::PostFilter
209 } else {
210 VectorFilterStrategy::PreFilterCTE
211 };
212
213 Ok(CostEstimate {
214 strategy,
215 candidates,
216 post_filter_bytes,
217 pre_filter_bytes,
218 k_prime,
219 })
220 }
221}
222
223/// Default ceiling on the candidate set, in bytes.
224pub const DEFAULT_BYTE_BUDGET: usize = 64 * 1024 * 1024;
225
226/// Default cap on the counting probe.
227///
228/// The probe costs a fraction of what it prices, and the cap is what bounds
229/// that fraction. Above it the planner knows only "more than the cap", which is
230/// already enough to reject `PreFilterCTE`.
231pub const DEFAULT_PROBE_CAP: usize = 10_000;
232
233/// A vector search restricted to the nodes a traversal reaches (§5.3).
234///
235/// Mirrors [`TraversalBuilder`], which is the crate's established shape for a
236/// read with options. The strategy is chosen by the planner and not by the
237/// caller: D-007's whole content is that the choice is arithmetic, and a
238/// parameter that lets a caller get it wrong would be a fidelity leak of the
239/// kind Doctrine VIII names. [`Self::strategy`] exists to force a strategy in
240/// tests — above all in the test that requires the two to agree.
241#[derive(Debug, Clone)]
242pub struct FilteredVectorSearch {
243 model: ModelName,
244 query: Vec<f32>,
245 top_k: usize,
246 traversal: TraversalBuilder,
247 forced: Option<VectorFilterStrategy>,
248 byte_budget: usize,
249 probe_cap: usize,
250}
251
252impl FilteredVectorSearch {
253 pub fn new(model: ModelName, query: Vec<f32>, traversal: TraversalBuilder) -> Self {
254 Self {
255 model,
256 query,
257 top_k: 10,
258 traversal,
259 forced: None,
260 byte_budget: DEFAULT_BYTE_BUDGET,
261 probe_cap: DEFAULT_PROBE_CAP,
262 }
263 }
264
265 pub fn top_k(mut self, k: usize) -> Self {
266 self.top_k = k;
267 self
268 }
269
270 pub fn byte_budget(mut self, budget: usize) -> Self {
271 self.byte_budget = budget;
272 self
273 }
274
275 pub fn probe_cap(mut self, cap: usize) -> Self {
276 self.probe_cap = cap;
277 self
278 }
279
280 /// Force a strategy, bypassing the planner. For tests and diagnosis.
281 pub fn strategy(mut self, strategy: VectorFilterStrategy) -> Self {
282 self.forced = Some(strategy);
283 self
284 }
285
286 /// Run the search, returning results and the plan that produced them.
287 ///
288 /// The estimate comes back so a caller can log it against reality, which is
289 /// D-007's empirical-tuning requirement. `execute` is the same call with the
290 /// plan dropped.
291 pub async fn execute_explained(
292 &self,
293 conn: &libsql::Connection,
294 now_ts: &str,
295 ) -> Result<(Vec<VectorSearchResult>, CostEstimate)> {
296 if self.top_k == 0 {
297 let estimate = CostEstimate {
298 strategy: VectorFilterStrategy::PreFilterCTE,
299 candidates: CandidateCount::Exact(0),
300 post_filter_bytes: 0,
301 pre_filter_bytes: 0,
302 k_prime: 0,
303 };
304 return Ok((Vec::new(), estimate));
305 }
306
307 // The probe is the traversal, run under a cap. It doubles as the
308 // candidate set: having paid for the walk, throwing the ids away and
309 // walking again for `PreFilterCTE` would be the cost model charging
310 // twice for what it priced once.
311 let (candidates, count) = self.probe(conn, now_ts).await?;
312
313 let dim = declared_dimension(conn, &self.model).await?;
314 let corpus = self.corpus_size(conn).await?;
315 let estimator = CostEstimator::new(self.byte_budget, corpus, dim * 4);
316 let mut estimate = estimator.estimate(self.top_k, count)?;
317
318 if let Some(forced) = self.forced {
319 estimate.strategy = forced;
320 }
321
322 tracing::debug!(
323 strategy = ?estimate.strategy,
324 candidates = candidates.len(),
325 capped = count.is_capped(),
326 k_prime = estimate.k_prime,
327 post_filter_bytes = estimate.post_filter_bytes,
328 pre_filter_bytes = estimate.pre_filter_bytes,
329 "filtered vector search plan"
330 );
331
332 if candidates.is_empty() {
333 return Ok((Vec::new(), estimate));
334 }
335
336 let results = match estimate.strategy {
337 VectorFilterStrategy::PreFilterCTE => self.run_pre_filter(conn, &candidates).await?,
338 VectorFilterStrategy::PostFilter => {
339 let (rows, saturated) = self
340 .run_post_filter(conn, &candidates, estimate.k_prime)
341 .await?;
342 // Short *and* saturated means the index scan ran out before the
343 // filter did, so the missing rows may exist and may be nearer
344 // than what came back. Escalate rather than under-return.
345 if rows.len() < self.top_k && saturated {
346 tracing::debug!(
347 got = rows.len(),
348 want = self.top_k,
349 k_prime = estimate.k_prime,
350 "post-filter saturated and short; escalating to PreFilterCTE"
351 );
352 self.run_pre_filter(conn, &candidates).await?
353 } else {
354 rows
355 }
356 }
357 };
358
359 Ok((results, estimate))
360 }
361
362 /// Run the search (§5.3).
363 pub async fn execute(
364 &self,
365 conn: &libsql::Connection,
366 now_ts: &str,
367 ) -> Result<Vec<VectorSearchResult>> {
368 Ok(self.execute_explained(conn, now_ts).await?.0)
369 }
370
371 /// The counting probe: the traversal, capped.
372 async fn probe(
373 &self,
374 conn: &libsql::Connection,
375 now_ts: &str,
376 ) -> Result<(Vec<String>, CandidateCount)> {
377 let mut ids = self.traversal.execute_ids(conn, now_ts).await?;
378 let count = if ids.len() > self.probe_cap {
379 ids.truncate(self.probe_cap);
380 CandidateCount::AtLeast(self.probe_cap)
381 } else {
382 CandidateCount::Exact(ids.len())
383 };
384 Ok((ids, count))
385 }
386
387 /// How many vectors the model holds.
388 ///
389 /// **`COUNT(*)` per query, deliberately, and measured before being left
390 /// alone (defect AF, Wave 3).** The objection was that D-007 argues strategy
391 /// choice should be arithmetic rather than a rule of thumb, and the
392 /// arithmetic's own input is O(corpus) while the thing it selects is not.
393 /// True in mechanism. Measured:
394 ///
395 /// ```text
396 /// corpus 2,000 vectors 5.2 µs
397 /// corpus 20,000 vectors 8.5 µs
398 /// whole filtered search 2.5 ms
399 /// ```
400 ///
401 /// Ten times the corpus costs 1.6 times the time, because ~4.9 µs of it is a
402 /// round trip and statement preparation — `declared_dimension`, which reads
403 /// one `PRAGMA`, costs 5.0 µs flat for the same reason. Extrapolated to §9's
404 /// stated 100K corpus that is ~22 µs against a 2.5 ms search: **under 1%.**
405 ///
406 /// So it is not cached, and the reason is worth stating because the
407 /// implementation plan proposed caching it on the grounds that "neither
408 /// `corpus_size` nor `declared_dimension` can change without DDL". That is
409 /// true of the dimension and **false of the count** — it changes on every
410 /// `upsert_embeddings`. Caching it would trade a real staleness bug for less
411 /// than one percent of a query. `declared_dimension` *is* DDL-fixed and
412 /// could be cached soundly; at 5 µs there is nothing to buy.
413 async fn corpus_size(&self, conn: &libsql::Connection) -> Result<usize> {
414 let sql = format!("SELECT COUNT(*) FROM {}", self.model.table());
415 let n: i64 = conn
416 .query(&sql, ())
417 .await?
418 .next()
419 .await?
420 .ok_or_else(|| DbError::ModelNotRegistered {
421 model: self.model.to_string(),
422 table: self.model.table(),
423 })?
424 .get(0)?;
425 Ok(n as usize)
426 }
427
428 /// Exact distances over the candidate rows, ordered, limited to `top_k`.
429 ///
430 /// **It joins `concepts` for the same reason `search_vector` does** (0.13.18,
431 /// W9.3, [D-191](../../docs/architecture/s13-decision-register.md#d-191)).
432 /// This is the *third* reader of an embedding table, and the plan that
433 /// closed F-31 named two — the argument for one predicate applied where the
434 /// join is holds here exactly, and without it the two strategies would
435 /// disagree about a retired concept, which is worse than both being wrong.
436 /// `the_strategy_never_changes_the_answer` is the gate that says so.
437 ///
438 /// No `k'` inflation is needed on this path: the filter and the `LIMIT` are
439 /// in one statement, so the limit already applies to survivors.
440 ///
441 /// **The instant comes from the traversal** (0.13.19, W9.4,
442 /// [D-192](../../docs/architecture/s13-decision-register.md#d-192)), and it
443 /// binds after the candidate chunk because the chunk is variadic and the
444 /// instant is not.
445 ///
446 /// Candidate ids are carried in the statement as bound parameters, never
447 /// spliced. A TEMP table would be the natural staging and is unavailable
448 /// under `PRAGMA query_only` (measured: `SQLITE_READONLY (8)`); a bound
449 /// placeholder list needs no write privilege at all, which makes the
450 /// reformulation strictly better than the mechanism it replaces rather than
451 /// a concession to it.
452 async fn run_pre_filter(
453 &self,
454 conn: &libsql::Connection,
455 candidates: &[String],
456 ) -> Result<Vec<VectorSearchResult>> {
457 let blob = self.encoded_query(conn).await?;
458 let at = self.traversal.as_of_valid.as_deref();
459 let mut out: Vec<VectorSearchResult> = Vec::new();
460
461 // SQLITE_MAX_VARIABLE_NUMBER bounds one statement's parameter count, so
462 // a candidate set larger than a chunk becomes several statements whose
463 // results are merged. The alternative — one statement with the ids
464 // interpolated — is the injection shape D-039 removed from the traversal
465 // CTE, and it is not reintroduced here for a read path either.
466 const IDS_PER_STATEMENT: usize = 500;
467 for chunk in candidates.chunks(IDS_PER_STATEMENT) {
468 let placeholders: Vec<String> =
469 (0..chunk.len()).map(|i| format!("?{}", i + 2)).collect();
470 let sql = format!(
471 "SELECT e.concept_id, vector_distance_cos(e.embedding, ?1)
472 FROM {table} AS e
473 JOIN concepts AS c ON c.id = e.concept_id
474 WHERE e.concept_id IN ({ids})
475 AND {visible}
476 ORDER BY 2 ASC
477 LIMIT {k}",
478 table = self.model.table(),
479 ids = placeholders.join(", "),
480 visible = crate::vector::search::visible_concept(at.map(|_| chunk.len() + 2)),
481 k = self.top_k,
482 );
483
484 let mut params: Vec<libsql::Value> = vec![blob.clone().into()];
485 params.extend(chunk.iter().map(|id| id.as_str().into()));
486 if let Some(t) = at {
487 params.push(t.into());
488 }
489
490 let mut rows = conn.query(&sql, params).await?;
491 while let Some(row) = rows.next().await? {
492 out.push(VectorSearchResult {
493 concept_id: row.get(0)?,
494 score: row.get::<f64>(1)? as f32,
495 });
496 }
497 }
498
499 // Each chunk was ordered and limited independently, so the merge has to
500 // reorder. Total ordering by score with the id as tie-break: two rows at
501 // an identical distance must not swap between runs, or the same query
502 // answers differently on two machines.
503 out.sort_by(|a, b| {
504 a.score
505 .partial_cmp(&b.score)
506 .unwrap_or(std::cmp::Ordering::Equal)
507 .then_with(|| a.concept_id.cmp(&b.concept_id))
508 });
509 out.truncate(self.top_k);
510 Ok(out)
511 }
512
513 /// Top-k′ from the index, then discard what the filter rejects.
514 ///
515 /// Returns the survivors and whether the index scan was *saturated* — it
516 /// returned every row it was asked for, so there were more it did not
517 /// return. Saturation is what makes a short result inconclusive.
518 ///
519 /// It passes the traversal's instant down for the same reason it passes the
520 /// model down: this arm's answer has to be the other arm's answer, and the
521 /// gate that says so is `a_validity_that_ended_is_invisible_to_the_
522 /// strategy_choice`.
523 ///
524 /// **It passes no half-life, and that is a decision** (0.13.20, W9.5,
525 /// [D-193](../../docs/architecture/s13-decision-register.md#d-193)). Decay
526 /// reorders whatever pool it is handed, and these two strategies do not
527 /// hold the same pool: this one gets the k' the cost model priced, while
528 /// `run_pre_filter` scores every candidate the traversal returned. Ranking
529 /// by age inside each would make the answer a function of the byte estimate
530 /// — which is the one thing [D-050](../../docs/architecture/s13-decision-register.md#d-050)
531 /// forbids, and the property that makes having a planner safe at all.
532 async fn run_post_filter(
533 &self,
534 conn: &libsql::Connection,
535 candidates: &[String],
536 k_prime: usize,
537 ) -> Result<(Vec<VectorSearchResult>, bool)> {
538 let hits = crate::vector::search_vector(
539 conn,
540 &self.query,
541 &self.model,
542 k_prime,
543 self.traversal.as_of_valid.as_deref(),
544 None,
545 )
546 .await?;
547 let saturated = hits.len() >= k_prime;
548
549 let allow: std::collections::HashSet<&str> =
550 candidates.iter().map(String::as_str).collect();
551 let mut out: Vec<VectorSearchResult> = hits
552 .into_iter()
553 .filter(|h| allow.contains(h.concept_id.as_str()))
554 .collect();
555 out.truncate(self.top_k);
556 Ok((out, saturated))
557 }
558
559 async fn encoded_query(&self, conn: &libsql::Connection) -> Result<Vec<u8>> {
560 let dim = declared_dimension(conn, &self.model).await?;
561 crate::vector::EmbeddingCodec::encode(&self.query, dim, self.model.as_str())
562 }
563}