Hybrid Search
Vector-only search misses exact keyword matches. BM25-only search misses semantic paraphrases. Identity-only search misses documents that don't contain the identity value directly. Relata runs all three signals and fuses them with reciprocal-rank fusion (RRF) — a rank-based combiner that requires no score calibration between signals that live on completely different scales.
The search engine is custom-built. Not Tantivy, not Lucene: integer posting lists with token interning, q-gram prefix/fuzzy search, reverse-trigram suffix search, hand-rolled Snowball-inspired stemmers (en/fr/de/es/pt/it/nl/sv/no/da/fi/hu/ro + Russian Cyrillic, Turkish, Arabic), subword tokenization (camelCase/digit splitting), stop words, synonyms, highlighting, and faceted search (full-match-set counts). The vector engine is a custom HNSW graph in memory, with a DiskANN warm tier for indexes that exceed RAM. Identity matching reuses the IdentityIndex built by SmartIngest.
The three retrieval signals
| Signal | Engine | What it finds that the others miss |
|---|---|---|
| BM25 full-text | Custom inverted index (integer posting lists) | Exact jargon, codes, account numbers typed verbatim |
| Vector similarity | Custom HNSW + DiskANN warm tier | Semantic paraphrase, synonym matches, language variation |
| Identity matching | IdentityIndex MV | The same entity referenced under a different surface form in a different source |
Each signal independently produces a ranked list. RRF combines them: the final score for a result is the sum of 1 / (60 + rank_i) across every signal that surfaced it, where rank_i is the 1-based position in that signal's list. Results that appear in multiple signals naturally bubble to the top. Results unique to one signal still appear — they are not discarded.
HYBRID_SEARCH in SQL
HYBRID_SEARCH is a top-level query form (like LOOKUP_IDENTITY), not a SELECT modifier. The grammar is HYBRID_SEARCH FROM <type> QUERY '<text>' LIMIT <n>:
-- Basic hybrid: BM25 + vector over a single type
PURPOSE 'investigation'
HYBRID_SEARCH FROM Document QUERY 'terror finance' LIMIT 25
-- Governed-RAG alias (same pipeline, RAG-shaped surface)
PURPOSE 'investigation'
RAG_RETRIEVE FROM Document QUERY 'terror finance' LIMIT 25HYBRID_SEARCH runs BM25 over the row's indexed text fields and cosine similarity over the row's stored embedding. The two ranked lists are fused via RRF before results are returned. Optional trailing modifiers:
RERANK— re-score the top-K via a sidecar cross-encoderMETRIC <name>— override the vector distance metricWEIGHTS <g> <b> <v>— per-query fusion weights for the graph, BM25, and vector channels
MATCH operator
MATCH is the pure-BM25 predicate form. Use it when you want keyword filtering without the vector overhead, or when you need one of the specialised modes:
-- Default: token-level BM25 posting list lookup
SELECT * FROM Document
WHERE MATCH(title, 'financial fraud')
-- Phrase: words in this exact order, adjacent positions
SELECT * FROM Document
WHERE MATCH(body, 'money laundering', PHRASE)
-- Fuzzy: edit-distance expansion — catches 'recieve', 'finacial', etc.
SELECT * FROM Post
WHERE MATCH(text, 'recieve', FUZZY)
-- Stemmed: stem-reduced token match (covers 'laundering', 'laundered', 'launder')
SELECT * FROM Document
WHERE MATCH(body, 'launder', STEMMED)
-- Suffix: reverse-trigram index — find strings ending with a pattern
SELECT * FROM Account
WHERE MATCH(account_number, '4242', SUFFIX)| Mode | Index used | Best for |
|---|---|---|
| Default | BM25 integer posting list | Standard keyword search |
PHRASE | Positional index | Exact phrase matching |
FUZZY | Q-gram + edit-distance expansion | Typo tolerance |
STEMMED | stemmed posting list | Morphological variants |
SUFFIX | Reverse-trigram index | Suffix matching (e.g. card last 4) |
The /search endpoint
The universal HTTP search API exposes the full three-signal pipeline with typeahead support, faceting, and hit highlighting. The Python SDK's SearchBuilder is the recommended entry point:
from relata import RelataClient, SearchBuilder
with RelataClient(url, bearer_token=token, purpose="investigation") as client:
results = client.search(
SearchBuilder("money laundering correspondent banking")
.types(["Transaction", "Document", "Alert"])
.limit(25)
.facet("source")
.facet("risk_tier")
.highlight(True)
)
for hit in results.hits:
print(f"{hit.score:.3f} [{hit.type}] {hit.highlight or hit.id}")
# Facet counts
for facet, counts in results.facets.items():
print(facet, counts)HTTP equivalent
curl -X POST http://127.0.0.1:9090/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $RELATA_BEARER_TOKEN" \
-d '{
"query": "money laundering correspondent banking",
"types": ["Transaction", "Document", "Alert"],
"limit": 25,
"facets": ["source", "risk_tier"],
"highlight": true
}'Response envelope: { "hits": [...], "facets": {...}, "took_ms": 12 }. Each hit carries id, type, score, and highlight (the matched snippet with <mark> tags).
BM25 engine internals
The engine was built to avoid two Tantivy/Lucene constraints: floating-point score normalization overhead and the inability to do integer-keyed prefix/suffix matching efficiently.
| Feature | How it works |
|---|---|
| Posting lists | Integer RowId postings — tokens are interned to 32-bit IDs; posting lists are Vec<u32> |
| BM25 params | k1 = 1.2, b = 0.75 (standard Okapi defaults) |
| Prefix search | Q-gram inverted index (bigrams + trigrams) |
| Fuzzy search | Q-gram overlap + edit-distance expansion on candidates |
| Suffix search | Reverse-trigram index (strings stored reversed, prefix-searched) |
| Stemming | Hand-rolled Snowball-inspired suffix strippers (en/fr/de/es/pt/it/nl/sv/no/da/fi/hu/ro + Russian Cyrillic, Turkish, Arabic) |
| Stop words | Per-language lists, applied at index time and query time |
| Synonyms | Configurable per-tenant synonym maps; applied at query time |
| Highlighting | Match-position tracking; returns snippet with <mark> tags |
| Faceted search | Per-facet posting list aggregation with count rollup |
| Custom ranking | Boost functions configurable per type and per field |
Vector search
Vectors are stored in a custom HNSW graph (crates/relata-storage/src/vector.rs). The primary distance metric is cosine similarity. For indexes that exceed available RAM, a DiskANN warm tier (vector_diskann.rs) pages segments to object-store-backed PagedAnnIndex buckets — cold vectors are re-loaded on demand without a full index rebuild.
The IVF cold tier (RELATA_VECTOR_COLD_RESIDENT_MAX, default 100,000 vectors) stages incoming vectors in paged buckets before spilling them to the object store. This means large write batches do not stall while the HNSW graph grows.
ACL-aware vector search
ACL filtering on vector results uses an adaptive strategy:
- Broad principal (allowed rows > 25% of index): post-filter — score all candidates, then apply the ACL bitmap to discard denied results.
- Narrow principal (allowed rows ≤ 25% of index): pre-filter — score only the allowed slots. This prevents the recall cliff that naive post-filtering causes when most of the index is off-limits.
The 25% threshold is tuned so that wide-access principals (analysts with most-row access) keep the fast path, while compartment-restricted principals get correct recall even on selective ACLs.
Search presets
RELATA_SEARCH_PRESET controls the BM25 fuzzy expansion aggressiveness. It applies uniformly to MATCH, HYBRID_SEARCH, and /search — no per-query override needed.
| Preset | Behaviour | Use when |
|---|---|---|
strict | Minimal fuzzy expansion; exact matches dominate | High-precision queries over structured data |
balanced | Moderate expansion (default) | General investigation and discovery |
lenient | Aggressive expansion; maximises recall | Broad exploration over noisy or user-generated text |
Change it at runtime without restarting the server:
RELATA_SEARCH_PRESET=lenient cargo run -p relata-cli -- serveQuery result cache
Repeated identical search queries (same query string, same types, same principal) are served from the result cache (relata-query::result_cache) without re-running the pipeline. Cache-aside reads use WITH CACHE in SQL:
PURPOSE 'fraud'
HYBRID_SEARCH FROM Document QUERY 'fraud indicators' LIMIT 25
WITH CACHEOptional WITH CACHE knobs: TTL <secs>, STALENESS <secs>, and BYPASS (skip the cache for fresh results):
PURPOSE 'fraud'
HYBRID_SEARCH FROM Document QUERY 'fraud indicators' LIMIT 25
WITH CACHE BYPASSThe cache is invalidated on any write to the types covered by the query.
Agent memory recall
The recall cognitive verb runs the same three-signal pipeline, then applies a fourth re-scoring pass: an additive relevance/recency/forgetting blend gated by confidence × class_weight. There is no separate search engine for agent memory — memory items are rows in the same store. See Agent Memory.
See also
- Identity Resolution — the third retrieval signal; how
IdentityIndexis built - Agent Memory —
recallruns on the same pipeline - Governance — ACL-aware pre-filtering for narrow principals
- Query Engine — how the planner lowers
HYBRID_SEARCHandMATCH - SQL Reference — full operator syntax and
WITH CACHEoptions