docs: Add course content for Day 2 (#1928)

* docs: Add course content for Day 2

* included feedback

* cleaned up spelling and removed repetitive info

* added warning about reindexing

* pitstop project fixed

* finished what-is-hnsw

* filterable hnsw file done

* updated image embedding

* fixed what is hnsw code

* header level

* updated filterable hnsw

* fixed pitstop and added waiting for index (which breaks the whole thing, because index doesnt build).

* added discord link

* updated discord

* updated project structur

* updated structure

* last fixes aligning notebook and markdown

* standart init for client

* pitstop structure

* pitstop fix

* pitstop fix

---------

Co-authored-by: Kirstin <kirstin.taufertshoefer@qdrant.com>
Co-authored-by: Evgeniya Sukhodolskaya <suxodolskaya97@gmail.com>
This commit is contained in:
Kirstin
2025-10-22 13:22:18 +02:00
committed by GitHub
co-authored by Kirstin Evgeniya Sukhodolskaya
parent c19e60bf77
commit b72da43166
8 changed files with 1449 additions and 3 deletions
@@ -0,0 +1,384 @@
---
title: "Project: HNSW Performance Benchmarking"
weight: 4
---
{{< date >}} Day 2 {{< /date >}}
# Project: HNSW Performance Benchmarking
Now that you've seen how [HNSW](https://qdrant.tech/articles/filtrable-hnsw/) parameters and payload indexes affect performance with the DBpedia dataset, it's time to optimize for your own domain and use case.
## Your Mission
Build on your Day 1 search engine by adding performance optimization. You'll discover which HNSW settings work best for your specific data and queries, and measure the real impact of payload indexing.
**Estimated Time:** 90 minutes
## What You'll Build
A performance-optimized version of your Day 1 search engine that demonstrates:
- **Fast bulk load**: Load with `m=0`, then switch to HNSW
- **HNSW parameter tuning**: Try different `m` and `ef_construct`
- **Payload indexing impact**: Time filtering with and without indexes
- **Domain findings**: What works best for your content
## Setup
### Prerequisites
* Qdrant Cloud cluster (URL + API key)
* Python 3.9+ (or Google Colab)
* Packages: `qdrant-client`, `sentence-transformers`, `python-dotenv`, `numpy`
### Models
* Embeddings: `sentence-transformers/all-MiniLM-L6-v2` (384-dim)
### Dataset
* Reuse your Day 1 domain data or prepare a dataset with **1,000+ items** and a rich text field (e.g., `description`).
* Include a few numeric fields for filtering (e.g., `length`, `word_count`) so payload indexing impact can be measured.
## Build Steps
### Step 1: Extend Your Day 1 Project
Start with your domain search engine from Day 1, or create a new one with 1000+ items:
```python
from qdrant_client import QdrantClient, models
from sentence_transformers import SentenceTransformer
import time
import numpy as np
import os
from dotenv import load_dotenv
load_dotenv()
client = QdrantClient(url=os.getenv("QDRANT_URL"), api_key=os.getenv("QDRANT_API_KEY"))
# For Colab:
# from google.colab import userdata
# client = QdrantClient(url=userdata.get("QDRANT_URL"), api_key=userdata.get("QDRANT_API_KEY"))
encoder = SentenceTransformer("all-MiniLM-L6-v2")
```
### Step 2: Create Multiple Test Collections
Test different HNSW configurations to find what works best:
```python
# Test configurations
configs = [
{"name": "fast_initial_upload", "m": 0, "ef_construct": 100},
{"name": "memory_optimized", "m": 8, "ef_construct": 100},
{"name": "balanced", "m": 16, "ef_construct": 200},
{"name": "high_quality", "m": 32, "ef_construct": 400},
]
for config in configs:
collection_name = f"my_domain_{config['name']}"
if client.collection_exists(collection_name=collection_name):
client.delete_collection(collection_name=collection_name)
client.create_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(size=384, distance=models.Distance.COSINE),
hnsw_config=models.HnswConfigDiff(
m=config["m"], ef_construct=config["ef_construct"], full_scan_threshold=10
),
optimizers_config=models.OptimizersConfigDiff(indexing_threshold=0),
strict_mode_config=models.StrictModeConfig(
unindexed_filtering_retrieve=True, unindexed_filtering_update=True
),
)
print(f"Created collection: {collection_name}")
```
### Step 3: Upload and Time
Measure upload performance for each configuration:
```python
def upload_with_timing(collection_name, data, config_name):
embeddings = [encoder.encode(dat["description"]).tolist() for dat in data]
points = []
for i, item in enumerate(data):
embedding = embeddings[i]
points.append(
models.PointStruct(
id=i,
vector=embedding,
payload={
**item,
"length": len(item["description"]),
"word_count": len(item["description"].split()),
"has_keywords": any(
keyword in item["description"].lower()
for keyword in ["important", "key", "main"]
),
},
)
)
start_time = time.time()
client.upload_points(collection_name=collection_name, points=points)
upload_time = time.time() - start_time
print(f"{config_name}: Uploaded {len(points)} points in {upload_time:.2f}s")
return upload_time
# Load your dataset here
# your_dataset = [{"description": "This is a description of a product"}, ...]
# Upload to each collection
upload_times = {}
for config in configs:
collection_name = f"my_domain_{config['name']}"
upload_times[config["name"]] = upload_with_timing(
collection_name, your_dataset, config["name"]
)
# Wait for index to be built
def wait_for_index_built(collection_name, vectors_per_point=1):
info = client.get_collection(collection_name=collection_name)
count = 0
while info.points_count * vectors_per_point - info.indexed_vectors_count != 0 and count < 10:
time.sleep(1)
info = client.get_collection(collection_name=collection_name)
count += 1
if count == 10:
raise Exception(
f"Indexed vectors count ({info.indexed_vectors_count}) is not equal to points count ({info.points_count}). Upload enough points to trigger index rebuild."
)
for config in configs:
collection_name = f"my_domain_{config['name']}"
wait_for_index_built(collection_name)
```
### Step 4: Benchmark Search Performance
Test search speed with different `hnsw_ef` values:
```python
def benchmark_search(collection_name, query_embedding, ef_values=[64, 128, 256]):
# Warmup
_ = client.query_points(
collection_name=collection_name,
query=query_embedding,
limit=10,
search_params=models.SearchParams(hnsw_ef=ef_values[0]),
)
results = {}
for hnsw_ef in ef_values:
times = []
# Run multiple queries for more reliable timing
for _ in range(5):
start_time = time.time()
_ = client.query_points(
collection_name=collection_name,
query=query_embedding,
limit=10,
search_params=models.SearchParams(hnsw_ef=hnsw_ef),
)
times.append((time.time() - start_time) * 1000)
results[hnsw_ef] = {
"avg_time": np.mean(times),
"min_time": np.min(times),
"max_time": np.max(times),
}
return results
test_query = "your test query"
query_embedding = encoder.encode(test_query).tolist()
performance_results = {}
for config in configs:
if config["m"] > 0: # Skip m=0 collections for search
collection_name = f"my_domain_{config['name']}"
performance_results[config["name"]] = benchmark_search(
collection_name, query_embedding
)
```
### Step 5: Measure Payload Indexing Impact
Measure filtering performance with and without indexes:
```python
def test_filtering_performance(collection_name):
query_embedding = encoder.encode("your filter test query").tolist()
# Test filter without index
filter_condition = models.Filter(
must=[models.FieldCondition(key="length", range=models.Range(gte=100, lte=500))]
)
# Timing without payload index
start_time = time.time()
_ = client.query_points(
collection_name=collection_name,
query=query_embedding,
query_filter=filter_condition,
limit=10,
)
time_without_index = (time.time() - start_time) * 1000
# Create payload index
client.create_payload_index(
collection_name=collection_name, field_name="length", field_schema="integer"
)
# Rebuild HNSW to attach filter data structures.
# Note: This is not advised for production. Better create payload index before uploading any data to avoid rebuild.
suffix = collection_name.replace("my_domain_", "")
config = next((c for c in configs if c["name"] == suffix), None)
client.update_collection(
collection_name=collection_name, hnsw_config=models.HnswConfigDiff(m=0)
)
client.update_collection(
collection_name=collection_name,
hnsw_config=models.HnswConfigDiff(
m=16,
ef_construct=config["ef_construct"],
full_scan_threshold=10,
payload_m=None,
max_indexing_threads=1,
),
optimizers_config=models.OptimizersConfigDiff(vacuum_min_vector_number=0),
)
# Wait for index to be built
wait_for_index_built(collection_name)
# Timing with index
start_time = time.time()
_ = client.query_points(
collection_name=collection_name,
query=query_embedding,
query_filter=filter_condition,
limit=10,
)
time_with_index = (time.time() - start_time) * 1000
return {
"without_index": time_without_index,
"with_index": time_with_index,
"speedup": time_without_index / time_with_index,
}
# Test on your best performing collection
best_collection = "my_domain_balanced" # Choose based on your results
filtering_results = test_filtering_performance(best_collection)
```
### Step 6: Analyze Your Results
Create a summary of your findings:
```python
print("=" * 60)
print("PERFORMANCE OPTIMIZATION RESULTS")
print("=" * 60)
print("\n1) Upload Performance:")
for config_name, time_taken in upload_times.items():
print(f" {config_name}: {time_taken:.2f}s")
print("\n2) Search Performance (hnsw_ef=128):")
for config_name, results in performance_results.items():
if 128 in results:
print(f" {config_name}: {results[128]['avg_time']:.2f}ms")
print("\n3) Filtering Impact:")
print(f" Without index: {filtering_results['without_index']:.2f}ms")
print(f" With index: {filtering_results['with_index']:.2f}ms")
print(f" Speedup: {filtering_results['speedup']:.1f}x")
```
## Success Criteria
You'll know you've succeeded when:
<input type="checkbox"> You've tested multiple HNSW configurations with real timing data
<input type="checkbox"> You can explain which settings work best for your domain and why
<input type="checkbox"> You've measured the concrete impact of payload indexing
<input type="checkbox"> You have clear recommendations for production deployment
## Share Your Discovery
### Step 1: Reflect on Your Findings
1. Which HNSW configuration (`m`, `ef_construct`) worked best for your domain?
2. How did the balance between upload time and search speed look?
3. What was the impact of adding a payload index?
4. How do your results compare to the DBpedia demo?
### Step 2: Post Your Results
**Post your results in** <a href="https://discord.com/invite/qdrant" target="_blank" rel="noopener noreferrer" aria-label="Qdrant Discord"> <img src="https://img.shields.io/badge/Qdrant%20Discord-5865F2?style=flat&logo=discord&logoColor=white&labelColor=5865F2&color=5865F2"
alt="Post your results in Discord"
style="display:inline; margin:0; vertical-align:middle; border-radius:9999px;" /> </a> **using this:**
```markdown
**[Day 2] HNSW Performance Benchmarking**
**High-Level Summary**
- **Domain:** "[your domain]"
- **Key Result:** "m=[..], ef_construct=[..], hnsw_ef=[..] gave [X] ms search and [Y] s upload (best balance)."
**Reproducibility**
- **Collections:** ...
- **Model:** sentence-transformers/all-MiniLM-L6-v2 (384-dim)
- **Dataset:** [N items] (snapshot: YYYY-MM-DD)
**Configuration Results**
| m | ef_construct | Upload_s | Search_ms@ef=128 |
|----|--------------|----------|------------------|
| 0 | 100 | X.X | — |
| 8 | 100 | Y.Y | A.A |
| 16 | 200 | Z.Z | B.B |
| 32 | 400 | W.W | C.C |
**Filtering Impact**
- Payload index on `length`: **[speedup]×**
Without index: [T1] ms → With index: [T2] ms
**Recommendations**
- Best config for this domain: [m, ef_construct, hnsw_ef]
- When to pick another setting: [short guidance]
- Notes for production: [one line on indexing order / filters]
**Surprise**
- "[one unexpected finding]"
**Next Step**
- "[one concrete action you’ll try next]"
```
## Optional: Go Further
* Test more granular parameters:
* **ef_construct** impact on recall & build time
* **hnsw_ef** per-query tuning by complexity
* Track memory usage differences (RAM/on-disk, payload indexes)
* Add accuracy metrics vs. a small labeled query set to see if higher `m` truly improves quality for your domain