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, slack, each with its own template / embedding. Multi-collection search lets you fan out one query across all of them without orchestrating N calls and merging client-side.
The trick: only collections with compatible embeddings can co-search. K3 groups by (dimensions, embedding_type, embed_model) — vectors from different models can’t be ranked against each other, so K3 fans out within a compatibility group and reports per-collection status for the rest.
Prerequisites
dodilCLI +dodil login- A bucket with vector engine configured (
automode) - Two or more collections in the bucket. We’ll set up three for this recipe:
docs— pipeline-mode,text_embedding_index(jina-embeddings-v4, 1024 dims)tickets— pipeline-mode,text_embedding_index(same model → compatible withdocs)ada-legacy— external-mode, OpenAI ada-002 (1536 dims, FLOAT, cosine — incompatible with the others)
1. Build the setup
dodil k3 bucket create kb-multi -d "Multi-collection demo"
dodil k3 vector store create -b kb-multi -m auto
# Wait for ACTIVECreate the three collections:
# docs — text_embedding_index → 1024 dims, jina-embeddings-v4
dodil k3 vector collection add docs -b kb-multi \
--template text_embedding_index --description "Product docs"
# tickets — same template → same model + dims → COMPATIBLE with docs
dodil k3 vector collection add tickets -b kb-multi \
--template text_embedding_index --description "Support tickets"
# ada-legacy — different model (1536 dims, OpenAI) → INCOMPATIBLE
dodil k3 vector collection add-manual ada-legacy -b kb-multi \
--dimensions 1536 \
--metric cosine \
--embed-model openai/text-embedding-ada-002 \
--description "Legacy data from before we standardized on Jina"Populate (assume docs and tickets get pipeline-driven content; ada-legacy gets vectors via UpsertVectors from your code):
dodil k3 object create ./product-guide.pdf -b kb-multi -k docs/product-guide.pdf
dodil k3 object create ./api-reference.pdf -b kb-multi -k docs/api-reference.pdf
dodil k3 object create ./TKT-1042.json -b kb-multi -k tickets/TKT-1042.json
dodil k3 object create ./TKT-1043.json -b kb-multi -k tickets/TKT-1043.json
# ada-legacy populated via your code / InsertVectors2. Search all collections — empty collection_name
curl -sS -X POST "https://k3.dev.dodil.io/kb-multi/vector/search" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"bucket": "kb-multi",
"text": "how do I configure SSO",
"topK": 10,
"searchMode": "SEARCH_MODE_AUTO",
"includeContent": true
}' | jq '{
results: [.results[] | {score, object: .object.key}],
collectionStatuses: .collectionStatuses,
searchModeUsed,
tookMs
}'Sample response:
{
"results": [
{"score": 0.91, "object": "docs/product-guide.pdf"},
{"score": 0.88, "object": "tickets/TKT-1042.json"},
{"score": 0.84, "object": "docs/api-reference.pdf"},
{"score": 0.79, "object": "tickets/TKT-1043.json"}
],
"collectionStatuses": [
{
"collection": "docs",
"embeddingCompleted": true,
"searchCompleted": true,
"failReason": ""
},
{
"collection": "tickets",
"embeddingCompleted": true,
"searchCompleted": true,
"failReason": ""
},
{
"collection": "ada-legacy",
"embeddingCompleted": false,
"searchCompleted": false,
"failReason": "incompatible embed_model: query embedded with jina-embeddings-v4 (1024 dims), collection uses openai/text-embedding-ada-002 (1536 dims)"
}
],
"searchModeUsed": "hybrid",
"tookMs": "184"
}Key observations:
- Results merged from
docs+tickets(the compatible group), sorted byscoreacross collections ada-legacyreturns 0 results withfail_reasonexplaining the incompatibility — not a crash, just observabilitysearch_mode_used: "hybrid"because both compatible collections have BM25 enabledtook_msis total wall time (not the slowest per-collection scan)
3. The compatibility group key
K3 groups collections by (dimensions, embedding_type, embed_model). Within a group, vectors are directly comparable; across groups they’re not.
| Match (collection A vs B) | Co-search? | Why |
|---|---|---|
Same dimensions + same embedding_type + same embed_model | ✅ | All three axes align — vectors live in the same space |
Same dimensions + same embedding_type, different embed_model | ❌ | Different model = different embedding space; scores aren’t comparable |
Different dimensions | ❌ | Vectors don’t have the same length — can’t be compared at all |
Different embedding_type (e.g. FLOAT vs INT8) | ❌ | Different precision/encoding |
What K3 actually does on an empty-collection_name query
- Picks the first compatible group for the query (for a
textquery, the group’sembed_modelmust match an embedding service K3 can route to). - Fans out the search to every collection in that group in parallel.
- Aggregates + sorts results by
score. - For every other collection in the bucket (different group), reports a
CollectionSearchStatusentry withfail_reasonexplaining the mismatch.
You can verify which group K3 picked by inspecting the collectionStatuses — collections in the active group have searchCompleted: true; everything else has a non-empty fail_reason.
4. Narrow within a group — metadata pre-filter
Multi-collection search fans out to all compatible collections, even ones you don’t want for a particular query. Three ways to narrow:
A. Tag your records with a source field + filter by it
# Search only ticket content
curl -sS -X POST "https://k3.dev.dodil.io/kb-multi/vector/search" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"bucket": "kb-multi",
"text": "SSO config",
"topK": 10,
"preFilter": {
"op": "LOGICAL_OP_AND",
"filters": [
{ "field": "source_key", "op": "FILTER_OP_CONTAINS", "value": "tickets/" }
]
}
}'The filter runs per-collection — collections that don’t have the source_key metadata field will return no matches (silently skipped).
B. Use multiple source_ids to restrict by Pipelines source
For pipeline-mode collections, each ingest carries the source id from the auto-generated ingest rule. Filter directly:
curl -sS -X POST "https://k3.dev.dodil.io/kb-multi/vector/search" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"bucket": "kb-multi",
"text": "SSO config",
"topK": 10,
"sourceIds": ["src_tickets_a1b2..."]
}'C. Just pick one collection — collection_name is the simplest narrowing
curl -sS -X POST "https://k3.dev.dodil.io/kb-multi/vector/search" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"bucket": "kb-multi",
"collectionName": "tickets",
"text": "SSO config",
"topK": 10
}'No fan-out, no compatibility checks, just one collection. Use this when you know exactly which collection you want.
5. Handling incompatible groups — query both
For a bucket with multiple embedding-model families (e.g. jina-embeddings-v4 AND openai/text-embedding-ada-002), one search can only cover one group. To search both, run two queries:
# Query group A (Jina-family) — empty collection_name picks the largest compatible group
QUERY_RESULTS_A=$(curl -sS -X POST "https://k3.dev.dodil.io/kb-multi/vector/search" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"bucket":"kb-multi","text":"SSO config","topK":10}' \
| jq '.results')
# Query group B (OpenAI ada) — name the collection explicitly
QUERY_RESULTS_B=$(curl -sS -X POST "https://k3.dev.dodil.io/kb-multi/vector/search" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"bucket":"kb-multi","collectionName":"ada-legacy","text":"SSO config","topK":10}' \
| jq '.results')
# Merge client-side. Note: scores from different models aren't directly comparable —
# you'd typically rerank both sets with a cross-encoder before merging.For a single ranked result across model families, fold a Jina-style reranker over both sets — see Hybrid + Rerank for the reranker pattern.
6. Observability — inspecting collection_statuses in your code
Every multi-collection search response carries one CollectionSearchStatus per collection in the bucket. Use it to log / alert on partial failures:
import requests
resp = requests.post(
f"{K3}/kb-multi/vector/search",
headers=HEADERS,
json={"bucket": "kb-multi", "text": query, "topK": 10},
).json()
participating = [s for s in resp["collectionStatuses"] if s["searchCompleted"]]
skipped = [s for s in resp["collectionStatuses"] if not s["searchCompleted"]]
print(f"Search ran across {len(participating)} collections, skipped {len(skipped)}")
for s in skipped:
print(f" ⚠️ {s['collection']}: {s['failReason']}")This is also a good place to detect unexpected skipped collections — if a collection you thought was compatible is in the skipped list, something changed (different embed_model after a recreate?).
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Empty results, but you have data | All collections are in incompatible groups vs the query’s resolved model | Inspect collection_statuses — if all show failReason, the query’s model has no compatible collections in the bucket |
| One collection always missing from results | Different embed_model or dimensions from the active group | Recreate the collection with the same model as the others, OR query it explicitly with collection_name |
Results from docs dominate tickets even though tickets has more relevant content | Different per-collection corpus size affects raw scores | Run a rerank pass — see Hybrid + Rerank |
| Adding a new collection causes existing multi-collection queries to skip it | Collection still COLLECTION_STATUS_CREATING | Wait for COLLECTION_STATUS_ACTIVE; for pipeline-mode collections, the Milvus side materializes on first ingest — pre-create a placeholder record if you need search-readiness before content ingest |
tookMs jumped after adding a new collection | More collections = more parallel Milvus scans | Add partition_names on the vector query path OR keep collections small via partitioning |
See also
- Search — API Reference — the spec for the multi-collection mechanism
- Hybrid + Rerank — when you want to merge results across truly incompatible groups, rerank handles the cross-model ranking
- Pipeline Collection + External Collection — recipes for the two collection types this recipe mixes
- Core Concepts → Collection — the schema fields the compatibility key reads from