nodedb_physical/physical_plan/vector/op.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Vector engine operations dispatched to the Data Plane.
4
5use nodedb_types::{
6 QualifiedCollection, Surrogate, SurrogateBitmap, vector_distance::DistanceMetric,
7};
8
9use crate::physical_plan::PhysicalPlan;
10use crate::physical_plan::document::{ReturningSpec, UpdateValue};
11
12use super::resolved_mutation::VectorResolvedMutation;
13use super::write::VectorWriteTargets;
14
15/// Vector engine physical operations.
16#[derive(
17 Debug,
18 Clone,
19 PartialEq,
20 serde::Serialize,
21 serde::Deserialize,
22 zerompk::ToMessagePack,
23 zerompk::FromMessagePack,
24)]
25pub enum VectorOp {
26 /// Vector similarity search.
27 Search {
28 collection: QualifiedCollection,
29 query_vector: Vec<f32>,
30 top_k: usize,
31 /// Optional search beam width override. If 0, uses default `4 * top_k`.
32 ef_search: usize,
33 /// Per-query distance metric override. Overrides the collection-default
34 /// metric configured at index creation time.
35 metric: DistanceMetric,
36 /// Pre-computed bitmap of eligible surrogates (from filter evaluation).
37 filter_bitmap: Option<SurrogateBitmap>,
38 /// Named vector field to search. Empty string = default field.
39 field_name: String,
40 /// RLS post-candidate filters (serialized `Vec<ScanFilter>`).
41 /// Applied after HNSW returns candidates, before returning to client.
42 /// Result count may be less than requested `top_k`.
43 rls_filters: Vec<u8>,
44 /// Optional sub-plan whose output rows materialize a `SurrogateBitmap`
45 /// used as an additional prefilter (intersected with `filter_bitmap`).
46 ///
47 /// Set by the planner when fusing `Vector ORDER BY ... + JOIN
48 /// ARRAY_SLICE(...) ON v.id = s.surrogate` into a single op:
49 /// the array slice runs first, its surrogate column becomes the
50 /// vector engine's pre-filter, then HNSW search runs against the
51 /// reduced candidate set. Mirrors `HashJoin`'s `inline_*_bitmap`
52 /// mechanism so the Data Plane composition is uniform.
53 inline_prefilter_plan: Option<Box<PhysicalPlan>>,
54 /// ANN tuning knobs from the SQL caller. Defaults to no overrides.
55 ann_options: nodedb_types::VectorAnnOptions,
56 /// When `true`, the SELECT projection contains only `id` and/or
57 /// `vector_distance(...)` — no payload fields. The handler skips
58 /// the document-body fetch. Ignored (treated as `false`) when RLS
59 /// filters are active, as body fetch is required for RLS evaluation.
60 skip_payload_fetch: bool,
61 /// Optional payload bitmap pre-filter for vector-primary collections.
62 /// Each atom is `Eq` / `In` / `Range` — the handler ANDs all atoms
63 /// and intersects the resulting bitmap with the HNSW candidate set
64 /// before walking. The planner only emits atoms whose field has a
65 /// registered payload index of the matching kind. Empty means no
66 /// payload pre-filter.
67 payload_filters: Vec<nodedb_types::PayloadAtom>,
68 },
69
70 /// Insert a vector into the HNSW index (write path).
71 Insert {
72 collection: QualifiedCollection,
73 vector: Vec<f32>,
74 dim: usize,
75 /// Named vector field. Empty string = default (unnamed) field.
76 field_name: String,
77 /// Global surrogate identifying this row. Allocated by the
78 /// Control Plane via `SurrogateAssigner` before dispatch; the
79 /// engine binds the HNSW node id to this surrogate.
80 surrogate: Surrogate,
81 /// User PK bytes (UTF-8 of the document id) when the insert
82 /// originates from a PK-bearing path; `None` for headless
83 /// inserts. Carried on the replication wire so followers bind
84 /// the leader-assigned surrogate to this exact key rather than
85 /// re-allocating a divergent one.
86 #[serde(default)]
87 pk_bytes: Option<Vec<u8>>,
88 /// Sync provenance: identifies the originating peer and sequence for idempotency.
89 #[serde(default)]
90 provenance: Option<nodedb_types::sync::wire::SyncProvenance>,
91 },
92
93 /// Batch insert vectors into the HNSW index.
94 BatchInsert {
95 collection: QualifiedCollection,
96 vectors: Vec<Vec<f32>>,
97 dim: usize,
98 /// One surrogate per inserted vector, parallel to `vectors`.
99 /// Empty vector = headless batch (no PK binding).
100 surrogates: Vec<Surrogate>,
101 },
102
103 /// Multi-vector search: query across all named vector fields, fuse via RRF.
104 MultiSearch {
105 collection: QualifiedCollection,
106 query_vector: Vec<f32>,
107 top_k: usize,
108 ef_search: usize,
109 filter_bitmap: Option<SurrogateBitmap>,
110 /// RLS post-candidate filters.
111 rls_filters: Vec<u8>,
112 },
113
114 /// Soft-delete a vector by internal node ID.
115 Delete {
116 collection: QualifiedCollection,
117 vector_id: u32,
118 },
119
120 /// Soft-delete a vector by surrogate (sync inbound path).
121 ///
122 /// The Data Plane resolves `surrogate → HNSW node_id` via the
123 /// `surrogate_to_local` map and tombstones the node. If the surrogate
124 /// is not found the op is a no-op (idempotent).
125 DeleteBySurrogate {
126 collection: QualifiedCollection,
127 /// The row's bound surrogate. `None` when the key's home binds none:
128 /// the delete removes nothing and still commits its sync provenance.
129 surrogate: Option<nodedb_types::Surrogate>,
130 /// Named vector field; empty = default field.
131 field_name: String,
132 /// Sync provenance: identifies the originating peer and sequence for idempotency.
133 #[serde(default)]
134 provenance: Option<nodedb_types::sync::wire::SyncProvenance>,
135 },
136
137 /// Set vector index parameters for a collection.
138 SetParams {
139 collection: QualifiedCollection,
140 /// Named vector field this config applies to. Empty string = the
141 /// default (unnamed) vector field. Lets one collection carry
142 /// multiple vector indexes, one per `VECTOR` / embedding column.
143 field_name: String,
144 /// Declared vector dimension. Enforced against every vector written
145 /// to this field; `0` means "not declared" and leaves the index to
146 /// adopt the width of whatever arrives first.
147 #[serde(default)]
148 dim: usize,
149 m: usize,
150 ef_construction: usize,
151 metric: String,
152 /// Index type: "hnsw" (default), "hnsw_pq", or "ivf_pq".
153 index_type: String,
154 /// PQ subvectors (for hnsw_pq and ivf_pq). Default: 8.
155 pq_m: usize,
156 /// IVF cells (for ivf_pq only). Default: 256.
157 ivf_cells: usize,
158 /// IVF probe count (for ivf_pq only). Default: 16.
159 ivf_nprobe: usize,
160 },
161
162 /// Tear down one vector index: the materialized graph, its build
163 /// parameters, its declared dimension, and its on-disk checkpoint.
164 ///
165 /// The counterpart of [`VectorOp::SetParams`]. Without it a dropped
166 /// vector index keeps answering searches until the process restarts and
167 /// keeps its `(collection, field)` slot occupied so no replacement index
168 /// can be created.
169 DropIndex {
170 collection: QualifiedCollection,
171 /// Named vector field the index covers. Empty string = the default
172 /// (unnamed) vector field.
173 field_name: String,
174 },
175
176 /// Query live vector index statistics. Returns `VectorIndexStats` as payload.
177 QueryStats {
178 collection: QualifiedCollection,
179 /// Named vector field. Empty string = default (unnamed) field.
180 field_name: String,
181 },
182
183 /// Force-seal the growing segment, triggering background HNSW build.
184 Seal {
185 collection: QualifiedCollection,
186 /// Named vector field. Empty string = default field.
187 field_name: String,
188 },
189
190 /// Force tombstone compaction on sealed segments.
191 CompactIndex {
192 collection: QualifiedCollection,
193 /// Named vector field. Empty string = default field.
194 field_name: String,
195 },
196
197 /// Rebuild sealed segments with new HNSW parameters.
198 /// Old index serves queries until rebuild completes, then swaps atomically.
199 Rebuild {
200 collection: QualifiedCollection,
201 /// Named vector field. Empty string = default field.
202 field_name: String,
203 /// New M parameter. 0 = keep current.
204 m: usize,
205 /// New M0 parameter. 0 = keep current.
206 m0: usize,
207 /// New ef_construction. 0 = keep current.
208 ef_construction: usize,
209 },
210
211 /// Insert a sparse vector into the inverted index.
212 SparseInsert {
213 collection: QualifiedCollection,
214 /// Named sparse vector field.
215 field_name: String,
216 /// Document ID to associate with this sparse vector.
217 doc_id: String,
218 /// Sparse vector entries as `(dimension, weight)` pairs.
219 entries: Vec<(u32, f32)>,
220 },
221
222 /// Search the sparse inverted index via dot-product scoring.
223 SparseSearch {
224 collection: QualifiedCollection,
225 /// Named sparse vector field.
226 field_name: String,
227 /// Query sparse vector entries.
228 query_entries: Vec<(u32, f32)>,
229 /// Maximum results to return.
230 top_k: usize,
231 },
232
233 /// Delete a document from the sparse inverted index.
234 SparseDelete {
235 collection: QualifiedCollection,
236 /// Named sparse vector field.
237 field_name: String,
238 /// Document ID to remove.
239 doc_id: String,
240 },
241
242 /// Insert multiple vectors for a single document (ColBERT-style).
243 /// All vectors are inserted as separate HNSW nodes sharing the
244 /// same `document_surrogate`.
245 MultiVectorInsert {
246 collection: QualifiedCollection,
247 /// Named vector field. Empty = default.
248 field_name: String,
249 /// Surrogate shared by all vectors of the document.
250 document_surrogate: Surrogate,
251 /// User PK bytes of the document when it has a key; `None` for a
252 /// headless document, whose surrogate self-keys. Carried on the
253 /// replication wire so every replica binds `document_surrogate` to
254 /// this exact key, as `Insert` does.
255 #[serde(default)]
256 pk_bytes: Option<Vec<u8>>,
257 /// Flat vector data: count × dim f32 values.
258 vectors: Vec<f32>,
259 /// Number of vectors.
260 count: usize,
261 /// Dimensionality of each vector.
262 dim: usize,
263 },
264
265 /// Delete all vectors for a document from the multi-vector index.
266 MultiVectorDelete {
267 collection: QualifiedCollection,
268 /// Named vector field. Empty = default.
269 field_name: String,
270 /// Document surrogate whose vectors should be tombstoned.
271 document_surrogate: Surrogate,
272 },
273
274 /// Search with multi-vector aggregated scoring (MaxSim, AvgSim, SumSim).
275 /// Over-fetches from HNSW, groups by doc_id, aggregates, deduplicates.
276 MultiVectorScoreSearch {
277 collection: QualifiedCollection,
278 /// Named vector field. Empty = default.
279 field_name: String,
280 /// Query vector.
281 query_vector: Vec<f32>,
282 /// Maximum documents to return.
283 top_k: usize,
284 /// HNSW ef_search override. 0 = auto.
285 ef_search: usize,
286 /// Aggregation mode: "max_sim", "avg_sim", "sum_sim".
287 mode: String,
288 },
289
290 /// Direct vector upsert for vector-primary collections: an existing
291 /// primary key is replaced whole, or patched by `on_conflict_updates`.
292 ///
293 /// Bypasses MessagePack document encoding — the Data Plane binds the
294 /// vector to an HNSW node, updates the payload bitmap indexes from
295 /// `payload`, and stores `payload` as the row's sidecar. No
296 /// full-document blob is written.
297 ///
298 /// Ordering invariant (enforced by the handler):
299 /// 1. Validate dim and storage dtype.
300 /// 2. Decode `payload` bytes and probe the surrogate.
301 /// 3. Remove the old node, bitmap entries, and sidecar when one exists.
302 /// 4. Insert into HNSW (surrogate bound) and update the bitmap indexes.
303 /// 5. Store the sidecar; a failed store rolls step 4 back.
304 DirectUpsert {
305 collection: QualifiedCollection,
306 /// Vector column name. Used to compute the vector index key so the
307 /// SELECT path (which keys by `(tid, collection, field)`) finds the
308 /// same index this insert wrote into.
309 field: String,
310 /// Global surrogate the Control Plane bound to `pk_bytes`.
311 surrogate: Surrogate,
312 /// UTF-8 of the declared primary-key value. Followers bind the
313 /// leader-assigned surrogate to this exact key.
314 pk_bytes: Vec<u8>,
315 /// FP32 vector values.
316 vector: Vec<f32>,
317 /// Pre-encoded MessagePack of every non-vector column the statement
318 /// supplied. Empty when the row carried none.
319 ///
320 /// Only the fields named in `payload_indexes` become bitmap-indexed
321 /// downstream; the rest travel here so the whole row image is available
322 /// to whatever has to decide it — a row-level-security write policy
323 /// among them.
324 payload: Vec<u8>,
325 /// Collection-level quantization. Applied via `set_quantization` on
326 /// first insert so subsequent seals trigger codec-dispatch rebuilds
327 /// against the configured codec (RaBitQ / BBQ).
328 quantization: nodedb_types::VectorQuantization,
329 /// Native storage dtype for vector values (F32 / F16 / BF16). The
330 /// executor uses this to decide whether to transcode incoming f32
331 /// components before writing them into the HNSW index backing store.
332 storage_dtype: nodedb_types::VectorStorageDtype,
333 /// Payload field bitmap indexes (name + kind). Registered via
334 /// `payload.add_index` on the first insert into a new collection.
335 payload_indexes: Vec<(String, nodedb_types::PayloadIndexKind)>,
336 /// When `Some`, return the STORED post-image of the upserted row — the
337 /// metadata sidecar as it exists after the handler wrote it, decoded
338 /// through the same normalizer the `SELECT` scan uses, so the returned
339 /// row renders identically to a later read. Never the submitted values:
340 /// the sidecar is `zerompk` TAGGED bytes and echoing the request would
341 /// report a shape no read of the collection ever produces.
342 ///
343 /// The vector goes to HNSW, every other column to the sidecar, and
344 /// there is no companion document write to carry the clause instead.
345 #[serde(default)]
346 returning: Option<ReturningSpec>,
347 /// Read filters gating the row `returning` emits. A vector-primary
348 /// write's policy admission happens Control-Plane-side against the
349 /// `payload` image (`RlsCtx::admit_write_value_map_image`), which
350 /// decides whether the write happens at all; this bounds what may be
351 /// shown back, so a `RETURNING` row never exceeds a `SELECT` by the
352 /// same principal.
353 #[serde(default)]
354 rls_filters: Vec<u8>,
355 /// `ON CONFLICT (pk) DO UPDATE SET` assignments applied to the stored
356 /// payload when the key exists. Empty means whole-row replace.
357 #[serde(default)]
358 on_conflict_updates: Vec<(String, UpdateValue)>,
359 /// Write policy for the merged post-image an `on_conflict_updates`
360 /// patch produces; the image exists only on the Data Plane. Decided
361 /// Control-Plane-side when the patch is empty.
362 rls_write_check: nodedb_types::RlsWriteCheck,
363 },
364
365 /// Vector-primary `INSERT`: an existing primary key raises
366 /// `unique_violation`. Same fields as [`VectorOp::DirectUpsert`] minus
367 /// the conflict patch.
368 DirectInsert {
369 collection: QualifiedCollection,
370 field: String,
371 surrogate: Surrogate,
372 pk_bytes: Vec<u8>,
373 vector: Vec<f32>,
374 payload: Vec<u8>,
375 quantization: nodedb_types::VectorQuantization,
376 storage_dtype: nodedb_types::VectorStorageDtype,
377 payload_indexes: Vec<(String, nodedb_types::PayloadIndexKind)>,
378 #[serde(default)]
379 returning: Option<ReturningSpec>,
380 #[serde(default)]
381 rls_filters: Vec<u8>,
382 },
383
384 /// Vector-primary `INSERT ... ON CONFLICT DO NOTHING`: an existing
385 /// primary key leaves the stored row alone and reports zero rows.
386 DirectInsertIfAbsent {
387 collection: QualifiedCollection,
388 field: String,
389 surrogate: Surrogate,
390 pk_bytes: Vec<u8>,
391 vector: Vec<f32>,
392 payload: Vec<u8>,
393 quantization: nodedb_types::VectorQuantization,
394 storage_dtype: nodedb_types::VectorStorageDtype,
395 payload_indexes: Vec<(String, nodedb_types::PayloadIndexKind)>,
396 #[serde(default)]
397 returning: Option<ReturningSpec>,
398 #[serde(default)]
399 rls_filters: Vec<u8>,
400 },
401
402 /// Multi-row vector-primary `INSERT` / `INSERT ... ON CONFLICT` /
403 /// `UPSERT`: one statement, one page. Every row is judged before any
404 /// row lands, so the page lands whole or not at all.
405 ///
406 /// Each row means what one `DirectInsert` / `DirectInsertIfAbsent` /
407 /// `DirectUpsert` of the same `intent` means, applied in page order. A
408 /// key the page names twice is judged against the body its earlier row
409 /// produced, and is stored once, with the final body.
410 ///
411 /// The page always resolves to a [`VectorOp::ResolvedDirectWrite`]
412 /// before it is logged or replicated: the record names the rows the page
413 /// stores, so replay never judges existence again.
414 DirectBatchWrite {
415 collection: QualifiedCollection,
416 /// Vector column name; keys the HNSW index.
417 field: String,
418 /// What an existing key means for every row of the page.
419 intent: super::write::VectorDirectWriteIntent,
420 /// The rows, in statement order.
421 rows: Vec<super::write::VectorDirectRow>,
422 quantization: nodedb_types::VectorQuantization,
423 storage_dtype: nodedb_types::VectorStorageDtype,
424 payload_indexes: Vec<(String, nodedb_types::PayloadIndexKind)>,
425 /// When `Some`, return one row per row the page writes, each its
426 /// key's stored post-image.
427 #[serde(default)]
428 returning: Option<ReturningSpec>,
429 /// Read filters gating the rows `returning` emits.
430 #[serde(default)]
431 rls_filters: Vec<u8>,
432 /// See `DirectUpsert::on_conflict_updates`. Only an `Upsert` page
433 /// carries one; one clause for every row.
434 #[serde(default)]
435 on_conflict_updates: Vec<(String, UpdateValue)>,
436 /// See `DirectUpsert::rls_write_check`.
437 rls_write_check: nodedb_types::RlsWriteCheck,
438 },
439
440 /// Vector-primary `DELETE`. Removes the HNSW node, its payload bitmap
441 /// entries, and the payload sidecar row of every targeted surrogate.
442 /// Reports the number of rows that existed.
443 DirectDelete {
444 collection: QualifiedCollection,
445 /// Vector column name; keys the HNSW index.
446 field: String,
447 targets: VectorWriteTargets,
448 /// When `Some`, return the removed rows' sidecar images.
449 #[serde(default)]
450 returning: Option<ReturningSpec>,
451 /// Read filters gating the rows `returning` emits.
452 #[serde(default)]
453 rls_filters: Vec<u8>,
454 /// Write policy decided against each removed row's sidecar image.
455 rls_write_check: nodedb_types::RlsWriteCheck,
456 },
457
458 /// Vector-primary `TRUNCATE`. Removes every live row of the collection's
459 /// primary index: each HNSW node, its payload bitmap entries, and its
460 /// payload sidecar row. Reports the number of rows that existed.
461 DirectTruncate {
462 collection: QualifiedCollection,
463 /// Vector column name; keys the HNSW index.
464 field: String,
465 /// `TRUNCATE ... RESTART IDENTITY`: the Control Plane resets the
466 /// collection's sequences after the Data Plane clears the rows.
467 restart_identity: bool,
468 },
469
470 /// Vector-primary `UPDATE`. A `new_vector` rebuilds the HNSW node under
471 /// the same surrogate; `payload_patch` merges into the sidecar and moves
472 /// the payload bitmap entries. Reports the number of rows that existed.
473 DirectUpdate {
474 collection: QualifiedCollection,
475 /// Vector column name; keys the HNSW index.
476 field: String,
477 targets: VectorWriteTargets,
478 /// Replacement vector, when the statement assigns the vector column.
479 new_vector: Option<Vec<f32>>,
480 /// Assignments to non-vector columns, applied to the stored payload.
481 payload_patch: Vec<(String, UpdateValue)>,
482 quantization: nodedb_types::VectorQuantization,
483 storage_dtype: nodedb_types::VectorStorageDtype,
484 payload_indexes: Vec<(String, nodedb_types::PayloadIndexKind)>,
485 /// When `Some`, return the rows' stored post-images.
486 #[serde(default)]
487 returning: Option<ReturningSpec>,
488 /// Read filters gating the rows `returning` emits.
489 #[serde(default)]
490 rls_filters: Vec<u8>,
491 /// Write policy decided against each row's post-image.
492 rls_write_check: nodedb_types::RlsWriteCheck,
493 },
494
495 // ── Resolve-before-propose (governed vector-primary writes) ─────────
496 /// Read-only: report every row mutation the wrapped vector-primary write
497 /// would apply, and the response payload it would return, without
498 /// applying anything.
499 ///
500 /// Wraps a [`VectorOp::DirectDelete`], [`VectorOp::DirectUpdate`],
501 /// [`VectorOp::DirectUpsert`], or [`VectorOp::DirectBatchWrite`]
502 /// verbatim, live write predicate included —
503 /// the handler resolves the targets, reads each row's sidecar, computes
504 /// the post-image, and decides the policy here, where the writing
505 /// identity is still available. A follower has none, so the predicate
506 /// can never cross the Raft wire.
507 ResolveDirectWrite(Box<VectorOp>),
508
509 /// Apply exactly the mutations a [`VectorOp::ResolveDirectWrite`]
510 /// reported, then return `response_payload` verbatim.
511 ///
512 /// No predicate is evaluated and no image is recomputed: the Control
513 /// Plane already decided both, and `rls_write_check` carries the verdict
514 /// (`RlsWriteCheck::DecidedEarlierInRequest`). Every mutation's pre-image
515 /// is checked against the stored sidecar before the first one applies,
516 /// so a resolution that drifted under a concurrent write applies nothing
517 /// and asks for a retry.
518 ResolvedDirectWrite {
519 collection: QualifiedCollection,
520 /// Vector column name; keys the HNSW index.
521 field: String,
522 /// Index settings a first write into a new index registers; an
523 /// `Upsert` mutation into an empty collection creates the index.
524 quantization: nodedb_types::VectorQuantization,
525 storage_dtype: nodedb_types::VectorStorageDtype,
526 payload_indexes: Vec<(String, nodedb_types::PayloadIndexKind)>,
527 mutations: Vec<VectorResolvedMutation>,
528 /// The statement's reply, decided while resolving. Applying nodes
529 /// return it unchanged, so leader and follower report the same thing.
530 response_payload: Vec<u8>,
531 rls_write_check: nodedb_types::RlsWriteCheck,
532 },
533}