External Collection — BYO embeddings
Goal: stand up a vector collection where you control the embedding pipeline. You pick the model and the dimensions, then write and search vectors on the data plane with a stock Qdrant or Pinecone client — no K3-specific SDK, and no control-plane call at all.
When to use:
- You have a model K3 doesn’t host (third-party SaaS — OpenAI, Cohere, Voyage; or a custom fine-tuned model)
- You need to bulk-load pre-computed embeddings from another system
- You want exact control over the embedding for each row
This is now the only BYO path.
AddVectorCollection— the RPC that used to register a caller-managed collection on the control plane — went with the Milvus/VBase decommission, anddodil data vector collection add-manualwent with it.EMBEDDING_SOURCE_EXTERNALstill exists on the wire but is read-only legacy: nothing produces a new one. A BYO collection is a plain table with avector(N)column, and it lives entirely on the plane. Source:dodil-k3/proto/proto-k3/k3_pipeline.proto:345-357.
Shape:
Your code → embed model (OpenAI, etc.) → dense vectors
│
▼
stock Qdrant / Pinecone client
(qdrant.uk-lon-1.dodil.io / pinecone.uk-lon-1.dodil.io,
db id — the bucket name — as the api-key)
│
▼
tabled data plane: docs (id VARCHAR PK, vector vector(N))Prerequisites
- A bucket —
kb-prod:dodil data bucket create kb-prod - A way to compute embeddings on your side. Examples below use OpenAI
text-embedding-ada-002(1536 dims).
1. Connect a stock client
The data plane speaks the Qdrant and Pinecone wire protocols directly. Both adapters read the db id out of the api-key header — the db id is the bucket name, and it is an identifier, not a credential. See Connect & wire adapters for the full credential story.
qdrant-client
from qdrant_client import QdrantClient
qd = QdrantClient(url="https://qdrant.uk-lon-1.dodil.io", api_key="kb-prod")2. Create the collection
A Qdrant collection is a table on the plane. create_collection lowers to exactly:
CREATE TABLE IF NOT EXISTS ada (id VARCHAR PRIMARY KEY, vector vector(1536));qdrant-client
from qdrant_client.models import VectorParams, Distance
qd.create_collection(
collection_name="ada",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)Alongside the table the adapter creates two companions: ada_payload (point payloads) and ada_qmeta (the collection’s real distance + dim, so get_collection reports what you actually created).
Only
Cosine,EuclidandDotare accepted.Distance.MANHATTANand anything else is a loud 400 fromDistance::from_wire(qdrant.rs:59-68). The vector column is hardcoded tovectoron the Qdrant wire.
There is nothing to register on the control plane — the collection will not appear in dodil data vector collection list, which lists pipelines with a vector facet. This one has no pipeline.
3. Write vectors
Embed on your side, upsert with the stock client. Upserts are idempotent by point id — re-running the same batch replaces rows instead of duplicating them.
qdrant-client
import openai
docs = [
{"id": "doc-1", "text": "Multi-head attention lets the model jointly attend to information from different representation subspaces."},
{"id": "doc-2", "text": "BERT uses bidirectional self-attention over masked tokens."},
{"id": "doc-3", "text": "GPT uses causal self-attention with left-to-right context."},
]
client = openai.OpenAI()
resp = client.embeddings.create(
model="text-embedding-ada-002",
input=[d["text"] for d in docs],
)
qd.upsert(
collection_name="ada",
points=[
{
"id": doc["id"],
"vector": emb.embedding, # 1536 floats
"payload": {"text": doc["text"], "source": "papers/attention.pdf"},
}
for doc, emb in zip(docs, resp.data)
],
)The payload lands in the ada_payload companion table in lock-step with the vector write, and comes back on search with with_payload.
4. Search — KNN by vector
Pre-embed the query, then search.
qdrant-client
qresp = client.embeddings.create(model="text-embedding-ada-002", input="multi-head attention")
hits = qd.search(
collection_name="ada",
query_vector=qresp.data[0].embedding,
limit=5,
with_payload=True,
)
for h in hits:
print(h.id, h.score, h.payload.get("text"))Use
search(), notquery_points(). The adapter implementsPOST /collections/:name/points/search; Qdrant’s newer/points/queryAPI (prefetch, fusion, named vectors) is not routed. Also notewith_payload=Truereturns the whole payload — field selection is accepted but not honored.
scoreis a distance — lower is closer. Every one of these paths returns the raw metric distance from pgvector, including the Qdrant wire, which passes the facet’sscorethrough verbatim. A stock Qdrant client will happily print it as if higher were better. Sort accordingly.
Filters
qdrant-client
from qdrant_client.models import Filter, FieldCondition, MatchValue
hits = qd.search(
collection_name="ada",
query_vector=qvec,
query_filter=Filter(must=[FieldCondition(key="source", match=MatchValue(value="papers/attention.pdf"))]),
limit=5,
)Supported: a must array of match.value (exact, single key) and range (gt/gte/lt/lte) conditions, ANDed together and lowered to a WHERE over the payload companion. should, must_not, match.any, match.except, nested and geo filters return a 400 naming the clause.
A filtered search takes a different code path — candidate ids from the companion, then a KNN restricted to them — so it does not use the ANN index.
5. Delete
qdrant-client
qd.delete(collection_name="ada", points_selector={"points": ["doc-1", "doc-2"]})
# An EMPTY filter is Qdrant's match-all — this clears the whole collection.
qd.delete(collection_name="ada", points_selector={"filter": {}})
# Drop the table (and its companions)
qd.delete_collection("ada")Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
401 missing api-key header (= db_id) | No api-key sent | Pass the bucket name as the api-key — stock clients set the header from their api_key param |
Pinecone calls hit a table called vectors | The Pinecone adapter takes the table name from X-Dodil-Index, not from the SDK’s index name | Set additional_headers={"X-Dodil-Index": "<table>"} |
AttributeError / 404 on query_points, scroll, count, recommend | Only create/get/delete collection, upsert, retrieve, search and delete points are routed | Use search(); page with SQL over the Postgres wire |
| Dimension mismatch on upsert | Vector length ≠ the column’s declared vector(N) | qd.get_collection("ada") reports the real dim from the _qmeta companion |
400 naming a filter clause | A shape the adapter doesn’t lower (should, must_not, $or, $in, nested, geo) | Restructure as a must / flat-$eq form, or filter client-side |
| Results look inverted | score is a distance, not a similarity | Lower is closer on every wire here |
| Payload field selection ignored | with_payload is treated as a boolean | Project the fields you want client-side, or read the companion table in SQL |
The collection is missing from dodil data vector collection list | That command lists pipelines with a vector facet; a BYO table has no pipeline | Expected — list tables through the tables-gateway instead |
When to pick BYO over a pipeline collection
| Choose BYO | Choose a pipeline collection |
|---|---|
| You have your own embedding pipeline | You want K3 to handle embedding |
| Third-party SaaS model (OpenAI / Cohere) | Want one of K3’s built-in *_embedding_index templates |
| Pre-computed batches | Real-time on-upload ingest |
| Exact control over the embedding for each row | Template-driven chunking + embedding is acceptable |
| You want payload filters and idempotent upserts by id | You want server-side query embedding via POST /:bucket/search/vector |
See also
- Connect & wire adapters — endpoints, credentials, per-language snippets
- Pipeline Collection — opposite shape: K3 handles embedding via a Scriptum template
- Multi-collection Search — the HTTP search route across collections
dodil data vsearch— KNN from the CLI over the typed facet