Skip to main content

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