Concepts
Available

Hybrid retrieval

Dense vectors, BM25, sparse vectors and filters in one query plan, fused into one ranked list.

Hybrid retrieval means answering one question with several retrievers at once, typically a vector search (meaning) and a BM25 text search (exact words), restricted by filters and fused into one ranked list. In Loams it is one request and one query plan, over one collection, at one consistent snapshot.

POST /v1/namespaces/acme/query
{
  "from": "collections.memories",
  "retrieve": [
    {"vector": {"field": "embedding", "query": [0.12, 0.34, …], "k": 100}},
    {"text":   {"field": "body", "query": "refund policy for enterprise", "k": 100}}
  ],
  "filter": {"and": [{"term": {"tenant": "t42"}}, {"range": {"ts": {"gte": "now-30d"}}}]},
  "fuse": {"method": "rrf", "k": 60},
  "limit": 10
}

The same query in SQL, through Flight SQL, the Postgres wire or the REST SQL route:

SELECT _id, body, _score
FROM rrf(vector_search('memories', [0.12, 0.34, 0.56], 'embedding', 100),
         text_search('memories', 'refund policy for enterprise', 'body', 100))
LIMIT 10;

Why we built it this way

Vector search finds "how do I get my money back" for a query about refunds; BM25 finds the document that contains the exact SKU or error code. Production retrieval needs both, and almost always with a filter (this tenant, the last 30 days).

The usual way to get that is three network hops and fusion in application code: BM25 from the search cluster, nearest neighbours from the vector database, then merge in Python. Nothing can plan that as a whole. The filter is applied twice, with different semantics. The two lists come from two snapshots taken at different times. And the slowest store sets the latency.

Our opinion: retrieval is a query, and it should be planned like one. When the filter, both retrievers and the fusion are operators in one plan, the engine can push the filter into both retrievers, read one snapshot for all of them, fetch documents only for the final top k, and report one set of timings.

One plan: the filter is computed once and pushed into every retriever, and documents are fetched only for the final top k. Expansion and rerank are planned stages of the same plan.

How it works

The filter becomes a bitmap. Predicates on indexed keyword, numeric, date, boolean and UUID fields are evaluated against Tantivy's fast fields into a bitmap of allowed rows. Every retriever receives it.

The vector retriever picks a strategy by selectivity. For a very selective filter (a few thousand rows), it scores the allowed rows exactly by brute force; that is faster than an index and has perfect recall. For a moderate one (up to about 10 % of rows), it pre-filters the index search. For a broad one, it searches the index with over-fetch and filters afterwards, retrying once with more candidates if too few survive. The index is the hot HNSW artifact if the collection is in the hot tier, otherwise Lance's IVF index on the bucket. Tail documents are always scored by brute force. Every returned vector score comes from Loams' own exact kernel, so it does not depend on which index found the candidate.

The text retriever runs BM25 over every split plus the tail. Statistics (document count, document frequency, field lengths) are computed over live documents only, across all splits. Lucene-based engines count deleted documents until a merge; Loams does not, so a document's score does not depend on how its history is laid out in segments.

Sparse vectors (SPLADE-style, or Qdrant's sparse vectors) are a third retriever, scored exactly over postings stored in the splits.

Fusion. The default is reciprocal rank fusion. Each document's fused score is the sum over the lists it appears in of 1 / (k + rank), with k = 60. It needs no score calibration, which is why it is the default: BM25 scores and cosine distances are not on the same scale. Weighted scores and DBSF (distribution-based score fusion, as Qdrant has it) are available when you want to tune.

A worked example with k = 60:

DocumentVector rankBM25 rankRRF score
A141/61 + 1/64 = 0.0320
B2absent1/62 = 0.0161
C511/65 + 1/61 = 0.0318
Dabsent21/62 = 0.0161

A and C, found by both retrievers, rise above B and D, each of which only one retriever found.

Late materialization. Retrievers pass row ids and scores, not documents. Documents are fetched from Lance only for the final top k, in coalesced reads.

Freshness. All retrievers read the same manifest version and merge the same tail, so a document written a moment ago is visible to vector, text and sparse retrieval together. See consistency tokens.

Every response says what it cost. A performance block reports planning and execution time, the manifest version read, the tail records merged, rows scanned per retriever, cache hit bytes and object store requests.

What we give up

  • Bounded queries. k, the number of retrievers, fusion depth and query nesting have published limits. The engine refuses a request past them instead of running it slowly.
  • Not every Elasticsearch query. The Elasticsearch surface covers what the LangChain and LlamaIndex integrations and the BEIR benchmark send: document APIs, _bulk, _search with the core Query DSL, knn, hybrid and RRF. There is no Painless, no Kibana and no full Query DSL.
  • Approximate means approximate. Index searches can miss a neighbour. Scores are exact, recall is not; a recall endpoint that measures it against the exact kernel is designed.
  • Graph expansion and reranking are not in the plan yet. The expand (GraphRAG, one or two hops over a mapped graph) and rerank stages are designed. Today a request that carries either is refused.

How other systems do it

  • Elasticsearch and OpenSearch support hybrid search with RRF over BM25 and kNN in one request, on Lucene segments on local disk.
  • Qdrant fuses dense and sparse prefetches with RRF or DBSF in its Query API, with filterable HNSW. Loams' hot vector tier is built on Qdrant's own HNSW code (qdrant-edge), and Loams' Qdrant API accepts the same requests.
  • turbopuffer offers vector and BM25 queries on object storage, with fusion done client-side or through multi-queries.
  • Postgres with pgvector can combine a vector index and full-text search in SQL, with the planner choosing between them, on a single node's storage.

Status

Available Vector kNN, BM25, sparse vectors, filters, RRF, weighted and DBSF fusion, the vector_search, text_search, hybrid_search and rrf SQL table functions, and the Qdrant and Elasticsearch-subset surfaces ship in Loams 0.1.0. Graph expansion and rerank are Planned.

Go deeper

On this page