Skip to Content
We are live but in Staging 🎉
Data EnginesVectorRecipesMulti-collection Search

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_entities rows with kind='vector' and keeps the ones that are active and have a bound search pipeline. A BYO/wire-created collection is a bare table on the plane with no store_entities row 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:

  • docstext_embedding_index
  • ticketstext_embedding_index (same template → same model + dims → same group as docs)
  • assetsvisual_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 ruleCreateVectorPipeline 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.png

Confirm 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})' done

2. Search all collections — omit the collection list

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); assets ran 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: false and the same failReason.

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. failReason reports a group whose thread failed, not a grouping decision. An empty embed_model is likewise not an error: it becomes the key ("", dim, type) and the EMBED_MODEL env 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/javascriptcode; 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”:

FieldWhat 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

SymptomCauseFix
Empty results, but you have dataEvery collection was skipped pre-dispatch, or every group failedRead warnings[] first, then collectionStatuses[].failReason
A collection is missing from results and from collectionStatusesIt never reached a group — inactive or no bound search pipelineSee warnings[]; re-create from a *_index template so the *_search counterpart spawns
A BYO/wire-created collection never appearsIt has no store_entities row, so this route cannot see itSearch it with a stock Qdrant/Pinecone client
NOT_FOUND: no collections matchedA name in collectionNames doesn’t exist in the bucketNames come from .destination.name, not the pipeline name
Results from docs dominate ticketsCorpus-size and rank effects in the fusionQuery the underrepresented collection separately with collectionNames, or rerank client-side
Scores changed shape after adding a collectionYou crossed from one group to two, so RRF kicked inExpected — never compare scores across responses
tookMs jumped after adding a new collection with a new modelMore groups = more parallel search-template threadsConsolidate on one embedding model where possible
429-ish RESOURCE_EXHAUSTEDThe per-pod search semaphore is fullRetry; the limit protects Scriptum from overload

See also