Search and retrieval
RelataDB combines search with governance, time, identity, and provenance.
Search surfaces
| Surface | Purpose |
|---|---|
| BM25/full-text | keyword search and snippets |
| Vector search | similarity over embeddings. Since v1.1 embeddings are caller-supplied — either pre-compute and send _emb_text in the row payload, or run the embedder sidecar so the media-worker drain cycle populates them post-ingest. SIMILAR ranks by multi-vector max-pool over all _emb_* slots and resolves the seed by id or _pk. |
| Image near-dup | perceptual-hash (aHash+dHash) match. Images hash in-tree (pure-Rust decode, no sidecar), so a re-encoded/edited copy is found within a small Hamming distance out of the box; video/audio fingerprints still need the decoder sidecar. |
| Hybrid retrieval | combine lexical, vector, and graph signals |
| Identity lookup | retrieve by canonical identifiers and identity variants |
| Graph traversal | relationship/path retrieval |
| Temporal filters | retrieve as of valid-time or system-time |
| Provenance filters | inspect where assertions came from |
Ask in plain English (no LLM required)
The nl_query MCP tool / REST endpoint translates a plain-English question to
governed SQL and executes it. With no RELATA_LLM_URL configured a
deterministic in-tree translator handles a documented set of intents — so
analysts get structured answers with zero external dependencies. Set
RELATA_LLM_URL (Ollama / vLLM / LM Studio) for richer free-form parsing.
Supported deterministic phrasings:
| Intent | Say | Runs |
|---|---|---|
| Graph path | "paths between X and Y", "path from X to Y" | PATHS_BETWEEN('X','Y', MAX_HOPS => 5) |
| Ownership | "ownership chain of C", "who owns C", "beneficial owner of C" | BENEFICIAL_OWNERSHIP_CHAIN('C', MAX_DEPTH => 5) |
| Sanctions | "sanctions screen N", "is N sanctioned" | SANCTIONS_SCREEN('N', THRESHOLD => 0.85) |
| Browse | anything without an intent verb | SELECT * FROM <Type> LIMIT 50 |
A recognised intent whose entities can't be extracted returns a clear "couldn't translate; try …" error — it never guesses and runs the wrong query. The active translator (local vs LLM) is reported in the startup posture block.
World-class search UX
RelataDB's search engine includes features inspired by Meilisearch and
Typesense. The /search REST endpoint accepts JSON parameters for
fine-grained control.
Subword tokenization
camelCase, PascalCase, and letter↔digit transitions are split during
tokenization so "iPhone14Max" produces tokens ["i", "phone", "14", "max"]. This means "phoneNumber" is findable by searching "phone".
snake_case and kebab-case already split via the non-alphanumeric pass.
Applied symmetrically at index and query time.
Matching strategy
Controls which query terms are required vs optional when scoring documents. All strategies still BM25-rank results; the difference is which documents are eligible.
| Strategy | Behaviour | Best for |
|---|---|---|
any (default) | Every term is optional (OR) — pre-default behaviour | High recall |
all | Every term must be present (AND) | Precision queries |
last | Only the last tokenised term is required; earlier terms optional | Search-as-you-type |
frequency | Rarest terms required; common terms optional | Mixed rare + common queries |
boolean | Query text carries explicit AND/OR/NOT operators | Exact-combination queries |
curl -s -X POST http://127.0.0.1:9090/search \
-H 'Content-Type: application/json' \
-d '{"query":"alice wonderland","type":"Person","limit":10,"matching_strategy":"all"}'Boolean operators — with "matching_strategy": "boolean", uppercase
AND / OR / NOT in the query text combine per-term BM25 matches as
posting-list set operations (left-associative; a bare space means OR):
curl -s -X POST http://127.0.0.1:9090/search \
-H 'Content-Type: application/json' \
-d '{"query":"sanction OR ofac NOT expired","type":"Alert","limit":10,"matching_strategy":"boolean"}'The same grammar is reachable from SQL as MATCH(col, 'q', BOOLEAN) and from
the typed /search door as rank_by: ["boolean", "<column>", "<query>"].
Per-query typo tolerance
Override the global RELATA_SEARCH_PRESET per query. Gate fuzzy expansion
by word length, disable specific words, or turn typos off entirely.
{
"query": "john smith",
"type": "Person",
"limit": 10,
"typo_tolerance": {
"enabled": true,
"min_word_size": 5,
"disable_on_words": ["smith"],
"disable_on_attributes": ["email"]
}
}| Field | Default | Description |
|---|---|---|
enabled | true | Master toggle. false = exact-match only. |
min_word_size | 0 | Words shorter than this stay exact (Meili default: 5 for 1-edit, 9 for 2-edit). |
disable_on_words | [] | Specific query terms that should not be fuzzy-expanded. |
disable_on_attributes | [] | Field names where typo tolerance is disabled. |
Typo tolerance in SQL MATCH — POST /query
The same typo_tolerance object is accepted by POST /query alongside the
SQL text. When set, SQL MATCH / PHRASE / SUFFIX / INFIX conditions
apply per-token levenshtein fuzzy matching:
1 edit for words ≥ min_word_size (default: any length), 2 edits for
words ≥ 9 chars.
POST /query
{
"sql": "SELECT * FROM Person WHERE MATCH(name, 'jon')",
"typo_tolerance": {
"enabled": true,
"min_word_size": 3
}
}This will also return rows where name contains "john" (1-edit match).
BM25F per-field weights
Boost results where a query term appears in high-priority fields (e.g. title
outweighs body). Pass fieldWeights — a map of field names to f32
multipliers. The BM25 score is scaled by the highest weight of any named
field that contains a query term. Fields absent from the map default to 1.0.
{
"query": "database",
"type": "Article",
"limit": 10,
"fieldWeights": {"title": 3.0, "body": 1.0, "tags": 5.0}
}Rows whose query terms appear only in tags are scaled ×5; rows where the
term appears in title are scaled ×3; rows with the term only in body are
unaffected. Omitting fieldWeights (or passing {}) leaves all scores
unchanged.
Faceted search
The FACETS SQL clause returns per-attribute value counts. Facets are
computed over the full matching set (up to 100 000 rows), not just the
top-k returned rows — so browse-UI facet counts are accurate even when
LIMIT is small.
For numeric facet columns, the response also includes facetStats with
min, max, sum, avg, and count:
{
"facets": {"status": {"active": 42, "closed": 7}},
"facetStats": {"amount": {"min": 100, "max": 50000, "sum": 125000, "avg": 2314, "count": 54}}
}Estimated total hits
Both /query and /search responses include estimatedTotalHits — the
full matching-set size (accurate up to the 100k facet cap; a lower bound
otherwise) — alongside count/rows (the actual returned rows). Use this
for pagination UX.
Multi-search / federated
POST /multi-search runs N independent queries in one round-trip and
returns combined results in input order — useful for dashboard widgets and
federated type searches:
curl -s -X POST http://127.0.0.1:9090/multi-search \
-H 'Content-Type: application/json' \
-d '{"queries":[{"query":"alice","type":"Person","limit":5},{"query":"transfer","type":"Transaction","limit":10}]}'Typed query door — namespace().query() (no SQL)
For the search / RAG persona, /search accepts a second body shape — a
typed JSON query that compiles to a governed SQL plan server-side, so you never
hand-build SQL. Governance (purpose, ACL, cell masking, audit) is identical to a
hand-written query. The typed shape is detected by the absence of a query
key (its presence keeps the legacy Meilisearch shape above on the same route).
curl -X POST http://127.0.0.1:9090/search \
-H 'Content-Type: application/json' \
-d '{"from":"Document",
"rank_by":["bm25","title","graph retrieval"],
"filters":[{"field":"status","op":"eq","value":"published"}],
"include_attributes":["id","title"],"limit":10}'
# returns the governed /query envelope: {rows, columns, ...}| Field | Description |
|---|---|
from (alias type) | Object type to query. |
rank_by | ["bm25"|"text","<column>","<query>"] (BM25 full-text, ORDER BY _score DESC), or ["vector","ann","<query>"] (HYBRID_SEARCH — server embeds the text). |
filters | [{field, op, value}] (ops eq|ne|gt|gte|lt|lte|like|ilike|in|between), a {and:[...]} / {or:[...]} object, or a single condition. |
include_attributes | Column projection (default *). |
consistency | "strong" (default) | "eventual" (forward-compat). |
compute_attributes | {label: rank_expr} compiled to a trailing COMPUTE clause — see below. |
Python SDK — flagship namespace() surface
from relata import RelataClient
with RelataClient("http://localhost:9090", purpose="rag") as client:
docs = client.namespace("Document")
docs.write([{"id": "d1", "title": "Knowledge graphs 101", "body": "..."}]) # schemaless (POST /ingest/auto)
res = docs.query(
text="graph retrieval",
match_column="title",
filters=[{"field": "status", "op": "eq", "value": "published"}],
limit=10,
)
for row in res:
print(row["title"]).write() is schemaless: POST /ingest/auto auto-creates the type on first
write with field types inferred from the rows — no DDL. Async mirrors
(AsyncNamespace, await docs.query(...)) and one shared connection pool across
every namespace.
Composable ranking + COMPUTE side outputs (SQL)
In SQL, ranking is a full expression tree and per-hit signals are a COMPUTE
clause (the typed door's rank_by/compute_attributes lower to these):
-- Blend BM25 with a saturated popularity signal, a time decay, and geo distance.
SELECT * FROM Article WHERE MATCH(content, 'ai')
RANK BY SUM(BM25(), PRODUCT(0.5, SATURATE(popularity, 100, 2)),
DECAY(published_at, 86400, 1), DIST(location, 1.0, 2.0))
LIMIT 10;
-- Attach per-hit signals without changing matching or order.
SELECT * FROM Doc WHERE MATCH(content, 'ai')
RANK BY VECTOR()
COMPUTE bm25_score AS BM25(), snippet AS HIGHLIGHT(content)
LIMIT 10;Ranking primitives: SUM / MAX / PRODUCT / SATURATE / DECAY /
ATTRIBUTE(name) / DIST(attr, x, y, …) / BM25() / VECTOR().
HIGHLIGHT(field) is compute-only (renders a snippet).
Namespace branching + pinning
- Branch — constant-time COW:
BRANCH dev FROM main(SQL) orPOST /v1/namespaces/dev/branchwith{"branch_from":"main"}. See Branching & Namespaces for the full surface (including how namespace branches differ from schema branches and sub-tenant namespaces). - Pin —
RELATA_PINNED_NAMESPACES=acme,contosoreserves a dedicated NVMe cache slice per hot namespace so its index is evict-immune under pressure.
Multi-query batch + reciprocal-rank fusion (RRF)
One round-trip, up to 16 typed subqueries, fused with rank-based RRF. This is the "build a search dashboard in one call" feature — multiple ranking strategies against one snapshot, merged into a single ranked list.
POST /search with {"queries": [...], "rerank_by": ["RRF", {"rank_constant": 60}], "limit": 20}:
curl -X POST http://127.0.0.1:9090/search \
-H 'Content-Type: application/json' \
-d '{
"queries": [
{"from":"Document", "rank_by":["bm25","title","graph retrieval"], "limit":50},
{"from":"Document", "rank_by":["field_weight",{"title":3.0,"body":1.0}, "graph retrieval"], "limit":50},
{"from":"Document", "rank_by":["vector","ann","graph retrieval"], "limit":50}
],
"rerank_by": ["RRF", {"rank_constant": 60}],
"limit": 20
}'What's happening:
- All subqueries are pinned to a single
AS OF SYSTEM TIMEsnapshot, sampled once before fan-out — concurrent writes cannot split results. - Each subquery runs through the same governed
/querypath (PURPOSE, ACL, tenant, cluster fan-out identical to N independent calls). rerank_by: ["RRF", {rank_constant: K}]fuses the per-subquery ranked lists using reciprocal-rank fusion (score = Σ 1/(K + rank_i)). DefaultK = 60(the standard RRF constant). Fused rows carry_rrf_score.- Vector/ANN
rank_byis rejected in batch mode (theAS OFclause isn't in theHYBRID_SEARCHgrammar today) — use BM25 / field-weight subqueries for the batch path.
Response shape:
{
"results": [ {/* per-subquery result set */}, {/* ... */} ],
"as_of_system": 1735490000000000000,
"processing_time_ms": 18,
"fused": {
"rank_constant": 60,
"rows": [ /* merged, ranked, with _rrf_score */ ],
"data": [ /* ... */ ]
}
}Tips & takeaways
- RRF needs ≥ 2 subqueries. A single subquery with
rerank_by: ["RRF"]is rejected — there's nothing to fuse. - Different
rank_bystrategies make RRF sing. Combine BM25 (lexical) +field_weight(weighted BM25F) + filter variants; each contributes a different ranking signal, and RRF is robust to score-scale differences (it uses ranks, not scores). - One snapshot = consistent dashboarding. Because all subqueries share one
AS OF, the facets/counts/hits in each pane are mutually consistent — no flicker from writes landing mid-search. rank_constanttuning: lowerK(e.g. 1) weights top ranks more heavily (good when you trust the top of each list); higherK(e.g. 100) smooths toward a popularity vote. 60 is the literature default and a safe start.- Governance is per-subquery, not bypassed. PURPOSE / ACL / cell masking apply to every subquery independently — a row you can't see in one subquery won't appear in the fused result either.
Cross-ref: Hybrid search (concepts) · Vector params · SQL RANK BY expression · Search & Retrieval reference top
Governance rules
Search is not a policy bypass. Search and retrieval paths must preserve:
- optional purpose (recorded for audit),
- ACL pushdown,
- cell masking,
- provenance handling,
- temporal semantics,
- audit/cost accounting.
Example topics
- Search documents by text.
- Search as of a system time.
- Retrieve with provenance.
- Combine identity lookup with document search.
- Explain why a restricted result is masked or absent.
Validation rule
Any published latency/recall claim must cite a benchmark, conformance row, or reproducible command output.