Qdrant: Vector Database Setup, Semantic Search, and Hybrid Queries
You embedded a few thousand documents, and then you realized you do not actually have a search engine. A Python loop over cosine similarities works until it does not: nothing is persisted, nothing is filtered, and every query scans everything. That is the moment people start shopping for a vector database, and the shortlist usually comes down to pgvector, Chroma, and Qdrant. This guide sets up Qdrant for real, with code that runs, and ends with a straight answer on which of the three fits your case.
Prerequisites
- Docker Engine (check with
docker version) - Python 3.10+
- Basic familiarity with Python and pip
Qdrant runs as a service. Everything here targets a local container, so no cloud account is needed.
Step 1: Run Qdrant with Docker
Pull the image and start the server:
docker pull qdrant/qdrant
docker run -p 6333:6333 -p 6334:6334 \
-v "$(pwd)/qdrant_storage:/qdrant/storage" \
qdrant/qdrant
Two ports matter:
6333is the REST API, and the web dashboard lives athttp://localhost:6333/dashboard6334is the gRPC API, used by the Rust, Go, Java, and C# clients
The -v flag keeps every byte of data in ./qdrant_storage, so restarts lose nothing. Qdrant is written in Rust, released under Apache 2.0, and the Docker latest tag is v1.19 at the time of writing.
Step 2: Install the Python client
pip install qdrant-client
Connect and check health:
from qdrant_client import QdrantClient
client = QdrantClient(url="http://localhost:6333")
print(client.get_collections())
You should see an empty collection list. If the connection fails, check that the container is still running.
Step 3: Create a collection
A collection is Qdrant's equivalent of a table. Two settings matter at creation time: size, which must match your embedding model's output dimension, and distance, the similarity metric.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name="articles",
vectors_config=models.VectorParams(
size=384,
distance=models.Distance.COSINE,
),
)
The distance options are COSINE, DOT, and EUCLID. Cosine is the safe default for text embeddings: it compares direction rather than magnitude, and most embedding providers normalize vectors anyway. The 384 here matches the BAAI/bge-small-en-v1.5 model used below.
One thing to decide before ingesting data: collection settings are fixed at creation. Changing the dimension later means recreating the collection, so pick size and distance once and stick with them.
Step 4: Embed text and upsert points
Install fastembed, Qdrant's embedding library:
pip install fastembed
from fastembed import TextEmbedding
model = TextEmbedding("BAAI/bge-small-en-v1.5")
def embed(text: str) -> list:
return list(model.embed([text]))[0].tolist()
Now embed a handful of text chunks and store them. The payload is arbitrary JSON attached to each vector, and this is what lets you filter search results later.
docs = [
("Qdrant is a vector database written in Rust. It stores vectors on disk and indexes them with HNSW.", "qdrant", "overview"),
("Chroma runs inside your Python process, which makes it fast to prototype and awkward to scale.", "chroma", "overview"),
("pgvector adds vector search to PostgreSQL as an extension, so vectors live next to relational data.", "pgvector", "overview"),
("Hybrid search combines dense and sparse vectors to match both meaning and exact keywords.", "concepts", "search"),
]
client.upsert(
collection_name="articles",
points=[
models.PointStruct(
id=idx,
vector=embed(text),
payload={"text": text, "tool": tool, "category": category},
)
for idx, (text, tool, category) in enumerate(docs)
],
wait=True,
)
Points are (id, vector, payload) tuples. wait=True blocks until the write is indexed, which is what you want in a script. For bulk loads, batch the upserts instead and drop the flag.
Step 5: Run a semantic search
query = "which database runs inside the application process?"
hits = client.query_points(
collection_name="articles",
query=embed(query),
limit=3,
).points
for hit in hits:
print(hit.id, round(hit.score, 3), hit.payload["tool"])
Real output from this exact script:
1 0.659 chroma
0 0.574 qdrant
2 0.557 pgvector
With cosine distance, a higher score means closer meaning. The query is about in-process databases, and the Chroma chunk lands first even though the word "process" appears nowhere in the query. That is the semantic half of semantic search.
Step 6: Filter with payload
Raw similarity is rarely the whole query. Production searches usually carry a constraint: only this tenant, only this author, only documents from this source.
hits = client.query_points(
collection_name="articles",
query=embed("vector database"),
query_filter=models.Filter(
must=[
models.FieldCondition(
key="tool",
match=models.MatchValue(value="pgvector"),
)
]
),
limit=3,
).points
for hit in hits:
print(hit.id, hit.payload["text"])
This searches only points where tool == "pgvector", and the filter runs before ranking, not after. That leads to a habit worth adopting early: any field you filter on should get a payload index, or every search degrades into a full scan.
client.create_payload_index(
collection_name="articles",
field_name="tool",
field_schema=models.PayloadSchemaType.KEYWORD,
)
Step 7: Hybrid search with dense and sparse vectors
Exact keywords are where pure dense search is weak. Ask for "HNSW" and a good embedding might still rank a generic paragraph about graph indexes above the one that literally contains the word. Combining a dense vector for meaning with a sparse vector for exact terms fixes that, and Qdrant's prefetch plus fusion API is built for exactly this.
A collection can hold both vector types as named vectors:
if client.collection_exists("articles"):
client.delete_collection("articles")
client.create_collection(
collection_name="articles",
vectors_config={
"dense": models.VectorParams(size=384, distance=models.Distance.COSINE),
},
sparse_vectors_config={
"text": models.SparseVectorParams(),
},
)
Sparse vectors store only their non-zero indices and values. In production you produce them with a sparse encoder such as SPLADE. To keep this example dependency-free, a small term-frequency encoder does the same job and keeps counts per token instead of collapsing duplicates:
vocab = {}
def sparse_encode(text: str) -> models.SparseVector:
counts = {}
for token in text.lower().split():
idx = vocab.setdefault(token, len(vocab))
counts[idx] = counts.get(idx, 0) + 1
indices = list(counts.keys())
values = [float(counts[i]) for i in indices]
return models.SparseVector(indices=indices, values=values)
Upsert every point with both vector keys:
client.upsert(
collection_name="articles",
points=[
models.PointStruct(
id=idx,
vector={
"dense": embed(text),
"text": sparse_encode(text),
},
payload={"text": text, "tool": tool, "category": category},
)
for idx, (text, tool, category) in enumerate(docs)
],
wait=True,
)
Then run both searches and fuse the results with reciprocal rank fusion (RRF). RRF ignores raw scores and works on rank position, so two retrievers with totally different score scales combine without calibration:
query = "Rust database with HNSW indexing"
hits = client.query_points(
collection_name="articles",
prefetch=[
models.Prefetch(query=sparse_encode(query), using="text", limit=20),
models.Prefetch(query=embed(query), using="dense", limit=20),
],
query=models.RrfQuery(rrf=models.Rrf(k=60)),
limit=5,
).points
for hit in hits:
print(hit.id, hit.payload["tool"], "|", hit.payload["text"][:60])
Real output from this exact script:
0 qdrant | Qdrant is a vector database written in Rust. It stores vecto
2 pgvector | pgvector adds vector search to PostgreSQL as an extension, s
3 concepts | Hybrid search combines dense and sparse vectors to match bot
1 chroma | Chroma runs inside your Python process, which makes it fast
The qdrant chunk wins on both sides: dense similarity and exact matches for "rust" and "hnsw". The k constant smooths the fusion. Lower values amplify top ranks, higher values give deeper results more say. The default is 2, and 60 appears in Qdrant's own examples. Weighted RRF, which lets you trust one retriever more than the other, has been available since v1.17.
Qdrant vs pgvector vs Chroma
All three do vector search. They differ in where the data lives and how far they scale.
| Choose | When |
|---|---|
| pgvector | You already run PostgreSQL and vectors can live next to relational data. Filters become ordinary SQL, and there is no extra service to operate. Community benchmarks place it comfortably in the 10M+ vector range for most apps. |
| Chroma | Prototyping and notebooks. It runs in-process, so the first demo takes minutes. Single node, memory-bound, no built-in sharding, so plan the migration before real concurrent traffic shows up. |
| Qdrant | A standalone service that must serve real users. Native payload filtering that runs before ranking, quantization options (roughly 8x compression with TurboQuant on the v1.18 line), hybrid search, and sharding and replication for growth. |
The starting point matters more than the feature list. If Postgres is already running, pgvector removes an entire service from your stack. If you need search quality features like hybrid retrieval and predictable latency under load, a purpose-built database like Qdrant earns its keep. If you are still sketching a demo, Chroma is fine, as long as you know it is a prototype, not a platform.
What to do next
- Take snapshots regularly. Both the dashboard and the API support creating them, which gives you point-in-time backups of the whole storage directory.
- Read the production checklist before exposing Qdrant publicly: it covers authentication, snapshots, and operator dashboards.
- For large collections, look at quantization to cut memory per vector, then choose between on-disk and in-memory storage per collection.