Skip to main content
Version: 0.9

Hybrid & Lexical Search

The engine's KV store (LanceDB-backed) supports three retrieval modes: vector similarity, lexical (full-text BM25), and hybrid (weighted blend of the two).

When to use which mode

ModeStrengthWeaknessUse for
VectorSemantic similarity ("happiness" matches "joyful")Misses exact matches; weak on rare proper nounsFuzzy semantic recall, RAG, NPC memory
LexicalExact term match, BM25-style relevanceNo semantic understandingItem names, place names, dialogue lookups
HybridBothMore compute per queryMost game-like retrieval — players say "where is the Sword of Arrows" — names matter, but so does intent

Setup: create an FTS index

A full-text-search index must be created once per (store, column) pair before lexical or hybrid queries can use it. Typical column choices: key_text (the search keys) or any string-typed value column.

import atelico
engine = atelico.Engine()
# ... create the store and insert entries ...
engine.kvstore_create_fts_index("memories", "key_text")

The index is persisted with the LanceDB table — restart the engine and it's still there.

import json

query = json.dumps({
"text": "sword of arrows",
"column": "key_text",
"filter": None, # optional SQL WHERE clause
"limit": 10,
})
rows = json.loads(engine.kvstore_lexical_query("memories", query))
for r in rows:
# combined_score carries the BM25 score
print(r["key_text"], r["combined_score"])

The hybrid query takes both an embedding vector and a query text. Each branch returns up to per_branch_limit candidates (defaults to 2 * limit); scores are min-max normalised independently and combined as vector_weight * vec_norm + lexical_weight * lex_norm. Items missing from one branch contribute 0 to that branch's component.

import json

query = json.dumps({
"embeddings": query_vector, # list[float] of dim matching the store
"text": "sword of arrows",
"fts_column": "key_text",
"filter": "category = 'weapon'", # optional SQL WHERE
"limit": 5,
"per_branch_limit": 0, # 0 = auto (2 * limit)
"vector_weight": 0.6,
"lexical_weight": 0.4,
})
response = json.loads(engine.kvstore_hybrid_query("memories", query))

for row, trace in zip(response["results"], response["scores"]):
print(f"{row['key_text']} merged={trace['merged_score']:.2f} "
f"vec={trace['vector_score']} lex={trace['lexical_score']}")

Hybrid scoring details

  1. Per-branch min-max normalisation. Vector distances are inverted to similarities first.
  2. Combined score: vector_weight * vec_norm + lexical_weight * lex_norm. Items missing from one branch contribute 0 to that branch's component (so a row that only matched lexically still scores; it just doesn't get the vector boost).
  3. Sort descending, truncate to limit.

The scores array returned alongside results carries per-row component scores so callers can trace why a row ranked where it did — vector-driven, lexical-driven, or both.

Where to look in the code

  • atelico-search/src/query.rsSearchMode, LexicalSearchQuery, HybridSearchQuery, HybridScoreTrace.
  • atelico-search/src/store.rsSearchStore::{create_fts_index, lexical_search, hybrid_search} and the merge_hybrid reranker.
  • atelico-search/src/kv_store.rs — KvStore wrappers used by the SDK and bindings (create_fts_index, lexical_query, hybrid_query).
  • atelico-search/tests/test_hybrid_search.rs — end-to-end tests against a real tempdir LanceDB.