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, trimmedWhat 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.
| Claim | Status |
|---|---|
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 logic | ✅ SearchInput is #[allow(dead_code)] for exactly those fields, :67-70 |
searchModeUsed in the response | Always 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 facet | ❌ QueryVectorsRequest 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.
1. Search
Python (requests)
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}'| Field | What 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
| Symptom | Cause | Fix |
|---|---|---|
| Keyword-heavy queries miss obvious documents | The collection’s sparseMode is SPARSE_MODE_NONE — no sparse signal exists for it | Re-create from a template whose contract declares sparse_mode |
searchMode / rerank / minScore in the request seem to do nothing | They are parsed for wire compatibility and feed no logic | Drop them from new code |
searchModeUsed always says "vector" even on a BM25 collection | K3 hardcodes it on this path | Don’t use it to infer retrieval strategy |
| Scores look tiny compared to the cosine similarities you expected | Multi-group responses carry RRF fusion scores | Rank within one response; never compare across responses or across surfaces |
| Scores look large and unfused | Only one group participated — RRF is a pass-through then | Expected |
Fewer results than topK | Collections were skipped or a group failed | Read warnings[], then collectionStatuses[] |
See also
- Search — API Reference — the route contract + dispatch model
- Pipeline Collection — how to get a collection in the first place
- Multi-collection Search — where the cross-group RRF merge actually earns its keep
- External Collection — pre-embedded KNN over the wire adapters when you want raw dense search