Full-text search
Native full-text search over skaidb tables: BM25-ranked retrieval with
MATCH() / SEARCH() SQL predicates, backed by an embedded
Tantivy index per search index.
This documents the shipped state; the SQL grammar is in
QUERY_SYNTAX.md, and the
phase history in git.
Status: phases 0–8 complete — phases 1–5 (single-node core: DDL,
index maintenance on put/delete, score(), top-k pushdown, crash
recovery, rebuild; analysis & mappings: analyzer registry, per-column
configuration, typed fast fields, .keyword twins, copy_to; query DSL:
the full predicate family MATCH/MATCH_PHRASE/MATCH_PREFIX/FUZZY/
WILDCARD/REGEXP/SEARCH/MATCH_CROSS, AND/OR/NOT composition plus
BOOSTED() optional scoring, dis-max multi-field scoring, HIGHLIGHT()
snippets, per-hit BM25 explain over the ES subset, multi-word synonyms; cluster: scatter-gather top-k,
per-replica indexes, topology continuity; performance: bulk ingest path,
writer heap under memory_target — benchmarked vs Elasticsearch, see
BENCHMARKS.md) plus aggregations: GROUP BY and
aggregate functions over search queries, with an exact fast-field facet
pushdown.
Using it
CREATE SEARCH INDEX articles_fts ON articles (title, body, year, published)
WITH (analyzer = 'english', refresh_ms = 1000,
title.boost = 2.0, title.keyword = true,
title.copy_to = 'everything', body.copy_to = 'everything',
year.type = 'long', published.type = 'bool');
SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet FROM articles
WHERE MATCH(body, 'quick brown fox') AND published = true
ORDER BY score() DESC LIMIT 10;
SELECT id FROM articles
WHERE SEARCH('title:"rust database" +body:performance year:[2020 TO 2024]')
ORDER BY score() DESC LIMIT 20;
-- Search predicates compose with AND/OR/NOT:
SELECT id FROM articles
WHERE (MATCH(body, 'rust') OR MATCH(title, 'rust')) AND NOT MATCH(body, 'draft');
REBUILD SEARCH INDEX articles_fts; -- re-index from the table
DROP SEARCH INDEX articles_fts;
- An index covers one or more document paths (dotted paths into nested
documents work:
meta.title). Arrays index every element (multi-valued fields). Rows are schema-less — the declaration is the mapping: a value that doesn't fit its column's declared type is simply not indexed for that column. refresh_ms(default 1000) controls how quickly writes become searchable — near-real-time. On the single-node write path, a search after a write commits the index first, so you read your own writes immediately; the server also runs a background refresher tick (200 ms), so even a table receiving no further traffic becomes searchable on the shared/read-only path withinrefresh_ms+ one tick.- Measured against Elasticsearch on identical hardware: ~1.5× ES's bulk ingest and single-digit-fraction query latencies (see BENCHMARKS.md).
From an application
Search is ordinary SQL, so every driver runs it through its normal query
call — there is no separate search API to learn. The query text binds as a
parameter, so user input never gets concatenated into SQL. score()
comes back in a column named score even without an alias; HIGHLIGHT
needs one. Placeholders differ per driver (? everywhere except Node.js
and Ruby, which use $1) — see the matrix in
HOWDOI.md.
SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet
FROM articles
WHERE MATCH(body, ?) AND published = true
ORDER BY score() DESC LIMIT 10;
cur = conn.cursor()
cur.execute("SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet "
"FROM articles WHERE MATCH(body, ?) AND published = true "
"ORDER BY score() DESC LIMIT 10", ("quick brown fox",))
for id_, title, score, snippet in cur.fetchall():
print(id_, score, snippet)
const res = await client.query(
`SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet
FROM articles WHERE MATCH(body, $1) AND published = true
ORDER BY score() DESC LIMIT 10`, ['quick brown fox']);
for (const row of res.rows) console.log(row.id, row.score, row.snippet);
rows, err := db.Query(`SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet
FROM articles WHERE MATCH(body, ?) AND published = true
ORDER BY score() DESC LIMIT 10`, "quick brown fox")
defer rows.Close()
for rows.Next() {
var id int
var title, snippet string
var score float64
rows.Scan(&id, &title, &score, &snippet)
}
Skaidb.ResultSet rs = conn.prepare(
"SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet "
+ "FROM articles WHERE MATCH(body, ?) AND published = true "
+ "ORDER BY score() DESC LIMIT 10")
.setString(1, "quick brown fox")
.executeQuery();
while (rs.next()) System.out.println(rs.getDouble("score") + " " + rs.getString("snippet"));
res = conn.exec_params(<<~SQL, ["quick brown fox"])
SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet
FROM articles WHERE MATCH(body, $1) AND published = true
ORDER BY score() DESC LIMIT 10
SQL
res.each { |row| puts "#{row['score']} #{row['snippet']}" }
$stmt = $db->prepare('SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet
FROM articles WHERE MATCH(body, ?) AND published = true
ORDER BY score() DESC LIMIT 10');
$stmt->execute(['quick brown fox']);
foreach ($stmt->fetchAll() as $row) { echo $row['score'], ' ', $row['snippet'], PHP_EOL; }
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet " +
"FROM articles WHERE MATCH(body, ?) AND published = true " +
"ORDER BY score() DESC LIMIT 10";
cmd.Parameters.Add("quick brown fox");
using var reader = cmd.ExecuteReader();
while (reader.Read()) Console.WriteLine($"{reader.GetDouble(2)} {reader.GetString(3)}");
use skaidb_proto::Response;
use skaidb_types::Value;
let mut q = client.prepare(
"SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet \
FROM articles WHERE MATCH(body, ?) AND published = true \
ORDER BY score() DESC LIMIT 10")?;
if let Response::Rows { rows, .. } =
client.execute_prepared(&mut q, &[Value::String("quick brown fox".into())])?
{
for row in rows { println!("{} {}", row[2], row[3]); }
}
SEARCH('…') takes a bound string the same way, so a query-string UI passes
the user's text straight through as a parameter.
Analyzers
Set the index default with analyzer = '...', or per column with
<column>.analyzer = '...':
standard— Unicode word split (UAX §29:dog'sstays one token) + lowercase (the default). Measured at 98.5% strict top-10 result-set overlap with Elasticsearch on a 280 k article corpus (see BENCHMARKS.md).folding—standard+ ASCII folding (café→cafe) for accent-insensitive matching without stemming.- Languages (standard + stopwords where a list exists + Snowball
stemmer):
arabic,danish,dutch,english,finnish,french,german,greek,hungarian,italian,norwegian,portuguese,romanian,russian,spanish,swedish,tamil,turkish. whitespace— split only, case kept.keyword— the whole value as one term.ngram(min,max)— lowercased character ngrams (substring matching).edge_ngram(min,max)— lowercased prefix ngrams (search-as-you-type); pair withsearch_analyzer = 'standard'so queries aren't ngrammed too.- Custom pipelines — compose your own chain (ES custom analyzers):
'<tokenizer> | <filter> | …', e.g.'unicode | lowercase | stopwords(the,a,an) | stem(english)'or'whitespace | lowercase | ascii_folding'. Tokenizers:unicode(UAX §29, thestandardbase),whitespace,keyword,ngram(min,max),edge_ngram(min,max),regex(<pattern>)(each pattern match is a token; a|inside the parens is pattern payload). Filters, applied in order:lowercase,ascii_folding,alphanum_only,remove_long(<max chars>),stop(<language>),stopwords(w1, w2, …),stem(<language>). Note pipeline tokenizers are bare — addlowercase/remove_longyourself (the built-instandard/ngramnames include them). Char filters (html_strip, pattern_replace) are not supported — they change source offsets, which would corrupt highlighting.
Query text is analyzed with the field's query-time analyzer:
<column>.search_analyzer if set, else the index-time analyzer.
Synonyms (synonyms = 'quick,fast,speedy; new york,nyc,big apple')
expand at query time in MATCH — each group entry is analyzed with the
field's own pipeline so stemming lines up. Multi-word entries work in
both directions: an entry that occurs in the query as a consecutive token
sequence expands to its peers, and multi-word peers expand as phrase
alternatives (a query for nyc matches "new york" only where the words
are adjacent). MATCH_PHRASE and the query-string language do not
expand. Because expansion is query-time, synonyms hot-reload:
ALTER SEARCH INDEX articles_fts SET (synonyms = 'quick,fast; car,auto');
ALTER SEARCH INDEX … SET changes query-time-safe options in place
(synonyms, refresh_ms, <col>.search_analyzer, <col>.boost) with no
reindex; index-time options (analyzers, types, twins, copy_to) error —
those change the stored postings and need DROP + CREATE.
Per-column options
WITH (...) takes global options (analyzer, refresh_ms) and
<column>.<option> per-column options:
| option | meaning |
|---|---|
<col>.type |
text (default), keyword, long, double, bool, date |
<col>.analyzer |
index-time analyzer for this text column |
<col>.search_analyzer |
query-time analyzer override |
<col>.boost |
score multiplier in multi-field queries (positive number) |
<col>.keyword |
true adds a <col>.keyword exact-match twin |
<col>.copy_to |
also index this text into a named composite field |
- Typed columns (
long,double,date,bool) become fast fields, addressable from theSEARCH()query-string language (year:1999,price:[30 TO *],published:true).doubleaccepts integer values;dateacceptstimestampand millisecond-integer values.MATCH()on a non-text column is an error. .keywordtwins index the raw string alongside the analyzed text:MATCH(title, 'rust handbook')matches analyzed terms whileMATCH(title.keyword, 'Rust Handbook')matches only the exact original string.copy_toaggregates several columns into one searchable composite field (analyzed with the index default) — the EScopy_topattern for "search everything" fields. Several columns may share one target.- Options are validated at
CREATEtime; unknown options, unknown analyzers, or analyzer/keyword/copy_to options on non-text columns error. - Changing columns, types, or index-time analyzers requires a rebuild (the
engine rebuilds automatically on open if the definition changed);
search_analyzeris query-time-only and needs none.
Predicates
Analyzed predicates (query text goes through the field's query-time analyzer):
MATCH(col, 'text')— any analyzed term matches (ESmatch).MATCH_PHRASE(col, 'text' [, slop])— terms in order withinsloptranspositions (ESmatch_phrase).FUZZY(col, 'text' [, distance])— Levenshtein ≤ 2 per term (ESfuzzy).SEARCH('query-string')— the mini-language: bare terms over text columns,"phrase",col:term,+must,-must_not,AND/OR, and ranges over typed columns (year:[2020 TO 2024],published:true).MATCH_CROSS(col, col, …, 'text')— term-centric multi-field match (ESmulti_matchcross_fields): the fields behave like one big field — each term scores by its best field and the terms OR together, so a query whose terms are spread across columns ('bob smith'againstfirst_name/last_name) still matches and ranks sensibly. Per-fieldMATCHcomposed with OR is field-centric instead (each field scores the whole query).MATCH_BEST(col, col, …, 'text')— field-centric dis-max over an explicit column subset (ESmulti_matchbest_fields): a row matches if any listed column matches and scores as its best single field. The same match set as OR-ing per-fieldMATCHes, spelled in one predicate.
Term-level pattern predicates (not analyzed — they run against the indexed terms, so with a lowercasing analyzer write patterns lowercase):
MATCH_PREFIX(col, 'qu')— term prefix (ESprefix).WILDCARD(col, 'qu*ck')—*any run,?any one char (ESwildcard).REGEXP(col, 'qu.[ck]+')— regular expression (ESregexp).
Composition: search predicates combine freely with AND/OR/NOT
among themselves (ES bool must/should/must_not); ordinary SQL
conditions join at the top level with AND and filter the hits afterward.
BOOSTED(required, optional…) adds an optional-scoring shape: the
required predicate decides which rows match, and each optional
predicate only raises the score of rows that already match (tantivy
Must + Should — ES bool must + should under the default
minimum_should_match: 0). Every argument must itself be a search
predicate.
Mixing a search predicate with an ordinary condition under OR/NOT is
rejected — the index cannot serve it. A NOT search returns only rows the
index knows: a row with none of the indexed columns present is never
returned.
Multi-field scoring is dis-max (a row scores as its best field, ES
best_fields), with per-column boosts applied.
Similarity & suggestions: MORE_LIKE_THIS(col, 'like text') finds
textually similar rows (the like-text's most distinctive terms by
in-index IDF, OR-ed — permissive defaults so short like-texts work).
SUGGEST '<text>' ON <index> returns per-token "did you mean" terms from
the index dictionary (Levenshtein ≤ 2, doc-frequency ranked); completion
/search-as-you-type is the edge_ngram + MATCH_PREFIX pattern.
Highlighting: HIGHLIGHT(col [, max_chars [, pre_tag, post_tag
[, no_match_size [, fragments]]]])
in the projection returns the best-scoring snippet of the column's text
(default fragment size 150 chars) with matching terms wrapped in tags
(<b>…</b> by default), other text HTML-escaped. Stemming is respected —
a query for jumping highlights jumps. Only valid together with a
search predicate; highlight multiple columns by calling HIGHLIGHT()
once per column. The snippet re-reads the row's live text (not stored
offsets), so it always reflects the current document.
- Custom tags (ES
pre_tags/post_tags): pass a string pair after the fragment size, e.g.HIGHLIGHT(body, 40, '<em>', '</em>')→slow roasted <em>vegetables</em>. no_match_size(ES): a trailing integer returns that many leading characters (HTML-escaped) when the column had no match, instead of an empty string —HIGHLIGHT(body, 40, '<b>', '</b>', 80).- Multiple fragments (ES
number_of_fragments): a final fragment count (2–10) switches the highlight value to an array of up to that many fragments, in text order, best match-density windows first —HIGHLIGHT(body, 40, '<b>', '</b>', 0, 3). The default (1, or the count omitted) keeps the classic single-string best passage.
Still not supported: a separate highlight_query, sentence/word
boundary scanners, ES number_of_fragments: 0 (whole-field mode), and the
plain/fvh highlighter types (fvh would need stored term-vector
offsets). On the RF<members sorted-scan scatter, remote shards use default
tags and a single fragment (the wire carries only the fragment size);
full-replica clusters and the primary search path use the full options.
Aggregations
Search queries combine with GROUP BY and aggregates like any other SQL,
and GROUP BY g TOP k BY score() returns each group's k best-scoring
rows instead of aggregates — the SQL spelling of ES top_hits
(per-group top documents), with HIGHLIGHT() available in the
projection:
SELECT region, title, score() FROM products
WHERE MATCH(title, 'widget') GROUP BY region TOP 3 BY score();
SELECT region, COUNT(*), SUM(units), AVG(price) FROM sales
WHERE MATCH(product, 'widget') GROUP BY region;
SELECT COUNT(*), MAX(price) FROM sales
WHERE SEARCH('+widget -clearance'); -- one global row
Two serving paths produce identical results:
- Fast-field pushdown (no row materialization): global aggregates
(
COUNT(*)/COUNT(col)/COUNT(DISTINCT col)/SUM/AVG/MIN/MAXover declared columns, no GROUP BY), grouped counts over atime_bucket(step, col)date histogram, and keyword-grouped metric aggregations — GROUP BY a declaredkeywordcolumn or a text column with a.keywordtwin (the twin's raw string is exactly the value the row path groups by), with any mix ofCOUNT/COUNT(col)/SUM/AVG/MIN/MAXover declared numeric columns. Grouped metrics run as a manual fold over fast-field columns (matching doc set → per-segment ord-indexed accumulators), not tantivy's aggregation module — whose 0.26.1 sub-aggregation data-loss bug (small buckets lose metric input on periodic flushes while doc counts stay exact) keeps date-histogram metrics and grouped distincts on the row fallback until fixed upstream.COUNT(DISTINCT)is exact — a terms-bucket count, never an HLL (the opt-inAPPROX_COUNT_DISTINCT()is the HLL: it pushes down as a cardinality sketch and never bails on wide term sets — on a single index; the sharded scatter serves both distinct forms from exact term sets instead, see below). - Row fallback: everything else — date-histogram metrics, grouped
distincts, residual predicates, HAVING, ORDER BY — gathers the matching
rows (deduped by key at the coordinator, so correct at any replication
factor) and runs the ordinary grouped executor. The gather is bounded
by the scan budget (past it, the error names the fix: declare the
group column a keyword fast field), and a
GROUP BYon a column not on the index at all fails fast instead of gathering — it can never be answered index-side, and on a large match set the silent gather tied a production coordinator up for the full statement timeout. Group without a search predicate to aggregate off-index columns row-side.
SQL semantics hold on both paths: rows missing the group column form the
NULL group, SUM over no values is NULL (not 0), and metric types
follow the column declarations (SUM of a long is
an integer). The pushdown is exact or declined — on a truncated bucket
list or any count mismatch (e.g. a date histogram that would lose rows
missing the date column) it silently falls back rather than approximate.
Cluster mode pushes down when one index holds every row (single node, or
RF ≥ member count). Sharded corpora (RF < members) scatter partials:
every document carries its placement hash in a _ring fast field, each
member aggregates only the hash arcs it primarily owns (the arcs tile the
key-space, so every key counts exactly once regardless of replication),
and the coordinator merges. Exact-or-decline throughout: the scatter runs
only for losslessly mergeable metrics (COUNT(*)/COUNT(col)/SUM/
MIN/MAX, globally and per keyword bucket — grouped partials merge by
bucket key), requires a stable membership epoch across the whole gather
and every member answering — a silent peer, an epoch change, or a
membership change in flight (dual-ring placement) falls back to the
deduped row gather. AVG scatters as SUM+COUNT pairs and the coordinator
divides after the merge. Ungrouped distinct counts scatter too:
per-shard final answers cannot merge (a value present on several shards
would double-count), so each member ships its mergeable intermediate
term-set state and the coordinator merges those — COUNT(DISTINCT)
stays exact across shards, and APPROX_COUNT_DISTINCT rides the same
term sets (rewritten for the scatter like AVG), so on a sharded corpus
it is exact within the same 65,536-distinct-terms bound and falls back
to the row gather beyond it. Grouped distincts keep the fallback (the
upstream sub-aggregation bug above). Sorted top-k (ORDER BY <fast
column> LIMIT k) scatters the same way — each member resolves its
primary-owned top k (highlights included) and the coordinator k-way
merges; a residual SQL filter declines the scatter (filters don't
travel). Per-hit explain routes to a replica of the key (ring
order), so both "explain": true and the SQL spelling —
EXPLAIN SCORE SELECT ... WHERE MATCH(...) FOR <pk> — work at any RF.
One typing nuance:
time_bucket pushdown keys are timestamps (a date column's semantics),
while the fallback preserves each row's stored type — store timestamp
values (not bare integers) in date columns for consistent typing.
Hybrid search (RANK BY RRF)
Fuse a full-text leg and a vector leg in one query by Reciprocal
Rank Fusion — the SQL analogue of Elasticsearch's rrf retriever. The
NEAREST clause is the vector leg, the WHERE search predicate is the text
leg, and RANK BY RRF fuses them:
SELECT id, rrf_score() FROM docs
NEAREST (embedding, [1.0, 0.0, 0.0], 100) -- vector leg (100 candidates)
WHERE MATCH(body, 'quick brown fox') -- text leg
RANK BY RRF -- default constant 60
LIMIT 10;
Each leg fetches the NEAREST k candidates; a hit at 1-based rank r in a
leg contributes 1 / (c + r) to its fused score (rrf_score()), so a doc that
ranks well in both legs beats one strong in only a single leg. Fusion is
rank-based, so BM25 scores and vector distances need no normalization.
RANK BY RRF (c) overrides the constant. The residual (non-search) part of
WHERE filters both legs. Cluster-wide: each leg scatter-gathers to a
coordinator-merged ranked list, then the coordinator fuses. Requires both a
NEAREST clause and a search predicate; no JOIN/UNION/DISTINCT/GROUP
BY/ORDER BY (ordering is rrf_score() desc).
Reranking (RERANK)
Second-stage relevance — the SQL analogue of Elasticsearch's
text_similarity_reranker retriever. The top candidates of a search (or
hybrid) retrieval are re-scored by an external cross-encoder model and the
page is served in the reranker's order:
SELECT id, title, score() FROM docs
WHERE MATCH(body, 'how do I tune compaction')
RERANK TOP 100 -- send the top 100 BM25 hits to the rerank endpoint
LIMIT 10;
Needs [inference] rerank_url (a Cohere/Jina/TEI-compatible endpoint — wire
contract and config in VECTOR.md). Full clause syntax, defaults,
and constraints: QUERY_SYNTAX.md. Key
properties:
- Opt-in per query — a down rerank endpoint fails only
RERANKqueries, never writes or ordinary searches. - Coordinator-side, cluster-wide — the reorder happens over the already
scatter-gathered candidate rows; one rerank HTTP call per query, bounded by
TOP(default 100, cap 1000) and 4000 chars per document. score()reads the rerank score; on a hybrid queryrrf_score()still reads the fusion score.- Composes with
RANK BY RRF(reranks the fused list) andHIGHLIGHT.
Deep pagination (AFTER / search_after)
Stable keyset pagination beyond OFFSET — SQL AFTER (<last sort value>,
<last pk>) on a search query ordered by score() DESC or a single column
(full syntax and rules: QUERY_SYNTAX.md).
Every sorted search page tie-breaks by primary key ascending, so pages
and cursors always agree, ties included. ES clients use the standard
workflow: sort by [<key>, {"_id": "asc"}], then echo the previous page's
last hit sort array as search_after — each sorted hit carries its sort
values, with JSON float types round-tripping exactly. A point-in-time (PIT)
pin is not implemented; the ES caveat about score-sorted deep paging under
concurrent index changes applies here too.
ES-compatible REST subset
The REST endpoint speaks enough Elasticsearch for existing ES client
libraries and log shippers (not Kibana). An ES "index" is a skaidb
table; its SEARCH INDEX is the mapping; _id maps to the table's
single-column primary key (stored as a string, auto-generated when a bulk
action omits it). Pre-create the table + search index for full control —
or let _bulk auto-create an unknown index ES-style: primary key
id plus a dynamic mapping from the first document (strings → text,
integers → long, floats → double, bools → bool; null/array/object
fields are stored but not indexed).
POST /{index}/_bulk index / create / delete NDJSON actions
(auto-creates an unknown index, see above)
POST /{index}/_search query DSL: match, match_phrase, prefix, wildcard,
regexp, fuzzy, term, terms, range, exists, bool
(must/filter/must_not/should — should beside
must/filter boosts scores via BOOSTED(), or is
required with minimum_should_match: 1),
query_string, more_like_this, multi_match
(best_fields / most_fields / cross_fields),
geo_distance / geo_bounding_box → the SQL
geo predicates (GEO.md; a geo index prunes
transparently) — distances take ES unit
suffixes ("5km", "1mi", …; bare number =
metres), points are {lat, lon} objects,
[lon, lat] GeoJSON arrays, "lat,lon" strings,
or WKT POINT (geohashes unsupported), boxes
take corner pairs or flat top/left/bottom/
right edges;
"explain": true per-hit BM25 breakdowns; from/size,
multi-key sort (incl. _score; sorted hits carry
their `sort` values), search_after deep paging
(sort [<key>, {"_id": "asc"}] + the previous
page's last hit `sort` array; not with from > 0
or knn/retriever; needs a full-text query),
_source with
include/exclude lists (trailing-* globs),
highlight (incl. number_of_fragments > 1 →
fragment arrays), exact totals; aggs: terms,
date_histogram (+ sum/avg/min/max/value_count/
cardinality/percentiles sub-aggs and top_hits —
top_hits runs one relevance-ordered query per
retained bucket), bare metrics incl.
percentiles (exact, linear-interpolated;
percents default to ES's), composite
(multi-source terms/date_histogram buckets,
ascending keys, after/after_key pagination;
metric sub-aggs yes, top_hits no);
vector retrieval: a top-level knn block
{field, query_vector | query_vector_builder,
k, filter} → NEAREST (a query_vector_builder
text searches a managed EMBED index, auto-
embedded); a retriever {rrf {retrievers:
[standard, knn]}} block → NEAREST + WHERE-search
RANK BY RRF (rank_constant → the RRF constant);
a retriever {text_similarity_reranker
{retriever: standard | knn | rrf, field,
inference_id, inference_text,
rank_window_size}} block → RERANK (field → ON,
inference_id → WITH, inference_text → QUERY,
rank_window_size → TOP, default 10; hit _score
is the rerank score)
POST /{index}/_count exact match count
GET /{index}/_doc/{id} fetch one document by _id
GET /{index}/_mapping the search-index declaration as ES properties
Everything translates to the same SQL statements documented above and
runs through the ordinary session path — HTTP Basic auth, RBAC, cluster
routing, and all pushdowns apply unchanged. Limits:
minimum_should_match above 1 is rejected, and bool.should beside a
must/filter with no search clause cannot be scored (set
minimum_should_match: 1 to make the shoulds required); clients that
hard-check the X-elastic-product header need that check disabled.
cardinality is skaidb's exact COUNT(DISTINCT), not an HLL
approximation. For knn/retriever queries num_candidates is ignored
(HNSW breadth is an index property — tune it with ALTER VECTOR INDEX …
SET (ef = n)), the hit _score is the fused rrf_score() for a
retriever or a 1/(1+distance) similarity for a plain knn, and the
total is the number of ranked hits (≤ k), not a full match count.
Architecture
- Why Tantivy (decision record): Lucene is a JVM library — embedding a
JVM contradicts the single static Rust binary, and out-of-process Lucene
is just running Elasticsearch. Tantivy is Lucene's architecture re-done
in Rust (MIT, a plain dependency,
forbid(unsafe)on our crates unaffected): immutable segments, skip-list postings, positional indexes, FST term dictionaries, columnar fast fields, BM25 — and public benchmarks have it matching or beating Lucene per core. A native engine stayed on the table (skaidb built its own TSDB and HNSW), but FTS parity is 10–20× the surface of either; hence the thin crate boundary below, which keeps a native replacement possible without touching engine/SQL. skaidb-ftscrate wraps Tantivy behind an engine-agnostic API (skaidbDocuments in,(key, score)hits out); no Tantivy types cross the crate boundary, so the engine and SQL layers stay independent of the search core.- Derived data over the LSM table, like the vector indexes: the index
registers in the catalog (schema-version prefix
s:<name>, replicated like other DDL), and everyput/deletemaintains it alongside secondary and vector indexes. The table remains the source of truth — a lost, stale, or mis-configured index rebuilds from the table. - Durability — the row WAL is the translog. Index writes apply
immediately but commit lazily (on the
refresh_mscadence). Each commit atomically persists the max row HLC it contains (the watermark) as the Tantivy commit payload. On open, the engine replays table rows (and tombstones) newer than the watermark into the index — so a crash, or a clean shutdown with uncommitted index writes, loses nothing. There is deliberately no commit-on-shutdown: the replay path runs on every open, keeping recovery constantly exercised. - Storage layout: Tantivy segments live under
<data_dir>/fts/<index>/, mmap'd for search (evictable pages — reads cost no memory budget). The writer heap is bounded per index: 64 MB by default, ormemory_target/8 clamped to [16 MB, 64 MB] when a budget is set (peak RSS during a bulk build ≈ 1.5× the heap). - Bulk ingest: a multi-row statement (and a replicated batch, including
the async replication frames) feeds every search index in one pass with a
single NRT refresh check at the end — an index commit never fires
mid-batch. Dev-box reference (100 k-row synthetic corpus,
skaidb-engine/examples/fts_bench): ≈ 126 k rows/s ingest with the index live (batched) vs ≈ 81 k rows/s per-row; ranked top-10 ≈ 1.2 ms p50. - Query pushdown:
ORDER BY score() DESC LIMIT kretrieves top-k directly from the index (early-terminated, no scan). ResidualWHEREconditions are applied after the authoritative row re-read, with over-fetch to keep k results (the vector-search discipline). AMATCHused as a plain predicate (no ranking) retrieves the matching key set from the index. - Cluster (the vector-search pattern): DDL broadcasts, and every member
indexes its shard locally from replicated writes — the replicated apply
paths (
apply_put/apply_delete, batched) maintain search indexes, so replication, rebalance, drain, hinted replay, and anti-entropy repair all keep the index in step with the table for free. A query scatters to all members; each answers with its local(key, score)top-k after committing pending index writes (writes replicate synchronously at the write consistency, so every acked write is searchable cluster-wide, not just NRT). The coordinator merges by score (keeping a replicated row's best per-shard score), re-reads survivors at read consistency, applies the residual filter, and generates highlight snippets from its own index. An unreachable member is skipped — its rows still surface through reachable replicas. Scoring uses per-shard BM25 statistics (the standard distributed-index default); a two-phase global-stats mode is a later phase. Post-reshardingReclaimleaves stale postings for moved-away keys — harmless (the authoritative re-read resolves them) and reclaimed byREBUILD SEARCH INDEX.
Observability
SHOW INDEXESlists search indexes with their analyzer and columns.SHOW STATUSrows:search_indexes,search_docs,search_rebuild_ms, and per-indexsearch.<name>.{docs,disk_bytes,uncommitted}./metricsgauges:skaidb_search_indexes,skaidb_search_docs_total,skaidb_search_disk_bytes,skaidb_search_rebuild_seconds.
Limits
- Search predicates compose with
AND/OR/NOTamong themselves; mixing them with ordinary conditions underOR/NOTis rejected (top-levelANDwith ordinary conditions works — they filter the hits). ORDER BY score()orders descending only (and requiresLIMIT). Column orderings work: declared fast-field columns withLIMITretrieve index-ordered top-k (declining to an exact gather-and-sort when matching rows lack the sort column — SQL NULL placement differs from the index's); anything else gathers and sorts through the ordinary executor.- No
JOIN,UNION,DISTINCTin the same query. A search predicate combines withNEAREST(vector) only through hybridRANK BY RRF(see below), not as a boolean sibling. - Per-shard BM25 statistics (the standard distributed-index default); there is no global-stats mode. Scoring targets result-set stability, not identical score floats — BM25 constants and length normalization differ subtly between engines.