Skip to Content
We are live but in Staging 🎉
PipelinesRecipesExternal Collection

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, and dodil data vector collection add-manual went with it. EMBEDDING_SOURCE_EXTERNAL still exists on the wire but is read-only legacy: nothing produces a new one. A BYO collection is a plain table with a vector(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.

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));
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, Euclid and Dot are accepted. Distance.MANHATTAN and anything else is a loud 400 from Distance::from_wire (qdrant.rs:59-68). The vector column is hardcoded to vector on 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.

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.

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(), not query_points(). The adapter implements POST /collections/:name/points/search; Qdrant’s newer /points/query API (prefetch, fusion, named vectors) is not routed. Also note with_payload=True returns the whole payload — field selection is accepted but not honored.

score is 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’s score through verbatim. A stock Qdrant client will happily print it as if higher were better. Sort accordingly.

Filters

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

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

SymptomCauseFix
401 missing api-key header (= db_id)No api-key sentPass the bucket name as the api-key — stock clients set the header from their api_key param
Pinecone calls hit a table called vectorsThe Pinecone adapter takes the table name from X-Dodil-Index, not from the SDK’s index nameSet additional_headers={"X-Dodil-Index": "<table>"}
AttributeError / 404 on query_points, scroll, count, recommendOnly create/get/delete collection, upsert, retrieve, search and delete points are routedUse search(); page with SQL over the Postgres wire
Dimension mismatch on upsertVector length ≠ the column’s declared vector(N)qd.get_collection("ada") reports the real dim from the _qmeta companion
400 naming a filter clauseA 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 invertedscore is a distance, not a similarityLower is closer on every wire here
Payload field selection ignoredwith_payload is treated as a booleanProject the fields you want client-side, or read the companion table in SQL
The collection is missing from dodil data vector collection listThat command lists pipelines with a vector facet; a BYO table has no pipelineExpected — list tables through the tables-gateway instead

When to pick BYO over a pipeline collection

Choose BYOChoose a pipeline collection
You have your own embedding pipelineYou want K3 to handle embedding
Third-party SaaS model (OpenAI / Cohere)Want one of K3’s built-in *_embedding_index templates
Pre-computed batchesReal-time on-upload ingest
Exact control over the embedding for each rowTemplate-driven chunking + embedding is acceptable
You want payload filters and idempotent upserts by idYou want server-side query embedding via POST /:bucket/search/vector

See also