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

SignalEngineWhat it finds that the others miss
BM25 full-textCustom inverted index (integer posting lists)Exact jargon, codes, account numbers typed verbatim
Vector similarityCustom HNSW + DiskANN warm tierSemantic paraphrase, synonym matches, language variation
Identity matchingIdentityIndex MVThe 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 25

HYBRID_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-encoder
  • METRIC <name> — override the vector distance metric
  • WEIGHTS <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)
ModeIndex usedBest for
DefaultBM25 integer posting listStandard keyword search
PHRASEPositional indexExact phrase matching
FUZZYQ-gram + edit-distance expansionTypo tolerance
STEMMEDstemmed posting listMorphological variants
SUFFIXReverse-trigram indexSuffix 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.

FeatureHow it works
Posting listsInteger RowId postings — tokens are interned to 32-bit IDs; posting lists are Vec<u32>
BM25 paramsk1 = 1.2, b = 0.75 (standard Okapi defaults)
Prefix searchQ-gram inverted index (bigrams + trigrams)
Fuzzy searchQ-gram overlap + edit-distance expansion on candidates
Suffix searchReverse-trigram index (strings stored reversed, prefix-searched)
StemmingHand-rolled Snowball-inspired suffix strippers (en/fr/de/es/pt/it/nl/sv/no/da/fi/hu/ro + Russian Cyrillic, Turkish, Arabic)
Stop wordsPer-language lists, applied at index time and query time
SynonymsConfigurable per-tenant synonym maps; applied at query time
HighlightingMatch-position tracking; returns snippet with <mark> tags
Faceted searchPer-facet posting list aggregation with count rollup
Custom rankingBoost functions configurable per type and per field

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 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.

PresetBehaviourUse when
strictMinimal fuzzy expansion; exact matches dominateHigh-precision queries over structured data
balancedModerate expansion (default)General investigation and discovery
lenientAggressive expansion; maximises recallBroad exploration over noisy or user-generated text

Change it at runtime without restarting the server:

RELATA_SEARCH_PRESET=lenient cargo run -p relata-cli -- serve

Query 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 CACHE

Optional 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 BYPASS

The 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