Skip to main content

uni_store/storage/
index_manager.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Index lifecycle management: creation, rebuild, and incremental updates for all index types.
5
6use crate::backend::StorageBackend;
7use crate::backend::table_names;
8use crate::backend::types::ScanRequest;
9#[cfg(feature = "lance-backend")]
10use crate::storage::inverted_index::InvertedIndex;
11#[cfg(feature = "lance-backend")]
12use crate::storage::sparse_index::SparseVectorIndex;
13use crate::storage::vertex::VertexDataset;
14use anyhow::{Result, anyhow};
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18#[cfg(feature = "lance-backend")]
19use std::collections::HashSet;
20use std::sync::Arc;
21#[cfg(feature = "lance-backend")]
22use tracing::{debug, info, instrument, warn};
23use uni_common::core::id::Vid;
24#[cfg(feature = "lance-backend")]
25use uni_common::core::schema::IndexDefinition;
26use uni_common::core::schema::SchemaManager;
27#[cfg(feature = "lance-backend")]
28use uni_common::core::schema::{
29    DistanceMetric, FullTextIndexConfig, InvertedIndexConfig, JsonFtsIndexConfig,
30    ScalarIndexConfig, ScalarIndexType, SparseVectorIndexConfig, VectorIndexConfig,
31    VectorIndexType,
32};
33
34/// Status of an index rebuild task.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub enum IndexRebuildStatus {
37    /// Task is waiting to be processed.
38    Pending,
39    /// Task is currently being processed.
40    InProgress,
41    /// Task completed successfully.
42    Completed,
43    /// Task failed with an error.
44    Failed,
45}
46
47/// A task representing an index rebuild operation.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct IndexRebuildTask {
50    /// Unique identifier for this task.
51    pub id: String,
52    /// The label for which indexes are being rebuilt.
53    pub label: String,
54    /// Current status of the task.
55    pub status: IndexRebuildStatus,
56    /// When the task was created.
57    pub created_at: DateTime<Utc>,
58    /// When the task started processing.
59    pub started_at: Option<DateTime<Utc>>,
60    /// When the task completed (successfully or with failure).
61    pub completed_at: Option<DateTime<Utc>>,
62    /// Error message if the task failed.
63    pub error: Option<String>,
64    /// Number of retry attempts.
65    pub retry_count: u32,
66}
67
68/// Resolves the embedding dimension of a vector or multi-vector property type.
69///
70/// Recurses through `List(Vector{dim})` (multi-vector / ColBERT) to the inner
71/// `Vector{dim}`; returns `None` for non-vector types.
72fn resolve_vector_dim(t: &uni_common::DataType) -> Option<usize> {
73    match t {
74        uni_common::DataType::Vector { dimensions } => Some(*dimensions),
75        uni_common::DataType::List(inner) => resolve_vector_dim(inner),
76        _ => None,
77    }
78}
79
80/// Maps a schema [`VectorIndexType`] to the backend's [`VectorIndexParams`].
81///
82/// The logical MUVERA type is resolved to its `inner` shape by the caller
83/// (`create_vector_index_inner`) before reaching here, so a `Muvera` value is a
84/// programming error. The `Option<num_partitions>` HNSW default of "auto" is
85/// resolved to a single partition, matching the prior raw-`Dataset` mapping.
86///
87/// # Errors
88/// Returns an error if a `Muvera` type reaches this physical-build mapping.
89#[cfg(feature = "lance-backend")]
90fn to_backend_vector_params(
91    metric: DistanceMetric,
92    index_type: &VectorIndexType,
93) -> Result<crate::backend::types::VectorIndexParams> {
94    use crate::backend::types::{VectorIndexKind, VectorIndexParams};
95    // `DistanceMetric` here is the schema (uni_common) enum; the backend has its
96    // own. Both are `#[non_exhaustive]`, so the catch-all arms are required.
97    let backend_metric = match metric {
98        DistanceMetric::L2 => crate::backend::types::DistanceMetric::L2,
99        DistanceMetric::Cosine => crate::backend::types::DistanceMetric::Cosine,
100        DistanceMetric::Dot => crate::backend::types::DistanceMetric::Dot,
101        // L1 has no ANN backend; L1 columns are searched exact/brute-force.
102        DistanceMetric::L1 => {
103            return Err(anyhow!(
104                "L1/Manhattan distance does not support an ANN vector index — L1 columns \
105                 are searched exact/brute-force. Declare the column without a vector index \
106                 (or use cosine/l2/dot if you need ANN)."
107            ));
108        }
109        // Binary metrics apply to `BinaryVector` columns and have no ANN backend
110        // here; compute exact distance with `VECTOR_DISTANCE(a, b, 'hamming'|'jaccard')`.
111        DistanceMetric::Hamming | DistanceMetric::Jaccard => {
112            return Err(anyhow!(
113                "{metric:?} distance does not support a vector index — it applies to \
114                 BinaryVector columns and is computed exactly via \
115                 VECTOR_DISTANCE(a, b, 'hamming'|'jaccard'). Declare the column without a \
116                 vector index."
117            ));
118        }
119        other => return Err(anyhow!("Unsupported vector index metric: {:?}", other)),
120    };
121    let kind = match index_type {
122        VectorIndexType::Flat => VectorIndexKind::Flat,
123        VectorIndexType::IvfFlat { num_partitions } => VectorIndexKind::IvfFlat {
124            num_partitions: *num_partitions,
125        },
126        VectorIndexType::IvfPq {
127            num_partitions,
128            num_sub_vectors,
129            bits_per_subvector,
130        } => VectorIndexKind::IvfPq {
131            num_partitions: *num_partitions,
132            num_sub_vectors: *num_sub_vectors,
133            num_bits: *bits_per_subvector,
134        },
135        VectorIndexType::IvfSq { num_partitions } => VectorIndexKind::IvfSq {
136            num_partitions: *num_partitions,
137        },
138        VectorIndexType::IvfRq {
139            num_partitions,
140            num_bits,
141        } => VectorIndexKind::IvfRq {
142            num_partitions: *num_partitions,
143            num_bits: *num_bits,
144        },
145        VectorIndexType::HnswFlat {
146            m,
147            ef_construction,
148            num_partitions,
149        } => VectorIndexKind::HnswFlat {
150            m: *m,
151            ef_construction: *ef_construction,
152            num_partitions: num_partitions.unwrap_or(1),
153        },
154        VectorIndexType::HnswSq {
155            m,
156            ef_construction,
157            num_partitions,
158        } => VectorIndexKind::HnswSq {
159            m: *m,
160            ef_construction: *ef_construction,
161            num_partitions: num_partitions.unwrap_or(1),
162        },
163        VectorIndexType::HnswPq {
164            m,
165            ef_construction,
166            num_sub_vectors,
167            num_partitions,
168        } => VectorIndexKind::HnswPq {
169            m: *m,
170            ef_construction: *ef_construction,
171            num_sub_vectors: *num_sub_vectors,
172            num_partitions: num_partitions.unwrap_or(1),
173        },
174        VectorIndexType::Muvera { .. } => {
175            return Err(anyhow!(
176                "MUVERA must be resolved to its inner index type before the physical build"
177            ));
178        }
179        other => return Err(anyhow!("Unsupported vector index type: {:?}", other)),
180    };
181    Ok(VectorIndexParams {
182        metric: backend_metric,
183        kind,
184    })
185}
186
187/// Manages physical and logical indexes across all vertex datasets.
188pub struct IndexManager {
189    base_uri: String,
190    schema_manager: Arc<SchemaManager>,
191    /// Storage backend, when available. Needed only for MUVERA FDE backfill (scan +
192    /// `replace_table_atomic`); `None` callers (e.g. some rebuild paths) still build
193    /// indexes over already-materialised columns.
194    backend: Option<Arc<dyn StorageBackend>>,
195}
196
197impl std::fmt::Debug for IndexManager {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.debug_struct("IndexManager")
200            .field("base_uri", &self.base_uri)
201            .finish_non_exhaustive()
202    }
203}
204
205impl IndexManager {
206    /// Create a new `IndexManager` bound to `base_uri` and the given schema, without a
207    /// storage backend (MUVERA backfill over pre-existing rows is unavailable).
208    pub fn new(base_uri: &str, schema_manager: Arc<SchemaManager>) -> Self {
209        Self {
210            base_uri: base_uri.to_string(),
211            schema_manager,
212            backend: None,
213        }
214    }
215
216    /// Attach a storage backend, enabling MUVERA FDE backfill over already-flushed rows.
217    pub fn with_backend(mut self, backend: Arc<dyn StorageBackend>) -> Self {
218        self.backend = Some(backend);
219        self
220    }
221
222    /// Serialize writers to a single index's postings dataset.
223    ///
224    /// The sparse and set-membership inverted indexes persist their postings with an
225    /// unconditional `WriteMode::Overwrite`. A DDL `CREATE … INDEX` backfill and a
226    /// concurrent flush incremental update both load-modify-overwrite the *same* postings
227    /// path; without serialization the second overwrite clobbers the first (a silent lost
228    /// update → a vid's posting vanishes from search candidates — issue #95). Keyed by the
229    /// postings dataset path, reusing the backend's per-key write-lock map. Returns `None`
230    /// when no backend is attached (offline rebuild paths cannot race a flush).
231    #[cfg(feature = "lance-backend")]
232    async fn postings_write_guard(
233        &self,
234        postings_path: &str,
235    ) -> Option<crate::backend::traits::TableWriteGuard> {
236        match self.backend.as_ref() {
237            Some(backend) => Some(backend.lock_table_for_write(postings_path).await),
238            None => None,
239        }
240    }
241
242    /// Build and persist an inverted index for set-membership queries.
243    #[cfg(feature = "lance-backend")]
244    #[instrument(skip(self), level = "info")]
245    pub async fn create_inverted_index(&self, config: InvertedIndexConfig) -> Result<()> {
246        let label = &config.label;
247        let property = &config.property;
248        info!(
249            "Creating Inverted Index '{}' on {}.{}",
250            config.name, label, property
251        );
252
253        let schema = self.schema_manager.schema();
254        if !schema.labels.contains_key(label) {
255            return Err(anyhow!("Label '{}' not found", label));
256        }
257
258        // Serialize this full-rebuild overwrite against a concurrent flush incremental
259        // update of the same postings dataset (issue #95).
260        let postings_path = format!("{}/indexes/{}/{}_inverted", self.base_uri, label, property);
261        let _postings_guard = self.postings_write_guard(&postings_path).await;
262
263        let mut index = InvertedIndex::new(&self.base_uri, config.clone()).await?;
264
265        // Backfill from the flushed vertex table via the storage backend. The
266        // LanceDB-managed table is not at the raw `{base}/vertices_<label>`
267        // path a `VertexDataset` open would target, and the backend read is
268        // branch-aware. Mirrors the sparse-index backfill. A not-yet-flushed
269        // table legitimately yields an empty index, populated on the next flush.
270        let table = table_names::vertex_table_name(label);
271        if let Some(backend) = self.backend.as_ref() {
272            if backend.table_exists(&table).await? {
273                let batches = backend.scan(ScanRequest::all(&table)).await?;
274                index
275                    .build_from_batches(&batches, |n| info!("Indexed {} terms", n))
276                    .await?;
277            } else {
278                debug!(
279                    "Table '{}' not flushed yet; creating empty inverted index (populated on flush)",
280                    table
281                );
282            }
283        } else {
284            warn!(
285                "No storage backend available; inverted index '{}' left empty (populated on flush)",
286                config.name
287            );
288        }
289
290        self.schema_manager
291            .add_index(IndexDefinition::Inverted(config))?;
292        self.schema_manager.save().await?;
293
294        Ok(())
295    }
296
297    /// Build and persist a vector (ANN) index on an embedding column. This is the SINGLE
298    /// build path every creation surface converges on (Cypher DDL, the
299    /// `uni.schema.createIndex` procedure, and the Rust/Python schema builders via
300    /// `rebuild`), so dense, native-multivector, and MUVERA indexes behave identically.
301    ///
302    /// For a `Muvera` index it first prepares the derived FDE column (`prepare_muvera_fde`:
303    /// register + one-time backfill), then builds the physical single-vector ANN over that
304    /// `__fde_*` column with the **Dot** metric (its inner product approximates MaxSim),
305    /// while the persisted config stays the MUVERA one so query routing detects it.
306    #[cfg(feature = "lance-backend")]
307    #[instrument(skip(self), level = "info")]
308    pub async fn create_vector_index(&self, config: VectorIndexConfig) -> Result<()> {
309        self.create_vector_index_inner(config, false).await
310    }
311
312    /// Like [`Self::create_vector_index`], but `force_backfill` re-materialises a MUVERA
313    /// index's derived FDE column over ALL current rows even if it was already registered.
314    ///
315    /// Full rebuilds ([`Self::rebuild_indexes_for_label`], hence `db.indexes().rebuild()`
316    /// and the bulk loader's sync index sync) set this. The flush-time FDE materializer
317    /// (`Writer::materialize_fde_columns`) only runs on the tx write path, so after a BULK
318    /// load — or any out-of-band table mutation — the newly written rows have no FDE. A
319    /// plain create would hit the "already registered" guard and skip the backfill, leaving
320    /// those rows invisible to the FDE ANN (silent empty results). A rebuild must therefore
321    /// force a fresh backfill; `splice_fde_batch` recomputes every row's FDE deterministically
322    /// and overwrites any stale column, so this is idempotent.
323    #[cfg(feature = "lance-backend")]
324    async fn create_vector_index_inner(
325        &self,
326        config: VectorIndexConfig,
327        force_backfill: bool,
328    ) -> Result<()> {
329        if let VectorIndexType::Muvera { inner, .. } = &config.index_type {
330            // Register + backfill the derived FDE column (forced on a full rebuild).
331            self.prepare_muvera_fde(&config, force_backfill).await?;
332            let inner_cfg = VectorIndexConfig {
333                name: config.name.clone(),
334                label: config.label.clone(),
335                property: crate::storage::muvera_index::fde_derived_column(&config.name),
336                index_type: (**inner).clone(),
337                metric: DistanceMetric::Dot,
338                embedding_config: None,
339                metadata: config.metadata.clone(),
340            };
341            self.build_physical_vector_index(&inner_cfg).await?;
342        } else {
343            self.build_physical_vector_index(&config).await?;
344        }
345        self.schema_manager
346            .add_index(IndexDefinition::Vector(config))?;
347        self.schema_manager.save().await?;
348        Ok(())
349    }
350
351    /// Prepare a MUVERA index's derived `__fde_*` column: register it as an internal
352    /// schema property and, the FIRST time (when it was not already registered), backfill
353    /// it over all already-flushed rows via a full table rewrite (scan → splice the FDE
354    /// column into the `get_arrow_schema`-sorted position → `replace_table_atomic`),
355    /// mirroring the inverted-index "scan all rows at create time" precedent.
356    ///
357    /// The "already registered" guard makes this cheap on incremental creates: a plain
358    /// `create_vector_index` (e.g. when another index on the label is added, or on schema
359    /// re-apply) skips the rewrite — on the tx write path the column is kept current by the
360    /// flush-time materializer (`Writer::materialize_fde_columns`). `force_backfill` bypasses
361    /// that guard for full rebuilds, where the materializer assumption does not hold (e.g.
362    /// after a bulk load); see [`Self::create_vector_index_inner`]. No-op for a non-MUVERA
363    /// config, an unresolved source dimension, a label with nothing flushed yet, or when no
364    /// backend is attached.
365    #[cfg(feature = "lance-backend")]
366    async fn prepare_muvera_fde(
367        &self,
368        config: &VectorIndexConfig,
369        force_backfill: bool,
370    ) -> Result<()> {
371        use crate::storage::muvera_index::fde_spec_for_config;
372
373        let schema = self.schema_manager.schema();
374        let Some(spec) = fde_spec_for_config(&schema, config) else {
375            return Ok(());
376        };
377        spec.params.validate()?;
378
379        // Register the derived column. `add_internal_property` is write-lock-guarded and
380        // reports whether THIS call inserted it, so two concurrent creates of the same MUVERA
381        // index can't both run the (expensive) full-table backfill — only the inserter does.
382        let newly_added = self.schema_manager.add_internal_property(
383            &spec.label,
384            &spec.derived_col,
385            uni_common::DataType::Vector {
386                dimensions: spec.params.fde_dim(),
387            },
388            true,
389        )?;
390
391        // Backfill when we just registered the column, or when a full rebuild forces it (the
392        // flush-time materializer doesn't cover bulk-loaded / out-of-band rows). A plain
393        // re-create that finds the column already present relies on that materializer.
394        if !newly_added && !force_backfill {
395            return Ok(());
396        }
397
398        // Run the backfill; if it FAILS after we just added the column, roll the registration
399        // back so the in-memory schema stays consistent with disk and a retry re-adds +
400        // re-backfills. Otherwise the retry would see the column registered, skip the
401        // backfill, and build the index over an unpopulated FDE column.
402        //
403        // Crash-window note: the on-disk order is backfill (`replace_table_atomic`) THEN
404        // schema save (in `create_vector_index_inner`). A crash in between leaves an orphan
405        // `__fde_*` column with no persisted schema entry, which the next create's idempotent
406        // rewrite overwrites — self-healing. Persisting the schema first would be worse (a
407        // registered column with no data errors reads), and a `Building` marker is not
408        // auto-recovered (`labels_needing_rebuild` skips `Building`/`Failed`).
409        if let Err(e) = self.backfill_fde_column(&spec).await {
410            if newly_added {
411                let _ = self
412                    .schema_manager
413                    .drop_property(&spec.label, &spec.derived_col);
414            }
415            return Err(e);
416        }
417        Ok(())
418    }
419
420    /// Materialize the MUVERA derived FDE column over all currently-flushed rows via a full
421    /// table rewrite (scan → recompute each row's FDE → splice into the
422    /// `get_arrow_schema`-sorted position → `replace_table_atomic`). No-op (kept registration)
423    /// when no backend is attached or the label has nothing flushed yet — create-before-ingest,
424    /// where the flush path materializes the column. The caller must have already registered
425    /// the derived column in the schema.
426    #[cfg(feature = "lance-backend")]
427    async fn backfill_fde_column(
428        &self,
429        spec: &crate::storage::muvera_index::FdeSpec,
430    ) -> Result<()> {
431        use crate::storage::muvera_index::splice_fde_batch;
432
433        let Some(backend) = self.backend.as_ref() else {
434            return Ok(());
435        };
436        let table = table_names::vertex_table_name(&spec.label);
437        // Err propagates (a backend fault must not silently skip the backfill and
438        // leave the FDE column NULL); Ok(false) is the create-before-ingest case
439        // where the flush path will materialize the column.
440        if !backend.table_exists(&table).await? {
441            return Ok(());
442        }
443
444        let schema = self.schema_manager.schema();
445        let label_id = schema
446            .label_id_by_name(&spec.label)
447            .ok_or_else(|| anyhow!("MUVERA: label '{}' not found", spec.label))?;
448        // Schema already carries the FDE column (registered by the caller) so it's in the
449        // arrow schema at the position future flush appends will use.
450        let target_schema =
451            VertexDataset::new(&self.base_uri, &spec.label, label_id).get_arrow_schema(&schema)?;
452        let source_dt = schema
453            .properties
454            .get(&spec.label)
455            .and_then(|p| p.get(&spec.source_prop))
456            .map(|m| m.r#type.clone());
457        let encoder = uni_common::muvera::FdeEncoder::new(&spec.params)?;
458
459        // Serialize the scan → splice → overwrite against concurrent flush appends.
460        // `replace_table_atomic` overwrites the WHOLE vertex table, so a row a flush
461        // appends between our scan and our overwrite would be silently dropped —
462        // durable loss of committed data (issue #96). The flush append takes this same
463        // per-table write lock inside `StorageBackend::write`, so holding it across the
464        // read-modify-write makes the two mutually exclusive and guarantees we scan the
465        // post-append state. The lock must wrap BOTH the scan and the replace (not just
466        // the replace) to close the read→overwrite TOCTOU window.
467        let _table_guard = backend.lock_table_for_write(&table).await;
468
469        let batches = backend.scan(ScanRequest::all(&table)).await?;
470        let mut new_batches = Vec::with_capacity(batches.len());
471        for batch in &batches {
472            new_batches.push(splice_fde_batch(
473                batch,
474                &target_schema,
475                spec,
476                &encoder,
477                source_dt.as_ref(),
478            )?);
479        }
480        backend
481            .replace_table_atomic(&table, new_batches, target_schema)
482            .await?;
483        Ok(())
484    }
485
486    /// Build the physical Lance ANN index described by `config` over `config.property`
487    /// with `config.metric`. Does NOT persist the schema index definition — the caller
488    /// does, possibly under a different logical config (see MUVERA in
489    /// [`Self::create_vector_index`]).
490    #[cfg(feature = "lance-backend")]
491    async fn build_physical_vector_index(&self, config: &VectorIndexConfig) -> Result<()> {
492        let label = &config.label;
493        let property = &config.property;
494        info!(
495            "Creating vector index '{}' on {}.{}",
496            config.name, label, property
497        );
498
499        let schema = self.schema_manager.schema();
500        if !schema.labels.contains_key(label) {
501            return Err(anyhow!("Label '{}' not found", label));
502        }
503
504        // L1/Manhattan has no ANN backend metric — an L1 column is searched
505        // exact/brute-force. Skip the physical index build entirely; the caller
506        // still persists the config so `vector_search` reads the L1 metric. The
507        // declared ANN algorithm is intentionally ignored for L1.
508        if matches!(config.metric, DistanceMetric::L1) {
509            info!(
510                "Vector index '{}' uses L1/Manhattan — no physical ANN index; \
511                 searched exact/brute-force",
512                config.name
513            );
514            return Ok(());
515        }
516
517        // Fail fast on an invalid PQ configuration before touching Lance (which
518        // would otherwise error opaquely at build time). The embedding dimension
519        // comes from the schema property type, recursing `List(Vector{dim})` for
520        // multi-vector (ColBERT) columns.
521        let prop_dim = schema
522            .properties
523            .get(label)
524            .and_then(|props| props.get(property))
525            .and_then(|meta| resolve_vector_dim(&meta.r#type));
526        let pq_sub = match &config.index_type {
527            VectorIndexType::IvfPq {
528                num_sub_vectors, ..
529            }
530            | VectorIndexType::HnswPq {
531                num_sub_vectors, ..
532            } => Some(*num_sub_vectors as usize),
533            _ => None,
534        };
535        // Only the realistic misconfiguration (sub-vectors that don't divide a
536        // dimension at least as large) is rejected up front. The degenerate
537        // `sub > dim` case (e.g. the default 16 on a dim-2 column) is left to Lance,
538        // which clamps/defers it — notably so an index can be declared on an empty
539        // table before any rows exist.
540        if let (Some(dim), Some(sub)) = (prop_dim, pq_sub)
541            && sub != 0
542            && dim >= sub
543            && dim % sub != 0
544        {
545            return Err(anyhow!(
546                "Vector index '{}': PQ num_sub_vectors ({}) must divide the embedding dimension ({})",
547                config.name,
548                sub,
549                dim
550            ));
551        }
552
553        let params = to_backend_vector_params(config.metric.clone(), &config.index_type)?;
554        let table = table_names::vertex_table_name(label);
555
556        let Some(backend) = self.backend.as_ref() else {
557            warn!(
558                "No storage backend; physical vector index '{}' deferred until a flush",
559                config.name
560            );
561            return Ok(());
562        };
563
564        // Build only once the table is flushed; create-before-flush is a no-op
565        // here and is materialized by the next flush's rebuild. A build failure
566        // on a tiny/degenerate column is tolerated (Lance may clamp or defer ANN
567        // training) — the schema definition is still persisted by the caller.
568        if backend.table_exists(&table).await? {
569            info!(
570                "Building physical vector index '{}' on '{}'",
571                config.name, table
572            );
573            if let Err(e) = backend
574                .create_vector_index(&table, property, &config.name, params)
575                .await
576            {
577                warn!(
578                    "Failed to build physical vector index '{}' (column may be empty): {}",
579                    config.name, e
580                );
581            } else {
582                info!("Vector index '{}' created", config.name);
583            }
584        } else {
585            debug!(
586                "Label '{}' not flushed yet; physical vector index '{}' built on next flush",
587                label, config.name
588            );
589        }
590
591        Ok(())
592    }
593
594    /// Build and persist a scalar (BTree) index for exact-match and range queries.
595    #[cfg(feature = "lance-backend")]
596    #[instrument(skip(self), level = "info")]
597    pub async fn create_scalar_index(&self, config: ScalarIndexConfig) -> Result<()> {
598        let label = &config.label;
599        let properties = &config.properties;
600        info!(
601            "Creating scalar index '{}' on {}.{:?}",
602            config.name, label, properties
603        );
604
605        let schema = self.schema_manager.schema();
606        if !schema.labels.contains_key(label) {
607            return Err(anyhow!("Label '{}' not found", label));
608        }
609
610        let columns: Vec<&str> = properties.iter().map(|s| s.as_str()).collect();
611        // Map the schema scalar type to the backend's; anything other than the
612        // explicit Bitmap/LabelList falls back to BTree (matching the prior
613        // `ScalarIndexParams::default()`).
614        let backend_idx_type = match config.index_type {
615            ScalarIndexType::Bitmap => crate::backend::types::ScalarIndexType::Bitmap,
616            ScalarIndexType::LabelList => crate::backend::types::ScalarIndexType::LabelList,
617            _ => crate::backend::types::ScalarIndexType::BTree,
618        };
619        let table = table_names::vertex_table_name(label);
620
621        if let Some(backend) = self.backend.as_ref() {
622            if backend.table_exists(&table).await? {
623                info!(
624                    "Building physical scalar index '{}' on '{}'",
625                    config.name, table
626                );
627                if let Err(e) = backend
628                    .create_scalar_index(&table, &columns, backend_idx_type, Some(&config.name))
629                    .await
630                {
631                    warn!(
632                        "Failed to build physical scalar index '{}' (table may be empty): {}",
633                        config.name, e
634                    );
635                } else {
636                    info!("Scalar index '{}' created", config.name);
637                }
638            } else {
639                debug!(
640                    "Label '{}' not flushed yet; physical scalar index '{}' built on next flush",
641                    label, config.name
642                );
643            }
644        } else {
645            warn!(
646                "No storage backend; physical scalar index '{}' deferred until a flush",
647                config.name
648            );
649        }
650
651        self.schema_manager
652            .add_index(IndexDefinition::Scalar(config))?;
653        self.schema_manager.save().await?;
654
655        Ok(())
656    }
657
658    /// Build and persist a full-text search (Lance inverted) index.
659    #[cfg(feature = "lance-backend")]
660    #[instrument(skip(self), level = "info")]
661    pub async fn create_fts_index(&self, config: FullTextIndexConfig) -> Result<()> {
662        let label = &config.label;
663        info!(
664            "Creating FTS index '{}' on {}.{:?}",
665            config.name, label, config.properties
666        );
667
668        let schema = self.schema_manager.schema();
669        if !schema.labels.contains_key(label) {
670            return Err(anyhow!("Label '{}' not found", label));
671        }
672
673        let columns: Vec<&str> = config.properties.iter().map(|s| s.as_str()).collect();
674        let table = table_names::vertex_table_name(label);
675
676        if let Some(backend) = self.backend.as_ref() {
677            if backend.table_exists(&table).await? {
678                info!(
679                    "Building physical FTS index '{}' on '{}'",
680                    config.name, table
681                );
682                // Validate the tokenizer config up front so an invalid analyzer
683                // is surfaced as a hard error rather than being swallowed by the
684                // "table may be empty" downgrade below.
685                if let Err(e) = crate::backend::fts_analyzer::to_inverted_params(
686                    &config.tokenizer,
687                    config.with_positions,
688                ) {
689                    return Err(anyhow!(
690                        "Invalid tokenizer/analyzer config for FTS index '{}': {}",
691                        config.name,
692                        e
693                    ));
694                }
695                if let Err(e) = backend
696                    .create_fts_index(
697                        &table,
698                        &columns,
699                        Some(&config.name),
700                        &config.tokenizer,
701                        config.with_positions,
702                    )
703                    .await
704                {
705                    warn!(
706                        "Failed to build physical FTS index '{}' (table may be empty): {}",
707                        config.name, e
708                    );
709                } else {
710                    info!("FTS index '{}' created", config.name);
711                }
712            } else {
713                debug!(
714                    "Label '{}' not flushed yet; physical FTS index '{}' built on next flush",
715                    label, config.name
716                );
717            }
718        } else {
719            warn!(
720                "No storage backend; physical FTS index '{}' deferred until a flush",
721                config.name
722            );
723        }
724
725        self.schema_manager
726            .add_index(IndexDefinition::FullText(config))?;
727        self.schema_manager.save().await?;
728
729        Ok(())
730    }
731
732    /// Creates a JSON Full-Text Search index on a column.
733    ///
734    /// This creates a Lance inverted index on the specified column,
735    /// enabling BM25-based full-text search with optional phrase matching.
736    #[cfg(feature = "lance-backend")]
737    #[instrument(skip(self), level = "info")]
738    pub async fn create_json_fts_index(&self, config: JsonFtsIndexConfig) -> Result<()> {
739        let label = &config.label;
740        let column = &config.column;
741        info!(
742            "Creating JSON FTS index '{}' on {}.{}",
743            config.name, label, column
744        );
745
746        let schema = self.schema_manager.schema();
747        if !schema.labels.contains_key(label) {
748            return Err(anyhow!("Label '{}' not found", label));
749        }
750
751        let table = table_names::vertex_table_name(label);
752
753        if let Some(backend) = self.backend.as_ref() {
754            if backend.table_exists(&table).await? {
755                info!(
756                    "Building physical JSON FTS index '{}' on '{}'",
757                    config.name, table
758                );
759                if let Err(e) = backend
760                    .create_fts_index(
761                        &table,
762                        &[column.as_str()],
763                        Some(&config.name),
764                        // JSON FTS uses the default (standard) analyzer; the
765                        // JSON-specific tokenizer wiring is out of scope here.
766                        &uni_common::core::schema::TokenizerConfig::Standard,
767                        config.with_positions,
768                    )
769                    .await
770                {
771                    warn!(
772                        "Failed to build physical JSON FTS index '{}' (table may be empty): {}",
773                        config.name, e
774                    );
775                } else {
776                    info!("JSON FTS index '{}' created", config.name);
777                }
778            } else {
779                debug!(
780                    "Label '{}' not flushed yet; physical JSON FTS index '{}' built on next flush",
781                    label, config.name
782                );
783            }
784        } else {
785            warn!(
786                "No storage backend; physical JSON FTS index '{}' deferred until a flush",
787                config.name
788            );
789        }
790
791        self.schema_manager
792            .add_index(IndexDefinition::JsonFullText(config))?;
793        self.schema_manager.save().await?;
794
795        Ok(())
796    }
797
798    /// Remove an index both physically from the Lance dataset and from the schema.
799    #[cfg(feature = "lance-backend")]
800    #[instrument(skip(self), level = "info")]
801    pub async fn drop_index(&self, name: &str) -> Result<()> {
802        info!("Dropping index '{}'", name);
803
804        let idx_def = self
805            .schema_manager
806            .get_index(name)
807            .ok_or_else(|| anyhow!("Index '{}' not found in schema", name))?;
808
809        // Drop the physical index through the backend. Best-effort: the index
810        // may never have been physically built (e.g. created before any flush),
811        // so a failure here is non-fatal.
812        let label = idx_def.label();
813        let table = table_names::vertex_table_name(label);
814        if let Some(backend) = self.backend.as_ref() {
815            if let Err(e) = backend.drop_index(&table, name).await {
816                warn!(
817                    "Physical index drop for '{}' returned error (non-fatal): {}",
818                    name, e
819                );
820            } else {
821                info!("Physical index '{}' dropped from '{}'", name, table);
822            }
823        }
824
825        self.schema_manager.remove_index(name)?;
826        self.schema_manager.save().await?;
827        Ok(())
828    }
829
830    /// Rebuild all indexes registered for `label` from scratch.
831    #[cfg(feature = "lance-backend")]
832    #[instrument(skip(self), level = "info")]
833    pub async fn rebuild_indexes_for_label(&self, label: &str) -> Result<()> {
834        info!("Rebuilding all indexes for label '{}'", label);
835        let schema = self.schema_manager.schema();
836
837        // Clone and filter to avoid holding lock while async awaiting
838        let indexes: Vec<_> = schema
839            .indexes
840            .iter()
841            .filter(|idx| idx.label() == label)
842            .cloned()
843            .collect();
844
845        for index in indexes {
846            match index {
847                // A full rebuild must force the MUVERA FDE backfill: bulk-loaded / reopened
848                // rows aren't covered by the flush-time materializer (see
849                // `create_vector_index_inner`).
850                IndexDefinition::Vector(cfg) => self.create_vector_index_inner(cfg, true).await?,
851                IndexDefinition::Scalar(cfg) => self.create_scalar_index(cfg).await?,
852                IndexDefinition::FullText(cfg) => self.create_fts_index(cfg).await?,
853                IndexDefinition::Inverted(cfg) => self.create_inverted_index(cfg).await?,
854                IndexDefinition::JsonFullText(cfg) => self.create_json_fts_index(cfg).await?,
855                IndexDefinition::Sparse(cfg) => self.create_sparse_vector_index(cfg).await?,
856                _ => warn!("Unknown index type encountered during rebuild, skipping"),
857            }
858        }
859        Ok(())
860    }
861
862    /// Create composite index for unique constraint
863    #[cfg(feature = "lance-backend")]
864    pub async fn create_composite_index(&self, label: &str, properties: &[String]) -> Result<()> {
865        let schema = self.schema_manager.schema();
866        if !schema.labels.contains_key(label) {
867            return Err(anyhow!("Label '{}' not found", label));
868        }
869
870        // Lance supports multi-column indexes.
871        let index_name = format!("{}_{}_composite", label, properties.join("_"));
872        let columns: Vec<&str> = properties.iter().map(|s| s.as_str()).collect();
873        let table = table_names::vertex_table_name(label);
874
875        if let Some(backend) = self.backend.as_ref() {
876            if backend.table_exists(&table).await? {
877                info!("Building composite index '{}' on '{}'", index_name, table);
878                if let Err(e) = backend
879                    .create_scalar_index(
880                        &table,
881                        &columns,
882                        crate::backend::types::ScalarIndexType::BTree,
883                        Some(&index_name),
884                    )
885                    .await
886                {
887                    warn!(
888                        "Failed to build composite index '{}' (table may be empty): {}",
889                        index_name, e
890                    );
891                } else {
892                    info!("Composite index '{}' created", index_name);
893                }
894
895                let config = ScalarIndexConfig {
896                    name: index_name,
897                    label: label.to_string(),
898                    properties: properties.to_vec(),
899                    index_type: uni_common::core::schema::ScalarIndexType::BTree,
900                    where_clause: None,
901                    metadata: Default::default(),
902                };
903                self.schema_manager
904                    .add_index(IndexDefinition::Scalar(config))?;
905                self.schema_manager.save().await?;
906            } else {
907                debug!(
908                    "Label '{}' not flushed yet; composite index for {:?} built on next flush",
909                    label, properties
910                );
911            }
912        } else {
913            warn!(
914                "No storage backend; composite index for {:?} deferred until a flush",
915                properties
916            );
917        }
918
919        Ok(())
920    }
921
922    /// Applies incremental updates to an inverted index.
923    ///
924    /// Instead of rebuilding the entire index, this method updates only the
925    /// changed entries, making it much faster for small mutations.
926    ///
927    /// # Errors
928    ///
929    /// Returns an error if the index doesn't exist or the update fails.
930    #[cfg(feature = "lance-backend")]
931    #[instrument(skip(self, added, removed), level = "info", fields(
932        label = %config.label,
933        property = %config.property
934    ))]
935    pub async fn update_inverted_index_incremental(
936        &self,
937        config: &InvertedIndexConfig,
938        added: &HashMap<Vid, Vec<String>>,
939        removed: &HashSet<Vid>,
940    ) -> Result<()> {
941        info!(
942            added = added.len(),
943            removed = removed.len(),
944            "Incrementally updating inverted index"
945        );
946
947        // Serialize this load-modify-overwrite against a concurrent DDL backfill of the
948        // same postings dataset (issue #95).
949        let postings_path = format!(
950            "{}/indexes/{}/{}_inverted",
951            self.base_uri, config.label, config.property
952        );
953        let _postings_guard = self.postings_write_guard(&postings_path).await;
954
955        let mut index = InvertedIndex::new(&self.base_uri, config.clone()).await?;
956        index.apply_incremental_updates(added, removed).await
957    }
958
959    /// Create (and backfill) a scored sparse-vector index. Mirrors
960    /// `create_inverted_index`: build from the flushed vertex dataset if it
961    /// exists, then register + persist the config.
962    #[cfg(feature = "lance-backend")]
963    #[instrument(skip(self), level = "info")]
964    pub async fn create_sparse_vector_index(&self, config: SparseVectorIndexConfig) -> Result<()> {
965        let label = &config.label;
966        let property = &config.property;
967        info!(
968            "Creating Sparse Vector Index '{}' on {}.{}",
969            config.name, label, property
970        );
971
972        let schema = self.schema_manager.schema();
973        if !schema.labels.contains_key(label) {
974            return Err(anyhow!("Label '{}' not found", label));
975        }
976
977        // Serialize this full-rebuild overwrite against a concurrent flush incremental
978        // update of the same postings dataset (issue #95).
979        let postings_path = format!("{}/indexes/{}/{}_sparse", self.base_uri, label, property);
980        let _postings_guard = self.postings_write_guard(&postings_path).await;
981
982        let mut index = SparseVectorIndex::new(&self.base_uri, config.clone()).await?;
983
984        // Backfill from the flushed vertex table via the storage backend (the
985        // LanceDB-managed table is not at the raw `{base}/vertices_<label>`
986        // path a `VertexDataset::open` expects). Mirrors the MUVERA backfill.
987        let table = table_names::vertex_table_name(label);
988        if let Some(backend) = self.backend.as_ref() {
989            // Err propagates (a backend fault must surface, not silently build an empty
990            // index); Ok(false) is the not-yet-flushed create-before-ingest case.
991            if backend.table_exists(&table).await? {
992                let batches = backend.scan(ScanRequest::all(&table)).await?;
993                index
994                    .build_from_batches(&batches, |n| debug!("Indexed {} sparse docs", n))
995                    .await?;
996            } else {
997                debug!(
998                    "Table '{}' not flushed yet; creating empty sparse index (populated on flush)",
999                    table
1000                );
1001            }
1002        } else {
1003            warn!(
1004                "No storage backend available; sparse index '{}' left empty (populated on flush)",
1005                config.name
1006            );
1007        }
1008
1009        self.schema_manager
1010            .add_index(IndexDefinition::Sparse(config))?;
1011        self.schema_manager.save().await?;
1012
1013        Ok(())
1014    }
1015
1016    /// Applies incremental updates to a sparse-vector index (load-modify-write,
1017    /// same semantics as the set-membership inverted index).
1018    #[cfg(feature = "lance-backend")]
1019    #[instrument(skip(self, added, removed), level = "info", fields(
1020        label = %config.label,
1021        property = %config.property
1022    ))]
1023    pub async fn update_sparse_vector_index_incremental(
1024        &self,
1025        config: &SparseVectorIndexConfig,
1026        added: &HashMap<Vid, Vec<(u32, f32)>>,
1027        removed: &HashSet<Vid>,
1028    ) -> Result<()> {
1029        info!(
1030            added = added.len(),
1031            removed = removed.len(),
1032            "Incrementally updating sparse vector index"
1033        );
1034        // Serialize this load-modify-overwrite against a concurrent DDL backfill of the
1035        // same postings dataset (issue #95).
1036        let postings_path = format!(
1037            "{}/indexes/{}/{}_sparse",
1038            self.base_uri, config.label, config.property
1039        );
1040        let _postings_guard = self.postings_write_guard(&postings_path).await;
1041
1042        let mut index = SparseVectorIndex::new(&self.base_uri, config.clone()).await?;
1043        index.apply_incremental_updates(added, removed).await
1044    }
1045
1046    /// Open a sparse-vector index for querying, given its label + property.
1047    /// Errors if no `IndexDefinition::Sparse` is registered for that pair.
1048    #[cfg(feature = "lance-backend")]
1049    pub async fn sparse_vector_index(
1050        &self,
1051        label: &str,
1052        property: &str,
1053    ) -> Result<SparseVectorIndex> {
1054        let schema = self.schema_manager.schema();
1055        let config = schema
1056            .indexes
1057            .iter()
1058            .find_map(|idx| match idx {
1059                IndexDefinition::Sparse(cfg) if cfg.label == label && cfg.property == property => {
1060                    Some(cfg.clone())
1061                }
1062                _ => None,
1063            })
1064            .ok_or_else(|| anyhow!("No sparse vector index found for {}.{}", label, property))?;
1065        SparseVectorIndex::new(&self.base_uri, config).await
1066    }
1067}