Skip to main content

nodedb_physical/physical_plan/
text.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Full-text search operations dispatched to the Data Plane.
4
5use nodedb_types::text_search::QueryMode;
6use nodedb_types::{QualifiedCollection, SurrogateBitmap};
7
8/// One per-row BM25 score column: `bm25_score(field, query)` under `alias`.
9#[derive(
10    Debug,
11    Clone,
12    PartialEq,
13    serde::Serialize,
14    serde::Deserialize,
15    zerompk::ToMessagePack,
16    zerompk::FromMessagePack,
17)]
18pub struct TextScoreSpec {
19    /// `None` reads the whole-document index.
20    pub field: Option<String>,
21    pub query: String,
22    /// Boolean combination of the query terms.
23    pub mode: QueryMode,
24    /// Fuzzy (Levenshtein) fallback for a term with no exact posting.
25    pub fuzzy: bool,
26    /// Output column the score lands in. A row the scoped index holds but
27    /// the query does not match carries `0.0` there. A row the index does
28    /// not hold carries `null`.
29    pub alias: String,
30}
31
32/// The order a bounded score scan keeps its best rows in: one score column.
33#[derive(
34    Debug,
35    Clone,
36    PartialEq,
37    serde::Serialize,
38    serde::Deserialize,
39    zerompk::ToMessagePack,
40    zerompk::FromMessagePack,
41)]
42pub struct ScoreScanOrder {
43    /// Alias of the score column the rows are ordered by.
44    pub alias: String,
45    pub ascending: bool,
46    /// Whether `null` scores sort before every number.
47    pub nulls_first: bool,
48}
49
50/// The row bound of a score scan.
51#[derive(
52    Debug,
53    Clone,
54    PartialEq,
55    serde::Serialize,
56    serde::Deserialize,
57    zerompk::ToMessagePack,
58    zerompk::FromMessagePack,
59)]
60pub struct ScoreScanBound {
61    /// Rows the scan returns at most.
62    pub rows: usize,
63    /// `Some`: the scan returns the first `rows` rows in this order. `None`:
64    /// any `rows` admitted rows.
65    pub order: Option<ScoreScanOrder>,
66}
67
68/// Full-text search physical operations.
69#[derive(
70    Debug,
71    Clone,
72    PartialEq,
73    serde::Serialize,
74    serde::Deserialize,
75    zerompk::ToMessagePack,
76    zerompk::FromMessagePack,
77)]
78pub enum TextOp {
79    /// BM25 full-text search on the inverted index.
80    Search {
81        collection: QualifiedCollection,
82        /// Field index the query reads. `None` reads the whole-document index.
83        field: Option<String>,
84        query: String,
85        /// Hits returned, best first. `usize::MAX` returns every match.
86        top_k: usize,
87        /// Boolean combination of the query terms.
88        mode: QueryMode,
89        /// Enable fuzzy matching (Levenshtein) for typo tolerance.
90        fuzzy: bool,
91        /// Pre-computed bitmap of eligible surrogates (from prefilter evaluation).
92        /// `None` = no prefilter; all postings are eligible.
93        prefilter: Option<SurrogateBitmap>,
94        /// Residual WHERE predicates (serialized `Vec<ScanFilter>`). They
95        /// restrict candidates before ranking, so `top_k` counts only rows
96        /// that satisfy them.
97        filters: Vec<u8>,
98        /// RLS filters (serialized `Vec<ScanFilter>`). Like `filters`, they
99        /// restrict candidates before ranking, so `top_k` counts only rows
100        /// the policy admits.
101        rls_filters: Vec<u8>,
102        /// Score columns injected into each hit.
103        scores: Vec<TextScoreSpec>,
104    },
105
106    /// Every row `filters` admit, each with its score columns.
107    ///
108    /// The physical plan for `bm25_score(field, term)` with no `text_match`:
109    /// every admitted row is present. A row the score's index holds but its
110    /// query does not match carries `0.0`. A row the index does not hold
111    /// carries `null`.
112    BM25ScoreScan {
113        collection: QualifiedCollection,
114        /// Residual WHERE predicates (serialized `Vec<ScanFilter>`).
115        filters: Vec<u8>,
116        /// RLS filters (serialized `Vec<ScanFilter>`). A row that fails one
117        /// is dropped.
118        rls_filters: Vec<u8>,
119        /// Score columns injected into each row.
120        scores: Vec<TextScoreSpec>,
121        /// The query's LIMIT pushed into the scan. `None` returns every
122        /// admitted row. The relational tail still applies its own ORDER BY
123        /// and LIMIT over the rows returned.
124        bound: Option<ScoreScanBound>,
125    },
126
127    /// Exact phrase search: all terms must appear consecutively in the document.
128    ///
129    /// Unlike `Search` (BM25 scoring), phrase search returns only documents
130    /// where the query terms appear as an exact contiguous sequence. Scoring
131    /// is positional: documents with the phrase closer to the start rank higher.
132    PhraseSearch {
133        collection: QualifiedCollection,
134        /// Field index the phrase reads. `None` reads the whole-document index.
135        field: Option<String>,
136        /// The phrase words as written, in order. The Data Plane analyzes
137        /// them once with the collection's analyzer and matches the tokens
138        /// as a contiguous sequence.
139        terms: Vec<String>,
140        /// Hits returned, best first. `usize::MAX` returns every match.
141        top_k: usize,
142        /// Pre-computed bitmap of eligible surrogates (from prefilter evaluation).
143        prefilter: Option<nodedb_types::SurrogateBitmap>,
144        /// Residual WHERE predicates (serialized `Vec<ScanFilter>`), applied
145        /// before ranking.
146        filters: Vec<u8>,
147        /// RLS filters (serialized `Vec<ScanFilter>`), applied before
148        /// ranking.
149        rls_filters: Vec<u8>,
150        /// Score columns injected into each hit.
151        scores: Vec<TextScoreSpec>,
152    },
153
154    /// Hybrid search: vector similarity + BM25 text, fused via RRF.
155    HybridSearch {
156        collection: QualifiedCollection,
157        /// Vector column the vector leg searches.
158        vector_field: String,
159        query_vector: Vec<f32>,
160        /// Field index the text leg reads. `None` reads the whole-document index.
161        text_field: Option<String>,
162        query_text: String,
163        /// Residual WHERE predicates (serialized `Vec<ScanFilter>`). They
164        /// restrict both legs before fusion.
165        filters: Vec<u8>,
166        top_k: usize,
167        ef_search: usize,
168        /// Boolean combination of the text leg's query terms.
169        mode: QueryMode,
170        fuzzy: bool,
171        /// Weight for vector results in RRF (0.0–1.0). Default: 0.5.
172        vector_weight: f32,
173        filter_bitmap: Option<SurrogateBitmap>,
174        /// RLS post-fusion filters.
175        rls_filters: Vec<u8>,
176        /// SELECT-list alias the response should use for the RRF score
177        /// column. `None` means the executor uses the fixed internal name
178        /// `rrf_score`. Set by the planner from the SELECT alias for the
179        /// `rrf_score(...)` call.
180        score_alias: Option<String>,
181    },
182
183    /// Index a document into the inverted FTS index.
184    ///
185    /// Used by the sync path when a Lite client sends an `FtsIndex` frame.
186    /// Origin assigns a surrogate for `(collection, doc_id)` on the Control
187    /// Plane before dispatch; `surrogate` is the pre-assigned value.
188    FtsIndexDoc {
189        collection: QualifiedCollection,
190        /// Pre-assigned global surrogate for `(collection, doc_id)`.
191        surrogate: nodedb_types::Surrogate,
192        /// `(field, text)` per top-level string field. Empty removes the
193        /// document from every index.
194        fields: Vec<(String, String)>,
195        /// Sync provenance: identifies the originating peer and sequence for idempotency.
196        #[serde(default)]
197        provenance: Option<nodedb_types::sync::wire::SyncProvenance>,
198    },
199
200    /// Remove a document from the inverted FTS index.
201    ///
202    /// Used by the sync path when a Lite client sends an `FtsDelete` frame.
203    FtsDeleteDoc {
204        collection: QualifiedCollection,
205        /// The global surrogate `(collection, doc_id)` is bound to. `None`
206        /// when the key's home binds none: the delete removes nothing and
207        /// still commits its sync provenance.
208        surrogate: Option<nodedb_types::Surrogate>,
209        /// Sync provenance: identifies the originating peer and sequence for idempotency.
210        #[serde(default)]
211        provenance: Option<nodedb_types::sync::wire::SyncProvenance>,
212    },
213
214    /// Three-source hybrid search: vector + BM25 text + graph BFS, fused via weighted RRF.
215    ///
216    /// Extends `HybridSearch` with an optional graph BFS leg. The graph leg
217    /// performs a BFS from `graph_seed_id` up to `graph_depth` hops, filtering
218    /// edges by `graph_edge_label` when set. All three ranked lists are passed
219    /// to `reciprocal_rank_fusion_weighted` with per-source k-constants.
220    HybridSearchTriple {
221        collection: QualifiedCollection,
222        /// Vector column the vector leg searches.
223        vector_field: String,
224        query_vector: Vec<f32>,
225        /// Field index the text leg reads. `None` reads the whole-document index.
226        text_field: Option<String>,
227        query_text: String,
228        /// Residual WHERE predicates (serialized `Vec<ScanFilter>`). They
229        /// restrict every leg before fusion.
230        filters: Vec<u8>,
231        /// Node id used as the BFS seed for the graph leg.
232        graph_seed_id: String,
233        /// Maximum BFS depth from the seed node.
234        graph_depth: usize,
235        /// Edge label filter for graph BFS. `None` = all edges.
236        graph_edge_label: Option<String>,
237        top_k: usize,
238        ef_search: usize,
239        /// Boolean combination of the text leg's query terms.
240        mode: QueryMode,
241        fuzzy: bool,
242        /// Per-source RRF k constants: (vector_k, text_k, graph_k).
243        rrf_k: (f64, f64, f64),
244        filter_bitmap: Option<SurrogateBitmap>,
245        /// RLS post-fusion filters.
246        rls_filters: Vec<u8>,
247        /// SELECT-list alias for the fused RRF score column.
248        score_alias: Option<String>,
249    },
250
251    /// Bind a collection's per-collection FTS analyzer.
252    ///
253    /// Persists to backend metadata (`InvertedIndex::set_collection_analyzer`)
254    /// so `InvertedIndex::analyze_for_collection` resolves it for every
255    /// subsequent tokenization of the collection's text — forward indexing,
256    /// the in-transaction staged-write overlay, and query-time scoring alike.
257    /// Config-only, single-node, non-WAL-durable — the same dispatch shape
258    /// `VectorOp::SetParams` uses for `CREATE VECTOR INDEX`.
259    SetTextConfig {
260        collection: QualifiedCollection,
261        /// Analyzer name, already checked against
262        /// `nodedb_fts::index::analyzer_config::analyzer_exists` by the DDL
263        /// layer (e.g. "standard", "english", "japanese"). `None` leaves the
264        /// collection's current analyzer in place.
265        analyzer_name: Option<String>,
266        /// Whether searches over this collection fall back to fuzzy matching
267        /// by default. `None` leaves the current setting in place.
268        fuzzy_default: Option<bool>,
269    },
270}