Skip to main content

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}