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}