Skip to Content
We are live but in Staging 🎉
VectorRecipesMulti-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, 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

  • dodil CLI + dodil login
  • A bucket with vector engine configured (auto mode)
  • 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 with docs)
    • 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 ACTIVE

Create 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 / InsertVectors

2. 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 by score across collections
  • ada-legacy returns 0 results with fail_reason explaining the incompatibility — not a crash, just observability
  • search_mode_used: "hybrid" because both compatible collections have BM25 enabled
  • took_ms is 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_modelAll three axes align — vectors live in the same space
Same dimensions + same embedding_type, different embed_modelDifferent model = different embedding space; scores aren’t comparable
Different dimensionsVectors 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

  1. Picks the first compatible group for the query (for a text query, the group’s embed_model must match an embedding service K3 can route to).
  2. Fans out the search to every collection in that group in parallel.
  3. Aggregates + sorts results by score.
  4. For every other collection in the bucket (different group), reports a CollectionSearchStatus entry with fail_reason explaining 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

SymptomCauseFix
Empty results, but you have dataAll collections are in incompatible groups vs the query’s resolved modelInspect collection_statuses — if all show failReason, the query’s model has no compatible collections in the bucket
One collection always missing from resultsDifferent embed_model or dimensions from the active groupRecreate 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 contentDifferent per-collection corpus size affects raw scoresRun a rerank pass — see Hybrid + Rerank
Adding a new collection causes existing multi-collection queries to skip itCollection still COLLECTION_STATUS_CREATINGWait 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 collectionMore collections = more parallel Milvus scansAdd partition_names on the vector query path OR keep collections small via partitioning

See also