Qdrant Python Search: Filters, Hybrid, and Tuning
I run vector search in production on a few different services. If you are looking for the short answer on qdrant python search, start with query_points for basic similarity, add Filter objects for metadata constraints, and use Prefetch for hybrid dense and sparse retrieval. The rest of this post covers the patterns that actually hold up under load.
How do I run a basic similarity search with query_points?
Use the query_points method on the client. It replaces the older search method and supports the full feature set including vector names and pagination.
from qdrant_client import QdrantClientfrom qdrant_client.models import PointStruct, VectorParams, Distance
client = QdrantClient(url="http://localhost:6333")
# Ensure collection existsclient.recreate_collection( collection_name="docs", vectors_config=VectorParams(size=384, distance=Distance.COSINE),)
# Upsert a few pointsclient.upsert( collection_name="docs", points=[ PointStruct(id=1, vector=[0.1] * 384, payload={"title": "intro"}), PointStruct(id=2, vector=[0.9] * 384, payload={"title": "advanced"}), ],)
# Basic similarity searchresults = client.query_points( collection_name="docs", query=[0.15] * 384, limit=5, with_payload=True,)
for point in results.points: print(point.id, point.score, point.payload)The query argument accepts a dense vector list or a Document object if you are using the built-in embeddings integration. I prefer passing raw vectors so the embedding logic stays in my application layer. That keeps retries and timeouts explicit.
How do I filter results with payload conditions and range filters?
Attach a Filter object to the query_filter parameter. Qdrant evaluates filters during the graph traversal, not as a post-filter step. This matters for recall.
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
results = client.query_points( collection_name="docs", query=[0.15] * 384, query_filter=Filter( must=[ FieldCondition(key="category", match=MatchValue(value="engineering")), FieldCondition(key="created_at", range=Range(gte=1704067200, lte=1735689600)), ], should=[ FieldCondition(key="tags", match=MatchValue(value="python")), ], ), limit=10,)Range filters work on integer, float, and datetime payloads stored as timestamps. I’ve been bitten by timezone drift when storing datetimes as strings. Store epoch seconds or milliseconds as integers. It makes range queries faster and avoids parsing ambiguity.
A should clause acts like an OR boost. Points matching the should condition rank higher but are not required. Use must_not for hard exclusions like soft-deleted records.
How does hybrid search work combining dense and sparse vectors?
Hybrid search runs a dense vector search and a sparse vector search in parallel then fuses the scores. In the Python client you express this with Prefetch objects inside the main query_points call.
from qdrant_client.models import Prefetch, FusionQuery, Fusion
results = client.query_points( collection_name="docs", prefetch=[ Prefetch( query=[0.15] * 384, using="dense", limit=50, ), Prefetch( query={"indices": [10, 20, 30], "values": [0.5, 0.8, 0.2]}, using="sparse", limit=50, ), ], query=FusionQuery(fusion=Fusion.RRF), limit=10,)The collection must have two named vectors: one dense and one sparse. The using parameter in each Prefetch matches the vector name. Reciprocal Rank Fusion (RRF) is the default and works well without tuning. You can switch to Fusion.DBSF if you need distribution-based score fusion.
I covered the RAG side of this pattern in Hybrid Search RAG Implementation: A Practical Guide. That post focuses on the retrieval pipeline. Here the focus is the client call shape.
Sparse vectors usually come from BM25 or SPLADE encoders. The Python client expects a dict with indices and values arrays. Keep the sparse dimension consistent across your dataset or the HNSW graph build will fail.
How do I paginate large result sets with the scroll API?
query_points returns a QueryResponse with a next_page_offset when there are more results. Pass that offset back in the next call.
offset = Nonewhile True: response = client.query_points( collection_name="docs", query=[0.15] * 384, limit=100, offset=offset, with_payload=["title", "category"], ) for point in response.points: process(point)
if response.next_page_offset is None: break offset = response.next_page_offsetFor very large exports use the scroll method instead. It does not rank by vector similarity. It iterates points in storage order with optional filtering. That makes it faster for full scans.
scroll_offset = Nonewhile True: records, scroll_offset = client.scroll( collection_name="docs", scroll_filter=Filter(must=[FieldCondition(key="category", match=MatchValue(value="engineering"))]), limit=500, offset=scroll_offset, with_payload=True, ) for record in records: export(record) if scroll_offset is None: breakDo not use scroll for similarity search. It ignores the vector index entirely.
What HNSW parameters and quantization settings actually move the needle?
HNSW parameters live in the HnswConfigDiff object during collection creation or update. The two knobs that matter most are m and ef_construct.
from qdrant_client.models import HnswConfigDiff
client.update_collection( collection_name="docs", hnsw_config=HnswConfigDiff(m=16, ef_construct=100),)m: Number of bi-directional links per node. Higher values increase recall and memory usage. Default is 16. I rarely go above 32.ef_construct: Size of the dynamic candidate list during index build. Higher values build a better graph at the cost of build time. Default is 100. For high-recall requirements push this to 200 or 400.
At query time you control ef via SearchParams. This is the search-time candidate list size.
from qdrant_client.models import SearchParams
results = client.query_points( collection_name="docs", query=[0.15] * 384, search_params=SearchParams(hnsw_ef=128, exact=False), limit=10,)Set hnsw_ef at query time based on your latency budget. Higher ef improves recall but increases latency linearly. I run a nightly benchmark job that sweeps ef from 32 to 512 and picks the lowest value that hits my recall target.
Quantization
Scalar quantization compresses float32 vectors to int8. It reduces memory by 4x and speeds up distance computation.
from qdrant_client.models import ScalarQuantizationConfig, ScalarQuantization
client.update_collection( collection_name="docs", quantization_config=ScalarQuantization( scalar=ScalarQuantizationConfig( type="int8", quantile=0.99, always_ram=True, ) ),)quantile=0.99 clips outliers before quantization. This preserves accuracy for the bulk of the distribution. always_ram=True keeps the quantized vectors in memory. If your dataset exceeds RAM, set it to false and Qdrant will memory-map the quantized index.
Product quantization (PQ) compresses further but requires a training step. I only reach for PQ when the index is over 50M vectors and RAM is constrained. The build complexity usually is not worth it for smaller collections.
When not to use these patterns
- Skip hybrid search if your queries are purely semantic and you have no keyword matching requirement. The extra index and prefetch logic add latency.
- Avoid
scrollfor user-facing search. It returns arbitrary order. - Do not enable quantization on collections under 100k vectors unless you are memory constrained on a small instance. The accuracy hit on small datasets is visible.
- Don’t tune
mandef_constructwithout a labeled eval set. You will optimize for the wrong metric.
FAQ
What is the difference between query_points and search in the Qdrant Python client?
query_points is the current unified method. It supports named vectors, prefetch for hybrid search, and pagination offsets. The legacy search method is deprecated and lacks these features.
How do I choose between RRF and DBSF fusion for hybrid search?
Start with RRF. It is parameter-free and robust across domains. Switch to DBSF only if you have a labeled eval set showing measurable gains on your specific data distribution.
Does quantization always reduce recall?
Scalar quantization with a 0.99 quantile typically costs less than 1-2% recall on dense embeddings. Product quantization can cost 5-10% depending on the number of sub-vectors. Always measure on your own data.
Can I filter on a field that is not indexed?
Yes, but Qdrant will fall back to a payload scan for that condition. For production filters, create a payload index on the field first using create_payload_index.
Key Takeaways
- Use
query_pointswithPrefetchfor hybrid dense and sparse retrieval. - Filter during graph traversal with
query_filternot post-filtering in application code. - Paginate with
next_page_offseton ranked results, usescrollonly for unordered exports. - Tune
hnsw_efat query time for the latency-recall tradeoff. Tunemandef_constructat build time. - Enable scalar quantization for memory savings on collections over 100k vectors.
- Benchmark every change against a labeled eval set. Intuition fails on vector search.
Working on something similar?
If you're building backend or AI systems and want a second set of senior eyes, let's talk.