All writing
qdrant vector-search python rag

Qdrant Python Search: Filters, Hybrid, and Tuning

Ayush Kaushik 6 min read
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 QdrantClient
from qdrant_client.models import PointStruct, VectorParams, Distance
client = QdrantClient(url="http://localhost:6333")
# Ensure collection exists
client.recreate_collection(
collection_name="docs",
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
# Upsert a few points
client.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 search
results = 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 = None
while 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_offset

For 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 = None
while 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:
break

Do 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 scroll for 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 m and ef_construct without 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.

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_points with Prefetch for hybrid dense and sparse retrieval.
  • Filter during graph traversal with query_filter not post-filtering in application code.
  • Paginate with next_page_offset on ranked results, use scroll only for unordered exports.
  • Tune hnsw_ef at query time for the latency-recall tradeoff. Tune m and ef_construct at 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.

Keep reading

Related articles