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