Qdrant Tutorial: Vector Search From Scratch
Stand up a Qdrant vector database, load embeddings, and run filtered nearest-neighbor search in Python - the core loop behind semantic search and RAG.
What is Qdrant?
Qdrant is an open-source vector database and similarity search engine written in Rust. Where a traditional database answers "find rows where this column equals that value," a vector database answers "find the items whose embedding is closest to this one." That single capability is the basis of semantic search, recommendation systems, deduplication, and retrieval-augmented generation (RAG) for large language models.
The unit of data in Qdrant is a point: an integer or UUID id, a dense vector (the embedding), and an optional JSON payload of metadata such as a title, category, or timestamp. Points live inside a collection, which fixes the vector dimensionality and the distance metric used for comparisons. Qdrant exposes a REST API on port 6333 and a faster gRPC API on port 6334, and there are official client libraries for Python, JavaScript, Rust, Go, and Java.
What sets Qdrant apart from a bare index library is everything wrapped around the math: persistence, a network service, snapshots and backups, quantization to shrink memory, and - most importantly - the ability to combine vector similarity with structured payload filters in a single query. That last point matters in production, where you almost never want "the closest vector" but "the closest vector that also belongs to this tenant and was published this year."
How Qdrant works (key concepts)
A few concepts cover most of what you need.
Collections are namespaces for points. You set the vector size (the embedding dimensionality, for example 384 for all-MiniLM-L6-v2 or 1536 for many OpenAI models) and a distance metric. Every vector you insert must match that size.
Distance metric decides what "close" means. Qdrant supports Cosine, Dot, and Euclid. Use the metric your embedding model was trained for - cosine similarity is the safe default for most text embedding models, while normalized embeddings can use dot product interchangeably.
Indexing uses HNSW (Hierarchical Navigable Small World), an approximate nearest-neighbor graph. Approximate means search is sublinear and fast on millions of vectors, at the cost of occasionally missing a true neighbor. You trade recall against speed by tuning HNSW parameters like m and ef.
Payload and filtering let you attach JSON to each point and constrain searches with conditions. Importantly, Qdrant applies filters during the search rather than as a post-filter on the top results, so a restrictive filter still returns accurate results.
Installing Qdrant
The fastest way to get a local instance is the official Docker image. It serves the REST API on 6333, gRPC on 6334, and a built-in web dashboard at http://localhost:6333/dashboard.
docker pull qdrant/qdrant
docker run -p 6333:6333 -p 6334:6334 \
-v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
qdrant/qdrant
The -v flag mounts a host directory so your collections survive container restarts; without it the data lives only inside the container.
Next install the Python client:
pip install qdrant-client
For quick experiments or unit tests you do not even need the server. The Python client can run a fully in-memory instance, which is useful for trying the API before you set up Docker:
from qdrant_client import QdrantClient
# In-memory, no server required:
client = QdrantClient(":memory:")
# Or connect to the Docker container:
# client = QdrantClient(url="http://localhost:6333")
Build a searchable collection in Python
Here is the complete loop: create a collection, insert points with payloads, and run both an unfiltered and a filtered query. This example uses tiny hand-written 4-dimensional vectors so you can see exactly what happens; in a real system those vectors would come from an embedding model.
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue,
)
client = QdrantClient(":memory:")
client.create_collection(
collection_name="cities",
vectors_config=VectorParams(size=4, distance=Distance.COSINE),
)
client.upsert(
collection_name="cities",
wait=True,
points=[
PointStruct(id=1, vector=[0.05, 0.61, 0.76, 0.74], payload={"city": "Berlin", "country": "DE"}),
PointStruct(id=2, vector=[0.19, 0.81, 0.75, 0.11], payload={"city": "London", "country": "UK"}),
PointStruct(id=3, vector=[0.36, 0.55, 0.47, 0.94], payload={"city": "Moscow", "country": "RU"}),
],
)
# Plain nearest-neighbor query
hits = client.query_points(
collection_name="cities",
query=[0.2, 0.1, 0.9, 0.7],
limit=2,
).points
for h in hits:
print(h.id, round(h.score, 3), h.payload["city"])
# Same query, restricted to UK results only
filtered = client.query_points(
collection_name="cities",
query=[0.2, 0.1, 0.9, 0.7],
query_filter=Filter(
must=[FieldCondition(key="country", match=MatchValue(value="UK"))]
),
limit=2,
).points
print("filtered:", [h.payload["city"] for h in filtered])
A few things worth noting. query_points is the current unified query method; the older client.search is deprecated, so new code should use query_points. The returned objects carry a score (higher is more similar for cosine and dot) and the stored payload. The wait=True flag on upsert blocks until the write is indexed, which keeps small scripts deterministic; for bulk loads you would batch points in chunks of a few hundred to a thousand and skip waiting on every batch.
To wire this into RAG, you replace the literal vectors with embeddings of your documents at upsert time and the embedding of the user's question at query time, then feed the returned payloads into your LLM prompt as context.
When to use Qdrant
Reach for Qdrant when similarity is the query - semantic search over documents, "more like this" recommendations, near-duplicate detection, or the retrieval half of a RAG pipeline. It works well when you need vector search and structured filtering together, when your dataset outgrows an in-process index, or when several services need to share the same searchable store over a network.
It is less suited to problems that exact keyword matching or a relational database already solves well. If your queries are "rows where status = 'open'" with no notion of semantic closeness, a vector database adds cost without benefit. And for a small prototype with a few thousand vectors that never leaves one process, a library like FAISS may be simpler. Once you need persistence, filtering, an API, or scale, Qdrant is a logical next step.
Frequently asked questions
What is Qdrant?
Qdrant is an open-source vector database and similarity search engine written in Rust. It stores high-dimensional embedding vectors alongside JSON payloads and returns the nearest neighbors to a query vector, with optional metadata filtering. It is commonly used for semantic search, recommendations, and retrieval-augmented generation.
Is client.search deprecated in qdrant-client?
Yes. The older client.search method is deprecated, and newer qdrant-client versions use client.query_points as the unified query interface. query_points covers plain nearest-neighbor search plus newer features like hybrid search, recommendations, and prefetch, so prefer it in new code.
Do I need a GPU to run Qdrant?
No. Qdrant runs entirely on CPU for both indexing (HNSW) and search. You only need a GPU to generate embeddings with a local model, and even that is optional since you can call a hosted embeddings API instead.
How is Qdrant different from a plain vector index like FAISS?
FAISS is an in-process similarity-search library; Qdrant is a full database service. Qdrant adds a network API, persistent storage, JSON payload filtering combined with vector search, collection management, snapshots, and horizontal scaling - things you would otherwise build yourself around a raw index.
Do real production work, free. Live cloud workspaces in your browser - every project you complete lands on your portfolio.
Get started - free