Multi-collection Search
Goal: search across multiple vector collections in a single bucket — one query, results merged and ranked across collections.
Why this matters: a typical RAG system grows past one collection — you accumulate docs, code, tickets, assets, each with its own template and embedding. Multi-collection search fans one query across all of them without orchestrating N calls and merging client-side.
The trick: vectors from different embedding models can’t be ranked against each other directly. K3 groups collections by (embed_model, dimensions, embedding_type), runs one search-template thread per group (embed once, fan out to every member), then RRF-merges the groups’ ranked lists into one result set.
Only pipeline collections take part. The route reads
store_entitiesrows withkind='vector'and keeps the ones that areactiveand have a bound search pipeline. A BYO/wire-created collection is a bare table on the plane with nostore_entitiesrow at all, so it is invisible here — not skipped with a warning, just absent. Search those with a stock client instead: External Collection.
Prerequisites
A bucket with two or more pipeline collections. Different groups come from different templates, since each template’s contract fixes its own model and dimensions. We’ll use three:
docs—text_embedding_indextickets—text_embedding_index(same template → same model + dims → same group asdocs)assets—visual_embedding_index(different model → separate group, and a different modality)
1. Build the setup
dodil data bucket create kb-multi -d "Multi-collection demo"
DOCS=$(dodil data vector collection add docs -b kb-multi \
--template text_embedding_index --description "Product docs" -o json | jq -r '.pipelineId')
TICKETS=$(dodil data vector collection add tickets -b kb-multi \
--template text_embedding_index --description "Support tickets" -o json | jq -r '.pipelineId')
ASSETS=$(dodil data vector collection add assets -b kb-multi \
--template visual_embedding_index --description "Screenshots" -o json | jq -r '.pipelineId')Each one needs its own ingest rule — CreateVectorPipeline derives none — with globs scoped so a document doesn’t get embedded into two collections:
dodil data ingest add docs-rule -b kb-multi -c "$DOCS" -i 'docs/**/*.pdf'
dodil data ingest add tickets-rule -b kb-multi -c "$TICKETS" -i 'tickets/**/*.json'
dodil data ingest add assets-rule -b kb-multi -c "$ASSETS" -i 'assets/**/*.png'Then populate:
dodil data object create ./product-guide.pdf -b kb-multi -k docs/product-guide.pdf
dodil data object create ./api-reference.pdf -b kb-multi -k docs/api-reference.pdf
dodil data object create ./TKT-1042.json -b kb-multi -k tickets/TKT-1042.json
dodil data object create ./screen-01.png -b kb-multi -k assets/screen-01.pngConfirm the group keys before you rely on the fan-out:
for P in "$DOCS" "$TICKETS" "$ASSETS"; do
dodil data vector collection get "$P" -b kb-multi \
| jq -c '{name: .destination.name} + (.destination.vector | {embedModel, dimensions, embeddingType})'
done2. Search all collections — omit the collection list
Python (requests)
import os, requests
resp = requests.post(
"https://api.data.dodil.io/kb-multi/search/vector",
headers={"Authorization": f"Bearer {os.environ['DODIL_TOKEN']}"},
json={"text": "how do I configure SSO", "topK": 10},
).json()
for r in resp["results"]:
print(round(r["score"], 4), r["object"]["key"])
for w in resp.get("warnings", []):
print("SKIPPED:", w)
for s in resp.get("collectionStatuses", []):
if not s["searchCompleted"]:
print("FAILED:", s["collection"], "—", s["failReason"])Sample response:
{
"results": [
{"score": 0.0325, "object": "docs/product-guide.pdf"},
{"score": 0.0311, "object": "tickets/TKT-1042.json"},
{"score": 0.0287, "object": "docs/api-reference.pdf"}
],
"warnings": [],
"collectionStatuses": [
{ "collection": "docs", "embeddingCompleted": true, "searchCompleted": true, "failReason": "" },
{ "collection": "tickets", "embeddingCompleted": true, "searchCompleted": true, "failReason": "" },
{ "collection": "assets", "embeddingCompleted": false, "searchCompleted": false, "failReason": "…" }
],
"tookMs": "1840"
}Key observations:
- One thread served
docs+tickets(same group — the query was embedded once for both);assetsran in its own group with its own embedding. - Two or more groups means the final scores are RRF fusion scores, comparable within this response only. With one group there is no fusion at all — you get the template’s raw scores.
- A group that fails does not crash the search: every member gets
embeddingCompleted: false,searchCompleted: falseand the samefailReason.
3. The compatibility group key
K3 groups collections by (embed_model, dimensions, embedding_type) — the tuple is built verbatim from each collection’s VectorConfig (services/search/search.rs:354-372), and the reason is spelled out in the source: collections sharing a dim but using different models (jina-v4 vs arcface-r100) must never co-mingle. Within a group, one query embedding serves every member. Across groups, each embeds and searches independently, and the merge is rank-based (RRF), never score-based.
| Match (collection A vs B) | Same group? |
|---|---|
Same embed_model + same dimensions + same embeddingType | ✅ one thread, one query embedding |
Same dimensions, different embed_model | ❌ separate groups, separate embeddings |
Different dimensions or embeddingType | ❌ separate groups |
Mismatched models are never rejected. There is no compatibility check and no “incompatible embed model” error anywhere in the source — an incompatible collection simply forms its own group and gets RRF-merged in.
failReasonreports a group whose thread failed, not a grouping decision. An emptyembed_modelis likewise not an error: it becomes the key("", dim, type)and theEMBED_MODELenv var is silently omitted, letting the template fall through to its own default.
4. Narrowing the fan-out
Pin the search to specific collections with collectionNames:
curl -sS -X POST "https://api.data.dodil.io/kb-multi/search/vector" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "SSO config", "collectionNames": ["tickets"], "topK": 10}'Names that match nothing return NOT_FOUND: no collections matched: … — an explicit list is a hard assertion, not a filter. The legacy singular collectionName is still accepted and folded into the same list.
File queries narrow automatically instead: the part’s content type routes to a modality (image/*, audio/*, video/* → visual; text/x-* or application/javascript → code; everything else → text) and only collections whose modality matches take part.
5. Observability — the two failure channels
They are different fields and they mean different things. Reading only one is the usual reason a search “silently returns nothing”:
| Field | What lands there |
|---|---|
warnings[] | Collections dropped before dispatch: not active, or no bound search pipeline. A human-readable string naming the collection. |
collectionStatuses[] | One entry per collection in a group that was dispatched, carrying failReason on failure. |
A collection that never reached a group appears in neither the results nor collectionStatuses — check warnings first.
participating = [s for s in resp["collectionStatuses"] if s["searchCompleted"]]
failed = [s for s in resp["collectionStatuses"] if not s["searchCompleted"]]
print(f"{len(participating)} collections returned, {len(failed)} failed, "
f"{len(resp['warnings'])} skipped before dispatch")
for s in failed:
print(f" {s['collection']}: {s['failReason']}")Zero active collections is a 200 with an empty result set, not an error.
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Empty results, but you have data | Every collection was skipped pre-dispatch, or every group failed | Read warnings[] first, then collectionStatuses[].failReason |
A collection is missing from results and from collectionStatuses | It never reached a group — inactive or no bound search pipeline | See warnings[]; re-create from a *_index template so the *_search counterpart spawns |
| A BYO/wire-created collection never appears | It has no store_entities row, so this route cannot see it | Search it with a stock Qdrant/Pinecone client |
NOT_FOUND: no collections matched | A name in collectionNames doesn’t exist in the bucket | Names come from .destination.name, not the pipeline name |
Results from docs dominate tickets | Corpus-size and rank effects in the fusion | Query the underrepresented collection separately with collectionNames, or rerank client-side |
| Scores changed shape after adding a collection | You crossed from one group to two, so RRF kicked in | Expected — never compare scores across responses |
tookMs jumped after adding a new collection with a new model | More groups = more parallel search-template threads | Consolidate on one embedding model where possible |
429-ish RESOURCE_EXHAUSTED | The per-pod search semaphore is full | Retry; the limit protects Scriptum from overload |
See also
- Search — API Reference — the route contract + dispatch model
- Hybrid Search — what the RRF merge does and doesn’t prove
- Pipeline Collection — how to create the collections this recipe fans across
- Core Concepts → Search — the grouping model