Skip to Content
We are live but in Staging 🎉

Hybrid Search

Goal: get both semantic recall and exact-keyword recall from one query. On DataK³ this is not something you configure per request — there is no caller-selectable mode. What you can control is which collections take part; everything else is owned by the *_embedding_search Scriptum template and by K3’s merge step.

query text POST /:bucket/search/vector ├── resolve collections → drop inactive / unbound ones (→ warnings[]) ├── group by (embed_model, dimensions, embedding_type) one *_embedding_search thread per group (embeds the query once, fans out to every member; retrieval strategy is the TEMPLATE's business) K3 RRF-merges across groups (k=60) → final top-K — a SINGLE group is a pass-through: raw template scores, trimmed

What is actually verifiable, and what isn’t

The house rule here is to be honest about the seam. K3’s half of this is in the repo; the template’s half is not.

ClaimStatus
RRF across collection groups, k = 60, 1/(k + rank + 1)dodil-k3/bin/api/src/services/search/search.rs:45-46, 668-715
A single group is not fused — scores pass through raw, trimmed to topK✅ same file, :674-678
searchMode, rerank, minScore, rerankText are parsed but drive no K3 logicSearchInput is #[allow(dead_code)] for exactly those fields, :67-70
searchModeUsed in the responseAlways the literal "vector" on this path (:470) — it never reports "hybrid"
sparse_mode / enable_bm25 on a collection✅ real config vocabulary (k3-core/src/vector_config.rs:100-106), passed to the template dispatch
Dense + BM25 retrieval inside the template⚠️ Not verifiable from either repo. The data plane has no BM25 anywhere — dodil-tables/crates/vector-sparse/ is a learned-sparse inverted index (SPLADE / BGE-M3 term weights) ranked by plain inner product, with no IDF and no k1/b. It is also unreferenced: no crate depends on it.
Sparse or hybrid input on the typed data-plane facetQueryVectorsRequest has nine fields and none is sparse, hybrid or filter (dodil-tables/proto/api/tables.proto:1087-1102)

So: the RRF you can observe in a response is the cross-group merge, not a dense+sparse fusion. If a template does hybrid internally, it does so inside Scriptum, and this page cannot make a claim about it.

Prerequisites

A collection whose contract resolved a sparse mode. Check what yours actually got — get takes the pipeline id:

dodil data vector collection get "$PIPELINE_ID" -b kb-prod \ | jq '.destination.vector | {sparseMode, enableBm25, dimensions, embedModel, embeddingType}'

SPARSE_MODE_NONE means the collection carries no sparse signal at all. K3’s own fallback when a template contract declares nothing is none — BM25 is not a default that K3 supplies, it is something the template must declare. enableBm25 is kept in lockstep with sparseMode for older readers; sparseMode is authoritative.

import os, requests resp = requests.post( "https://api.data.dodil.io/kb-prod/search/vector", headers={"Authorization": f"Bearer {os.environ['DODIL_TOKEN']}"}, json={"text": "what is multi-head attention", "collectionNames": ["docs"], "topK": 5}, ).json() print(resp["tookMs"], "ms;", resp["searchModeUsed"]) for r in resp["results"]: print(round(r["score"], 4), r["object"]["key"])

That is the whole recipe from the caller’s side. topK defaults to 10, is floored back to 10 when <= 0, and has no upper bound in K3 — over-fetching is your call.

2. How the RRF merge works

Reciprocal Rank Fusion combines ranked lists into one:

rrf_score(doc) = Σ over lists 1 / (k + rank(doc) + 1)

With k = 60 (the standard), the fusion is forgiving — a doc ranked #1 by one list and #50 by another still scores well. It rewards consensus and is parameter-light compared to learned-weight fusion. Results are keyed by chunk_id when present, else "{bucket}:{key}", so the same chunk surfacing in two groups accumulates one merged score.

The practical consequence for score:

  • Two or more groups → results carry RRF scores (small values like 0.016). Comparable within one response only.
  • One group → no fusion happens; you get the template’s own scores, trimmed to topK.

Either way, never compare a score from this route against a score from dodil data vsearch or the wire adapters — those are raw distances, where lower is closer.

3. Verify what a collection contributed

Two different fields report two different failures, and mixing them up is the usual reason a search “silently returns nothing”:

curl -sS -X POST "https://api.data.dodil.io/kb-prod/search/vector" \ -H "Authorization: Bearer $DODIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{"text": "multi-head attention", "topK": 10}' \ | jq '{warnings, collectionStatuses}'
FieldWhat lands there
warnings[]Collections skipped before dispatch — not active, or no bound search pipeline. Named in a human-readable string.
collectionStatuses[]One entry per collection in a group that was dispatched. On failure, every member of that group gets embeddingCompleted: false, searchCompleted: false and the same failReason.

A collection that never reached a group appears in neither list of results and has no collectionStatuses entry — check warnings first.

Need a reranker?

There is no server-side rerank. The rerank request field is parsed and discarded. If you want cross-encoder precision on the top-K, over-fetch (topK: 50) and rerank client-side — e.g. with a reranker model served by Ignite Models . The same pattern is the only way to merge results across incompatible embedding-model families beyond what RRF gives you.

Common gotchas

SymptomCauseFix
Keyword-heavy queries miss obvious documentsThe collection’s sparseMode is SPARSE_MODE_NONE — no sparse signal exists for itRe-create from a template whose contract declares sparse_mode
searchMode / rerank / minScore in the request seem to do nothingThey are parsed for wire compatibility and feed no logicDrop them from new code
searchModeUsed always says "vector" even on a BM25 collectionK3 hardcodes it on this pathDon’t use it to infer retrieval strategy
Scores look tiny compared to the cosine similarities you expectedMulti-group responses carry RRF fusion scoresRank within one response; never compare across responses or across surfaces
Scores look large and unfusedOnly one group participated — RRF is a pass-through thenExpected
Fewer results than topKCollections were skipped or a group failedRead warnings[], then collectionStatuses[]

See also