Skip to content

Commit a8c67d9

Browse files
committed
refactor(sql): unify CREATE ... INDEX DDL parsing through a shared grammar
Route vector, fulltext/search, sparse, and spatial index DDL through a common options/header parser instead of each handler hand-splitting tokens, so an unrecognized option is now a parse error rather than a silently skipped token. Split the vector apply_put handler into a directory and add a dedicated vector_params module to keep the resulting executor and WAL-replay plumbing consistent with the new grammar.
1 parent 8af0844 commit a8c67d9

94 files changed

Lines changed: 4279 additions & 2096 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/full-text-search.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ CREATE SEARCH INDEX ON articles FIELDS title, body
3939
ANALYZER 'english'
4040
FUZZY true;
4141

42+
-- `CREATE FULLTEXT INDEX` is an exact alias, and the column list may also be
43+
-- written in parentheses. ANALYZER takes 'standard' or a supported language
44+
-- name; an unknown name is rejected rather than silently falling back.
45+
-- FUZZY sets the collection default — queries may still opt in per call.
46+
CREATE FULLTEXT INDEX idx_articles_text ON articles (title, body) ANALYZER 'english';
47+
4248
-- Basic search with text_match
4349
SELECT title, bm25_score(body, 'distributed database rust') AS score
4450
FROM articles

docs/vectors.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ NodeDB's vector engine is built for production semantic search — not vectors s
3636
CREATE COLLECTION articles;
3737
CREATE VECTOR INDEX idx_articles_embedding ON articles METRIC cosine DIM 384;
3838

39+
-- DIM is required and is enforced on every write: an embedding of any other
40+
-- width is rejected at INSERT rather than quietly indexed at the wrong size.
41+
-- Name the column explicitly to carry several embeddings on one collection.
42+
CREATE VECTOR INDEX idx_articles_clip ON articles (clip_embedding) METRIC cosine DIM 512;
43+
3944
-- Insert documents with embeddings
4045
INSERT INTO articles {
4146
title: 'Understanding Transformers',

nodedb-cluster-tests/tests/cluster_array.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ async fn cluster_array_vector_prefilter_distributed() {
285285
.expect("CREATE COLLECTION genes");
286286
cluster
287287
.exec_ddl_on_any_leader(
288-
"CREATE VECTOR INDEX idx_genes_emb ON genes FIELD embedding METRIC cosine DIM 3",
288+
"CREATE VECTOR INDEX idx_genes_emb ON genes (embedding) METRIC cosine DIM 3",
289289
)
290290
.await
291291
.expect("CREATE VECTOR INDEX");

nodedb-fts/src/index/analyzer_config.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
//! Per-collection analyzer configuration stored in backend metadata.
44
//!
55
//! Uses structural `(tid, collection, subkey)` meta blobs:
6-
//! - `subkey = "analyzer"` → analyzer name (e.g. "german", "cjk_bigram")
6+
//! - `subkey = "analyzer"` → analyzer name (e.g. "german", "standard")
77
//! - `subkey = "language"` → lang code (e.g. "de", "ja")
8+
//! - `subkey = "fuzzy"` → `"1"` when the collection defaults to fuzzy matching
89
//!
910
//! Applied automatically at both index time and query time.
1011
@@ -49,6 +50,37 @@ impl<B: FtsBackend> FtsIndex<B> {
4950
)
5051
}
5152

53+
/// Set whether searches over this collection fall back to fuzzy
54+
/// (Levenshtein) matching by default. Persists to backend metadata.
55+
pub fn set_collection_fuzzy(
56+
&self,
57+
database_id: u64,
58+
tid: u64,
59+
collection: &str,
60+
fuzzy: bool,
61+
) -> Result<(), B::Error> {
62+
self.backend.write_meta(
63+
database_id,
64+
tid,
65+
collection,
66+
"fuzzy",
67+
if fuzzy { b"1" } else { b"0" },
68+
)
69+
}
70+
71+
/// Whether this collection defaults to fuzzy matching. `false` when unset.
72+
pub fn get_collection_fuzzy(
73+
&self,
74+
database_id: u64,
75+
tid: u64,
76+
collection: &str,
77+
) -> Result<bool, B::Error> {
78+
Ok(self
79+
.backend
80+
.read_meta(database_id, tid, collection, "fuzzy")?
81+
.is_some_and(|bytes| bytes.as_slice() == b"1"))
82+
}
83+
5284
/// Get the configured analyzer name for a collection.
5385
pub fn get_collection_analyzer(
5486
&self,
@@ -115,6 +147,20 @@ impl<B: FtsBackend> FtsIndex<B> {
115147
}
116148
}
117149

150+
/// Whether `name` resolves to a real analyzer.
151+
///
152+
/// [`resolve_analyzer`] falls back to the standard analyzer for anything it
153+
/// does not know, which silently turns a misspelled analyzer name into a
154+
/// different analyzer. Callers that accept a name from a user — DDL, config —
155+
/// gate on this first so the fallback is only ever reached for a name that
156+
/// was already checked. The two must stay in step: every arm below has a
157+
/// matching arm there.
158+
pub fn analyzer_exists(name: &str) -> bool {
159+
name == "standard"
160+
|| LanguageAnalyzer::new(name).is_some()
161+
|| NoStemAnalyzer::new(name).is_some()
162+
}
163+
118164
/// Resolve an analyzer name to a `Box<dyn TextAnalyzer>`.
119165
fn resolve_analyzer(name: &str) -> Box<dyn TextAnalyzer> {
120166
match name {

nodedb-fts/src/search/bm25_search.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ impl<B: FtsBackend> FtsIndex<B> {
8282
mode,
8383
prefilter,
8484
} = params;
85+
// A collection configured with `FUZZY true` falls back to fuzzy
86+
// matching even when the query did not ask for it — that is what
87+
// makes it an index property rather than a per-query flag. Resolved
88+
// here, at the one point every search path funnels through, so no
89+
// caller can be wired up without it.
90+
let fuzzy_enabled = fuzzy_enabled
91+
|| self
92+
.get_collection_fuzzy(database_id, tid, collection)
93+
.map_err(FtsIndexError::backend)?;
94+
8595
// Parse the query for NOT / - negation operators before analysis.
8696
let parsed = parse_query(query)?;
8797

nodedb-physical/src/physical_plan/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ impl PhysicalPlan {
279279
| PhysicalPlan::Text(TextOp::BM25ScoreScan { collection, .. })
280280
| PhysicalPlan::Text(TextOp::FtsIndexDoc { collection, .. })
281281
| PhysicalPlan::Text(TextOp::FtsDeleteDoc { collection, .. })
282-
| PhysicalPlan::Text(TextOp::SetAnalyzer { collection, .. })
282+
| PhysicalPlan::Text(TextOp::SetTextConfig { collection, .. })
283283
| PhysicalPlan::Query(QueryOp::PartialAggregate { collection, .. })
284284
| PhysicalPlan::Query(QueryOp::FacetCounts { collection, .. })
285285
| PhysicalPlan::Document(DocumentOp::BulkUpdate { collection, .. })

nodedb-physical/src/physical_plan/text.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,15 @@ pub enum TextOp {
147147
/// the in-transaction staged-write overlay, and query-time scoring alike.
148148
/// Config-only, single-node, non-WAL-durable — the same dispatch shape
149149
/// `VectorOp::SetParams` uses for `CREATE VECTOR INDEX`.
150-
SetAnalyzer {
150+
SetTextConfig {
151151
collection: String,
152-
/// Analyzer name (e.g. "standard", "simple", "english", "cjk_bigram").
153-
/// Unrecognized names fall back to the standard analyzer at resolve
154-
/// time (see `nodedb_fts::index::analyzer_config::resolve_analyzer`).
155-
analyzer_name: String,
152+
/// Analyzer name, already checked against
153+
/// `nodedb_fts::index::analyzer_config::analyzer_exists` by the DDL
154+
/// layer (e.g. "standard", "english", "japanese"). `None` leaves the
155+
/// collection's current analyzer in place.
156+
analyzer_name: Option<String>,
157+
/// Whether searches over this collection fall back to fuzzy matching
158+
/// by default. `None` leaves the current setting in place.
159+
fuzzy_default: Option<bool>,
156160
},
157161
}

nodedb-physical/src/physical_plan/vector.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ pub enum VectorOp {
130130
/// default (unnamed) vector field. Lets one collection carry
131131
/// multiple vector indexes, one per `VECTOR` / embedding column.
132132
field_name: String,
133+
/// Declared vector dimension. Enforced against every vector written
134+
/// to this field; `0` means "not declared" and leaves the index to
135+
/// adopt the width of whatever arrives first.
136+
#[serde(default)]
137+
dim: usize,
133138
m: usize,
134139
ef_construction: usize,
135140
metric: String,

nodedb-test-support/src/tx_batch_helpers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ pub fn vector_set_params(collection: &str) -> PhysicalPlan {
107107
PhysicalPlan::Vector(VectorOp::SetParams {
108108
collection: collection.into(),
109109
field_name: String::new(),
110+
dim: 0,
110111
m: 16,
111112
ef_construction: 200,
112113
metric: "cosine".into(),

nodedb-types/src/protocol/text_fields/decode.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,9 @@ impl<'a> zerompk::FromMessagePack<'a> for TextFields {
320320
FID_INDEX_TYPE => {
321321
out.index_type = Some(reader.read_string()?.into_owned());
322322
}
323+
FID_VECTOR_DIM => {
324+
out.vector_dim = Some(reader.read_u32()?);
325+
}
323326
FID_DATABASE => {
324327
out.database = Some(reader.read_string()?.into_owned());
325328
}

0 commit comments

Comments
 (0)