Qdrant Setup - Self-Hosted Vector Database
Status: Active
Last Updated: 2026-08-26
Category: AI/ML - Phase 3: RAG Infrastructure
Prerequisites: docker-basics, embeddings-vector-db
Time: 2-3 hours
Tags: qdrant, vector-database, docker, rag, embeddings, filtering
Summary
Deploy Qdrant in Docker on your homelab server, create collections with the right vector configuration, understand distance metrics, filter by payload metadata, and talk to it from Python. Qdrant is the recommended self-hosted vector store for our RAG stack.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Run Qdrant in Docker with persistent storage and an API key
- โ Create collections sized to your embedding model's dimension
- โ Pick a distance metric and know why cosine is the usual default
- โ Filter searches by payload (source, date, tags)
- โ Upsert and search from Python with the official client
Context / Why This Matters
embeddings-vector-db explains what embeddings are; this article stands up the database that stores them. When your document set outgrows brute-force numpy similarity (document-chunking โ hundreds of files), you need an ANN index plus metadata filtering โ that's Qdrant: a single Rust binary, trivially containerized, with a clean REST + gRPC API and good Python support. It pairs directly with the pipeline described in rag-pipeline.
Implementation / Core Content
Deploy With Docker
mkdir -p /srv/qdrant/storage /srv/qdrant/snapshots
docker run -d \
--name qdrant \
--restart unless-stopped \
-p 127.0.0.1:6333:6333 \
-p 127.0.0.1:6334:6334 \
-v /srv/qdrant/storage:/qdrant/storage \
-v /srv/qdrant/snapshots:/qdrant/snapshots \
qdrant/qdrant:v1.12.4
Notes:
- Port 6333 = REST/HTTP, 6334 = gRPC (used by clients for speed). Bind to localhost and front with your reverse proxy + auth if you need remote access โ see traefik-v3-reverse-proxy.
- Pin a version tag, not
latest, so upgrades are deliberate. - The two volume mounts make collections survive container replacement; snapshot dir enables
/collections/{name}/snapshotsbackups.
Enable API key auth via env vars (or a config file mounted at /qdrant/config/config.yaml):
docker rm -f qdrant
docker run -d --name qdrant --restart unless-stopped \
-p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
-e QDRANT__SERVICE__API_KEY="change-me-long-random" \
-e QDRANT__SERVICE__READ_ONLY_API_KEY="optional-read-key" \
-v /srv/qdrant/storage:/qdrant/storage \
qdrant/qdrant:v1.12.4
Health check:
curl http://localhost:6333/healthz # -> "healthz"
curl http://localhost:6333/collections # list collections
Or use compose alongside Ollama on one network โ see docker-compose-intro.
Collections and Vector Config
A collection holds points (id + vector + payload) sharing one vector schema. The critical parameter is the dimension, which must match your embedding model exactly:
| Embedding model (Ollama) | Dim |
|---|---|
| nomic-embed-text | 768 |
| mxbai-embed-large | 1024 |
| snowflake-arctic-embed | 1024 |
Mismatched dimension = every insert rejected. Decide the model first (embeddings-vector-db); changing models later means re-embedding the whole corpus into a new collection.
from qdrant_client import QdrantClient, models
client = QdrantClient(host="localhost", port=6333, api_key="change-me-long-random")
if not client.collection_exists("docs"):
client.create_collection(
collection_name="docs",
vectors_config=models.VectorParams(
size=768, # match embedding model!
distance=models.Distance.COSINE,
),
)
For most homelab corpora (up to a few million points) defaults are fine; Qdrant builds HNSW indexes automatically. Named vectors (vectors_config={...} dict form) allow multiple vector spaces per point โ skip until you need hybrid dense+sparse search.
Distance Metrics
| Metric | Use when |
|---|---|
| Cosine | Default choice. Ignores magnitude, compares direction. Matches normalized embedding models (nomic, bge, etc.) |
| Dot | Equivalent to cosine for already-normalized vectors, slightly faster |
| Euclid | Spatial/embedding models trained for L2 (some image encoders); rare for text |
Rule: use cosine unless your embedding model card explicitly says otherwise. Consistency matters more than the specific pick โ never mix metrics across a migration.
Points, Payloads, and Filtering
Every point: numeric ID, vector, and arbitrary JSON payload. Payload design is what makes filtered retrieval possible later:
client.upsert(
collection_name="docs",
points=[models.PointStruct(
id=1, # unique int or UUID
vector=[0.12, -0.44, ...], # len == 768
payload={
"source": "runbooks/backup-failures.md",
"chunk_index": 3,
"category": "ops",
"tags": ["backup", "restic"],
"updated": "2026-07-02",
"text": "If restic exits with code 3, the snapshot was partial...",
},
)],
)
Store the chunk text in the payload โ you need it back at query time to build the prompt, and it makes the collection self-contained.
Search with filters (payload indexes get created automatically on demand):
hits = client.query_points(
collection_name="docs",
query=embedding_vector,
limit=5,
with_payload=True,
query_filter=models.Filter(
must=[
models.FieldCondition(key="category", match=models.MatchValue(value="ops")),
models.Range(key="updated", gte="2026-01-01"),
],
must_not=[
models.FieldCondition(key="tags", match=models.MatchAny(any=["archived"])),
],
),
).points
for h in hits:
print(round(h.score, 3), h.payload["source"], h.payload["text"][:80])
Filter semantics: must = AND, should = OR (at least one), must_not = exclusion. Filters narrow the candidate set before ranking โ far better than retrieving then discarding in Python.
Client Options
- Python
qdrant-client(pip install qdrant-client) โ used above; supports REST and gRPC (prefer_grpc=True). - Raw REST with curl for scripting/debugging.
- Local mode (
QdrantClient(":memory:")or a path) โ same API, no server; handy for tests, not for production persistence. - LangChain/LlamaIndex integrations wrap this client โ covered in langchain-integration.
Ops Basics
# snapshot a collection (writes into the snapshots volume)
curl -X POST http://localhost:6333/collections/docs/snapshots
# collection info: count, status, indexed vectors
curl http://localhost:6333/collections/docs
Include /srv/qdrant/storage in normal filesystem backups too, or schedule snapshots and back those up (restic-backups pattern applies).
Practical Examples
Example 1: End-to-End Mini Pipeline (Embed + Search)
"""mini_rag.py โ index markdown notes, search them."""
import glob, ollama
from qdrant_client import QdrantClient, models
client = QdrantClient(host="localhost", port=6333)
MODEL, DIM = "nomic-embed-text", 768
def embed(texts: list[str]) -> list[list[float]]:
return ollama.embed(model=MODEL, input=texts)["embeddings"]
client.recreate_collection("notes",
vectors_config=models.VectorParams(size=DIM, distance=models.Distance.COSINE))
points = []
for pid, path in enumerate(sorted(glob.glob("notes/*.md"))):
text = open(path).read()
points.append(models.PointStruct(
id=pid, vector=embed([text])[0],
payload={"source": path, "text": text}))
client.upsert("notes", points)
qvec = embed(["how do I restore a restic snapshot?"])[0]
for h in client.query_points("notes", query=qvec, limit=3, with_payload=True).points:
print(f"{h.score:.3f} {h.payload['source']}")
Expected: the backup-related note scores highest (cosine similarity ~0.6โ0.8), unrelated notes ~0.2โ0.4.
Example 2: Metadata-Restricted Search
Only ops-tagged documents updated this year:
results = client.query_points(
"docs", query=qvec, limit=5,
query_filter=models.Filter(must=[
models.FieldCondition(key="tags", match=models.MatchAny(any=["ops"])),
models.FieldCondition(key="updated", match=models.MatchText(text="2026")),
]),
).points
Example 3: Verify Persistence
docker rm -f qdrant
docker start qdrant # or re-run the docker run command
curl http://localhost:6333/collections | grep notes # still there
Troubleshooting & Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
| Insert fails: dimension mismatch | Vector size โ collection size |
Match embedding model dim exactly; recreate collection if wrong |
| Search results irrelevant | Wrong distance metric, or mixing embed models between write & query | Cosine + same model both directions; re-embed corpus on model change |
| Data lost after container upgrade | No volume mount on storage path | Always mount /qdrant/storage; pin image versions |
| Client can't connect from another container | Bound to 127.0.0.1 only | Put both containers on a shared Docker network and use container name as host |
| Slow queries on large collections | Payload-heavy unindexed filters | Add payload indexes (create_payload_index); use gRPC client |
| 403 responses | API key enabled but client missing key | Pass api_key= in client constructor |
| Scores look like tiny floats, not 0โ1 | Metric semantics differ (Euclid returns distances) | Cosine scores are similarities; normalize expectations per metric |
| Collection won't accept writes: 409 | Duplicate point ID with different vector | Use deterministic IDs (hash of source+chunk_index) for idempotent re-indexing |
Next Steps / Ops Actions
- Fill the collection properly โ how to split documents first: document-chunking
- Wire retrieval into answering prompts: rag-pipeline
- Orchestrate loaders/retrievers with less boilerplate: langchain-integration
- Expose safely behind TLS if accessed off-box: traefik-v3-reverse-proxy
Sources & Related
External references consulted:
- https://qdrant.tech/documentation/guides/installation/
- https://qdrant.tech/documentation/concepts/collections/
- https://qdrant.tech/documentation/concepts/filtering/
- https://github.com/qdrant/qdrant-client
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation by KB build session.