This qdrant haystack document store tutorial walks you through the complete integration: spinning up Qdrant, configuring the Haystack document store, indexing documents with embeddings, and running dense, sparse, and hybrid retrieval pipelines. By the end you’ll have a production-ready retrieval backbone you can drop into any RAG system.
Step 1: Spin up Qdrant
Qdrant runs as a single container with no external dependencies. For local development, start it with Docker:
docker run -d \
--name qdrant \
-p 6333:6333 \
-p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.10.0
The HTTP API listens on 6333; gRPC on 6334. The volume mount persists collections across restarts. Verify the instance is healthy:
curl http://localhost:6333/healthz
# {"status":"ok"}
If you prefer a managed instance, Qdrant Cloud offers a free tier with 1 GB storage — just swap the host and add an API key in the next step.
Step 2: Install the Haystack Qdrant integration
Haystack keeps document store implementations in separate packages. Install the Qdrant backend plus an embedding model:
pip install haystack-ai qdrant-haystack sentence-transformers
haystack-ai (the 2.x line) is the current package name; the legacy farm-haystack is deprecated. Pin versions in your requirements file to avoid surprise upgrades:
haystack-ai==2.5.0
qdrant-haystack==1.4.0
sentence-transformers==3.0.1
Step 3: Configure the document store
Create a document_store.py module to centralize configuration. This keeps connection logic out of your pipeline code and makes testing easier.
# document_store.py
from haystack_integrations.document_stores.qdrant import QdrantDocumentStore
def get_document_store(
host: str = "localhost",
port: int = 6333,
index: str = "documents",
embedding_dim: int = 384,
recreate_index: bool = False,
) -> QdrantDocumentStore:
"""
Returns a configured QdrantDocumentStore.
Args:
host: Qdrant host (use your cloud URL for managed).
port: HTTP port.
index: Collection name in Qdrant.
embedding_dim: Must match your embedder output dimension.
recreate_index: Drop and recreate collection on init (dev only).
"""
return QdrantDocumentStore(
host=host,
port=port,
index=index,
embedding_dim=embedding_dim,
recreate_index=recreate_index,
# Optional: enable on-disk payload storage for large metadata
on_disk_payload=True,
# Optional: hnsw config for larger collections
hnsw_config={"m": 16, "ef_construct": 100},
)
Verify: Run a quick smoke test in a REPL:
from document_store import get_document_store
ds = get_document_store(recreate_index=True)
print(ds.count_documents()) # 0
Step 4: Write documents with embeddings
Haystack expects Document objects with content and optional meta. The document store handles embedding storage automatically when you pass an embedder to write_documents.
# index.py
from haystack import Document
from haystack.components.embedders import SentenceTransformersDocumentEmbedder
from document_store import get_document_store
# Sample corpus — replace with your data loader
raw_docs = [
Document(content="Qdrant is a vector database written in Rust.", meta={"source": "wiki", "topic": "database"}),
Document(content="Haystack 2.x uses a component-based pipeline architecture.", meta={"source": "docs", "topic": "framework"}),
Document(content="Sentence Transformers provides state-of-the-art sentence embeddings.", meta={"source": "blog", "topic": "embeddings"}),
]
# Initialize embedder (384-dim all-MiniLM-L6-v2)
embedder = SentenceTransformersDocumentEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
embedder.warm_up()
# Embed documents
docs_with_embeddings = embedder.run(raw_docs)["documents"]
# Write to Qdrant
ds = get_document_store(recreate_index=True)
ds.write_documents(docs_with_embeddings)
print(f"Indexed {ds.count_documents()} documents")
Verify: Check the Qdrant dashboard at http://localhost:6333/dashboard — you should see a collection named documents with three points, each carrying a 384-dim vector and your metadata.
Step 5: Dense retrieval with the Qdrant retriever
Haystack’s QdrantEmbeddingRetriever wraps the vector search. Wire it into a pipeline for reusable query logic.
# retrieve_dense.py
from haystack import Pipeline
from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack_integrations.components.retrievers.qdrant import QdrantEmbeddingRetriever
from document_store import get_document_store
ds = get_document_store()
query_embedder = SentenceTransformersTextEmbedder(model="sentence-transformers/all-MiniLM-L6-v2")
retriever = QdrantEmbeddingRetriever(document_store=ds, top_k=3)
pipe = Pipeline()
pipe.add_component("embedder", query_embedder)
pipe.add_component("retriever", retriever)
pipe.connect("embedder.embedding", "retriever.query_embedding")
question = "What vector database is written in Rust?"
result = pipe.run({"embedder": {"text": question}})
for doc in result["retriever"]["documents"]:
print(f"score={doc.score:.3f} | {doc.content[:80]}... | meta={doc.meta}")
Verify: The top result should be the Qdrant document with a score > 0.7. Adjust top_k and hnsw_config.ef (search-time ef) for recall/latency tradeoffs.
Step 6: Sparse retrieval with BM25
Qdrant supports sparse vectors natively. Haystack exposes this through QdrantSparseEmbeddingRetriever paired with a sparse embedder like FastEmbedSparseDocumentEmbedder (requires fastembed).
pip install fastembed
# index_sparse.py
from haystack import Document
from haystack_integrations.components.embedders.fastembed import FastEmbedSparseDocumentEmbedder
from document_store import get_document_store
raw_docs = [
Document(content="Qdrant vector database Rust performance", meta={"id": 1}),
Document(content="Haystack pipeline components retriever generator", meta={"id": 2}),
]
sparse_embedder = FastEmbedSparseDocumentEmbedder(model="Qdrant/bm25")
sparse_embedder.warm_up()
docs_sparse = sparse_embedder.run(raw_docs)["documents"]
ds = get_document_store(index="sparse_docs", embedding_dim=0) # dim=0 for sparse-only
ds.write_documents(docs_sparse)
Then retrieve:
# retrieve_sparse.py
from haystack import Pipeline
from haystack_integrations.components.embedders.fastembed import FastEmbedSparseTextEmbedder
from haystack_integrations.components.retrievers.qdrant import QdrantSparseEmbeddingRetriever
from document_store import get_document_store
ds = get_document_store(index="sparse_docs", embedding_dim=0)
pipe = Pipeline()
pipe.add_component("embedder", FastEmbedSparseTextEmbedder(model="Qdrant/bm25"))
pipe.add_component("retriever", QdrantSparseEmbeddingRetriever(document_store=ds, top_k=3))
pipe.connect("embedder.sparse_embedding", "retriever.query_sparse_embedding")
result = pipe.run({"embedder": {"text": "vector database Rust"}})
for doc in result["retriever"]["documents"]:
print(f"score={doc.score:.3f} | {doc.content}")
Step 7: Hybrid retrieval (dense + sparse)
Production RAG systems almost always benefit from hybrid search. Qdrant supports fused scoring via prefetch — Haystack exposes this through QdrantHybridRetriever.
# retrieve_hybrid.py
from haystack import Pipeline
from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack_integrations.components.embedders.fastembed import FastEmbedSparseTextEmbedder
from haystack_integrations.components.retrievers.qdrant import QdrantHybridRetriever
from document_store import get_document_store
# Collection must have both dense and sparse vectors
ds = get_document_store(index="hybrid_docs", embedding_dim=384)
pipe = Pipeline()
pipe.add_component("dense_embedder", SentenceTransformersTextEmbedder(model="sentence-transformers/all-MiniLM-L6-v2"))
pipe.add_component("sparse_embedder", FastEmbedSparseTextEmbedder(model="Qdrant/bm25"))
pipe.add_component("retriever", QdrantHybridRetriever(document_store=ds, top_k=5))
pipe.connect("dense_embedder.embedding", "retriever.query_embedding")
pipe.connect("sparse_embedder.sparse_embedding", "retriever.query_sparse_embedding")
result = pipe.run({
"dense_embedder": {"text": "Rust vector database"},
"sparse_embedder": {"text": "Rust vector database"},
})
for doc in result["retriever"]["documents"]:
print(f"score={doc.score:.3f} | {doc.content[:100]}")
The hybrid retriever runs a prefetch for each vector type and fuses results using Reciprocal Rank Fusion (RRF) by default. You can tune fusion_type (“rrf” | “dbsf”) and fusion_params in the retriever constructor.
Step 8: Metadata filtering
Qdrant’s payload filtering is a major advantage over pure ANN indexes. Haystack passes filters dictionaries directly to Qdrant’s Filter DSL.
# filter_example.py
from haystack import Pipeline
from haystack.components.embedders import SentenceTransformersTextEmbedder
from haystack_integrations.components.retrievers.qdrant import QdrantEmbeddingRetriever
from document_store import get_document_store
ds = get_document_store()
pipe = Pipeline()
pipe.add_component("embedder", SentenceTransformersTextEmbedder(model="sentence-transformers/all-MiniLM-L6-v2"))
pipe.add_component("retriever", QdrantEmbeddingRetriever(document_store=ds, top_k=3))
pipe.connect("embedder.embedding", "retriever.query_embedding")
# Filter: only docs where meta.topic == "database"
filters = {
"operator": "AND",
"conditions": [
{"field": "meta.topic", "operator": "==", "value": "database"}
]
}
result = pipe.run({
"embedder": {"text": "vector search"},
"retriever": {"filters": filters}
})
for doc in result["retriever"]["documents"]:
print(f"score={doc.score:.3f} | topic={doc.meta.get('topic')} | {doc.content[:80]}")
Verify: Only the Qdrant document (topic=database) should return, even if other documents have higher vector similarity.
Filter syntax cheat sheet
| Operator | Haystack dict | Qdrant equivalent |
|---|---|---|
| Equality | {"field": "meta.tag", "operator": "==", "value": "prod"} |
FieldCondition(key="meta.tag", match=MatchValue(value="prod")) |
| In | {"field": "meta.ids", "operator": "in", "value": [1,2,3]} |
MatchAny(any=[1,2,3]) |
| Range | {"field": "meta.date", "operator": ">=", "value": "2024-01-01"} |
Range(gte="2024-01-01") |
| Geo | {"field": "meta.loc", "operator": "geo_radius", "value": {"lat": 40.7, "lon": -74.0, "radius": 1000}} |
GeoRadius(center=..., radius=...) |
Nested metadata works automatically — use dot notation: meta.author.name.
Step 9: Update and delete documents
Qdrant supports upsert by point ID. Haystack’s write_documents with policy=DuplicatePolicy.OVERWRITE handles this, but you need stable IDs.
# update_delete.py
from haystack import Document
from haystack_integrations.document_stores.qdrant import DuplicatePolicy
from document_store import get_document_store
ds = get_document_store()
# Assign explicit IDs
docs = [
Document(id="doc-1", content="Updated content", meta={"version": 2}),
Document(id="doc-2", content="Another doc", meta={"version": 1}),
]
# Upsert
ds.write_documents(docs, policy=DuplicatePolicy.OVERWRITE)
# Delete by ID
ds.delete_documents(ids=["doc-2"])
print(f"Remaining: {ds.count_documents()}") # 1
Verify: ds.get_documents_by_id(["doc-1"]) returns the updated document; doc-2 raises DocumentNotFoundError.
Step 10: Production hardening
Connection pooling and timeouts
The underlying qdrant-client uses httpx. Configure a shared client for connection reuse:
# document_store_prod.py
import httpx
from haystack_integrations.document_stores.qdrant import QdrantDocumentStore
http_client = httpx.Client(
limits=httpx.Limits(max_connections=50, max_keepalive_connections=10),
timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0),
)
def get_prod_document_store(**kwargs) -> QdrantDocumentStore:
return QdrantDocumentStore(
http_client=http_client,
prefer_grpc=True, # lower latency for high throughput
**kwargs,
)
Quantization for large collections
For collections > 1M vectors, enable scalar or binary quantization to cut memory and latency:
ds = QdrantDocumentStore(
index="large_corpus",
embedding_dim=768,
quantization_config={
"scalar": {"type": "int8", "quantile": 0.99, "always_ram": True}
},
# Or binary for 32x compression (requires normalized vectors)
# quantization_config={"binary": {"always_ram": True}},
)
Monitoring
Expose Qdrant metrics (/metrics) to Prometheus. Key series:
qdrant_collection_points_total— index sizeqdrant_request_duration_seconds_bucket— p50/p99 latencyqdrant_optimization_status— background indexing health
Set alerts on p99 > 500ms or optimization stuck > 10min.
Verification checklist
Run through this list before calling the integration done:
- Qdrant health endpoint returns
{"status":"ok"} -
document_store.count_documents()matches expected corpus size - Dense retriever returns relevant results for known queries
- Sparse retriever surfaces keyword matches dense misses
- Hybrid retriever outperforms either alone on your eval set
- Metadata filters correctly include/exclude documents
- Upsert by ID updates without creating duplicates
- Delete by ID removes documents cleanly
- Connection pool handles concurrent pipeline runs without socket exhaustion
- Quantization (if enabled) maintains recall > 0.95 on your test set
Where to go next
- Reranking: Add a
TransformersSimilarityRankerorCohereRankerafter retrieval for precision gains. - Generative pipeline: Wire the retriever into a
PromptBuilder→Generatorloop for full RAG. - Multi-tenancy: Use
meta.tenant_idfilters with a single collection, or separate collections per tenant for strict isolation. - Streaming updates: Pair Qdrant’s write-ahead log with a CDC pipeline (Debezium → Kafka → Qdrant) for near-realtime indexing.
The Qdrant-Haystack integration is mature enough for production workloads. The main operational lever is collection sizing — monitor memory, tune HNSW m/ef_construct, and enable quantization before you hit resource limits.