Почему Qdrant, а не Chroma
Chroma — отличный старт для прототипа. Но у неё есть потолок. Вот конкретные ситуации, когда нужен Qdrant:
Архитектура: Rust, сегменты, WAL
Qdrant написан на Rust — это не маркетинг, а инженерное решение. Rust даёт предсказуемое потребление памяти без GC-пауз (критично при обслуживании запросов с латентностью <10 мс) и безопасную работу с concurrency без data races.
Сегменты: immutable + mutable
Данные хранятся в сегментах — независимых единицах хранения, каждая со своим HNSW-индексом и payload-хранилищем. Новые точки попадают в mutable-сегмент (append-only). Когда он достигает порога размера, Qdrant создаёт новый и асинхронно переводит старый в immutable с оптимизацией индекса. Запросы идут параллельно по всем сегментам — результаты сливаются.
WAL: надёжность записи
Write-Ahead Log (WAL) — файл, в который каждая операция записывается до применения к основным данным. Если сервер упал в момент записи — при перезапуске WAL воспроизводится, данные восстанавливаются. Это стандарт надёжности для production-систем (PostgreSQL, Kafka — тоже WAL).
Установка и клиенты
# ── Docker (рекомендуется для разработки и production) ────────────
docker run -d \
--name qdrant \
-p 6333:6333 \
-p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:latest
# 6333 → REST API (http://localhost:6333)
# 6334 → gRPC API (быстрее для батчевых операций)
# ── Python SDK ────────────────────────────────────────────────────
pip install qdrant-client
# С поддержкой fastembed (встроенные embeddings, опционально)
pip install "qdrant-client[fastembed]"
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
# ── In-memory (тесты, Jupyter) ────────────────────────────────────
client = QdrantClient(":memory:")
# ── Локальный файл (без сервера, небольшие корпусы) ───────────────
client = QdrantClient(path="./qdrant_storage")
# ── Сервер (Docker / Qdrant Cloud) ───────────────────────────────
client = QdrantClient(
host="localhost",
port=6333,
# api_key="your-key", # для Qdrant Cloud
# https=True, # для Cloud / TLS
prefer_grpc=True, # gRPC быстрее REST для больших батчей
timeout=30,
)
# ── Проверка подключения ──────────────────────────────────────────
info = client.get_collections()
print(f"Collections: {[c.name for c in info.collections]}")
Коллекции: векторная конфигурация
В Qdrant документы называются точками (points), метаданные — payload. Каждая коллекция настраивает векторное пространство при создании. Параметры расстояния и размерности изменить нельзя без пересоздания.
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, HnswConfigDiff,
OptimizersConfigDiff, QuantizationConfig,
ScalarQuantizationConfig, ScalarType,
)
client = QdrantClient(host="localhost", port=6333)
# ── Простая коллекция: один вектор ────────────────────────────────
client.create_collection(
collection_name="docs",
vectors_config=VectorParams(
size=1536, # размерность вектора
distance=Distance.COSINE, # COSINE | DOT | EUCLID | MANHATTAN
on_disk=False, # True → вектора на диск (экономия RAM)
),
)
# ── Расширенная конфигурация ──────────────────────────────────────
client.recreate_collection( # создаёт или пересоздаёт
collection_name="docs_prod",
vectors_config=VectorParams(
size=1536,
distance=Distance.COSINE,
on_disk=False,
hnsw_config=HnswConfigDiff(
m=16, # связность графа (default 16)
ef_construct=100, # точность построения (default 100)
full_scan_threshold=10_000, # до N точек — brute-force вместо HNSW
on_disk=False, # True → индекс на диск, не в RAM
),
),
optimizers_config=OptimizersConfigDiff(
indexing_threshold=20_000, # строить HNSW после N точек (default 20k)
memmap_threshold=50_000, # переключить на mmap после N точек
),
# Квантизация (подробнее ниже)
quantization_config=ScalarQuantizationConfig(
type=ScalarType.INT8,
quantile=0.99,
always_ram=True, # квантизованный индекс держать в RAM
),
)
# ── Управление коллекциями ────────────────────────────────────────
client.collection_exists("docs") # True / False
info = client.get_collection("docs")
print(info.points_count) # количество точек
print(info.config.params.vectors) # VectorParams
client.delete_collection("old_docs")
Named vectors: несколько векторов на точку
Главное отличие Qdrant от Chroma — одна точка может хранить несколько именованных векторов разной размерности и метрики. Это открывает сценарии, невозможные в Chroma:
from qdrant_client.models import (
VectorParams, SparseVectorParams, Distance,
SparseIndexParams,
)
# ── Коллекция с named vectors ─────────────────────────────────────
client.create_collection(
collection_name="docs_hybrid",
vectors_config={
# Основной семантический вектор
"dense": VectorParams(size=1536, distance=Distance.COSINE),
# Отдельный вектор для заголовка (меньше размерность, другая модель)
"title": VectorParams(size=384, distance=Distance.COSINE),
},
sparse_vectors_config={
# Разреженный вектор для BM25/SPLADE (для hybrid search)
"sparse": SparseVectorParams(
index=SparseIndexParams(on_disk=False),
),
},
)
# ── Добавление точек с несколькими векторами ──────────────────────
from qdrant_client.models import PointStruct, SparseVector
client.upsert(
collection_name="docs_hybrid",
points=[
PointStruct(
id=1,
vector={
"dense": dense_embed(text).tolist(), # 1536-dim
"title": dense_embed(title, model="small").tolist(), # 384-dim
"sparse": SparseVector(
indices=[4, 71, 203, 1045], # ненулевые позиции
values=[0.82, 0.65, 0.41, 0.33],
),
},
payload={
"text": text,
"title": title,
"source": "wiki",
"year": 2024,
},
)
],
)
# ── Поиск по конкретному вектору ──────────────────────────────────
results = client.query_points(
collection_name="docs_hybrid",
query=dense_embed("запрос").tolist(),
using="dense", # ← указываем, по какому вектору искать
limit=5,
)
Payload indexes: обязательны для фильтрации
Критический момент, который упускают большинство разработчиков.
Без индекса на поле фильтрация работает через полное сканирование:
Qdrant проверяет каждую точку. При 1M документов и фильтре
source="wiki" без индекса — сотни миллисекунд вместо <5 мс.
from qdrant_client.models import (
PayloadSchemaType, TextIndexParams, TokenizerType,
)
# ── Создать индексы сразу после создания коллекции ────────────────
# Правило: индекс нужен на КАЖДОЕ поле, которое используется в where-фильтрах
client.create_payload_index("docs", "source", PayloadSchemaType.KEYWORD)
client.create_payload_index("docs", "year", PayloadSchemaType.INTEGER)
client.create_payload_index("docs", "score", PayloadSchemaType.FLOAT)
client.create_payload_index("docs", "verified", PayloadSchemaType.BOOL)
client.create_payload_index("docs", "created_at", PayloadSchemaType.DATETIME)
client.create_payload_index("docs", "tags", PayloadSchemaType.KEYWORD) # array тоже
# ── Fulltext-индекс (для MatchText) ──────────────────────────────
client.create_payload_index(
collection_name="docs",
field_name="content",
field_schema=TextIndexParams(
type="text",
tokenizer=TokenizerType.WORD, # WORD | WHITESPACE | PREFIX | MULTILINGUAL
min_token_len=2,
max_token_len=20,
lowercase=True,
),
)
# ── Проверить индексы ─────────────────────────────────────────────
info = client.get_collection("docs")
for name, schema in info.payload_schema.items():
print(f" {name}: {schema.data_type}")
# ── Удалить индекс ────────────────────────────────────────────────
client.delete_payload_index("docs", "old_field")
CRUD: точки
from qdrant_client.models import (
PointStruct, PointIdsList,
SetPayload, DeletePayload,
Filter, FieldCondition, MatchValue,
)
import uuid
# ── UPSERT: добавить или обновить ────────────────────────────────
client.upsert(
collection_name="docs",
points=[
PointStruct(
id=str(uuid.uuid4()), # UUID или int
vector=embed(text).tolist(),
payload={
"text": text,
"source": "wiki",
"year": 2024,
"tags": ["python", "async"],
"verified": True,
"created_at": "2024-03-15T12:00:00Z",
},
)
for text in texts
],
wait=True, # дождаться завершения записи (default True)
)
# ── GET: получить точки по ID ─────────────────────────────────────
points = client.retrieve(
collection_name="docs",
ids=["id1", "id2"],
with_payload=True,
with_vectors=False, # не тянуть тяжёлые векторы если не нужны
)
# ── UPDATE: обновить только payload (вектор не трогаем) ──────────
client.set_payload(
collection_name="docs",
payload={"verified": True, "year": 2024},
points=["id1", "id2"], # или Filter(...)
)
# Удалить конкретные ключи из payload
client.delete_payload(
collection_name="docs",
keys=["draft_flag"],
points=Filter(must=[FieldCondition(key="source", match=MatchValue(value="blog"))]),
)
# ── DELETE: удалить точки ─────────────────────────────────────────
client.delete(
collection_name="docs",
points_selector=PointIdsList(points=["id1", "id2"]),
)
# Удалить по фильтру (например, всё из источника «blog»)
client.delete(
collection_name="docs",
points_selector=Filter(
must=[FieldCondition(key="source", match=MatchValue(value="blog"))]
),
)
# ── COUNT: подсчёт (с фильтром) ───────────────────────────────────
count = client.count(
collection_name="docs",
count_filter=Filter(
must=[FieldCondition(key="verified", match=MatchValue(value=True))]
),
exact=True, # False = приближённо, быстрее
)
print(count.count)
Фильтры: must / should / must_not
Система фильтрации Qdrant — самая богатая среди векторных БД. Три ключа соответствуют булевой логике: must = AND, should = OR, must_not = NOT.
match=MatchValue(value="wiki")match=MatchAny(any=["wiki","docs"])match=MatchExcept(except=["draft"])match=MatchText(text="fastapi async")range=Range(gte=2023, lte=2024). Поля: gt, gte, lt, lte.HasId(has_id=[1,2,3])from qdrant_client.models import (
Filter, FieldCondition,
MatchValue, MatchAny, MatchExcept, MatchText,
Range, IsEmpty, IsNull, HasId,
GeoBoundingBox, GeoPoint, GeoRadius,
)
# ── Простые фильтры ───────────────────────────────────────────────
# Точное совпадение
f = Filter(must=[FieldCondition(key="source", match=MatchValue(value="wiki"))])
# Вхождение в список ($in)
f = Filter(must=[FieldCondition(key="source", match=MatchAny(any=["wiki","docs"]))])
# Исключение ($nin)
f = Filter(must=[FieldCondition(key="category", match=MatchExcept(except=["draft","deprecated"]))])
# ── Диапазоны ─────────────────────────────────────────────────────
# Год 2023–2024
f = Filter(must=[FieldCondition(key="year", range=Range(gte=2023, lte=2024))])
# Дата после 2024-01-01
f = Filter(must=[FieldCondition(
key="created_at",
range=Range(gte="2024-01-01T00:00:00Z"),
)])
# ── Комбинации must + should + must_not ──────────────────────────
# (source IN [wiki, docs]) AND year >= 2023 AND NOT verified=False
f = Filter(
must=[
FieldCondition(key="source", match=MatchAny(any=["wiki", "docs"])),
FieldCondition(key="year", range=Range(gte=2023)),
],
must_not=[
FieldCondition(key="verified", match=MatchValue(value=False)),
],
)
# ── should: хотя бы одно условие ─────────────────────────────────
# (source=wiki AND year>=2024) OR (source=internal AND verified=True)
f = Filter(
should=[
Filter(must=[
FieldCondition(key="source", match=MatchValue(value="wiki")),
FieldCondition(key="year", range=Range(gte=2024)),
]),
Filter(must=[
FieldCondition(key="source", match=MatchValue(value="internal")),
FieldCondition(key="verified", match=MatchValue(value=True)),
]),
]
)
# ── Fulltext поиск по содержимому ────────────────────────────────
# Требует TEXT-индекс на поле!
f = Filter(must=[FieldCondition(key="content", match=MatchText(text="async fastapi install"))])
# ── Проверка отсутствия поля ─────────────────────────────────────
f = Filter(must=[IsEmpty(is_empty=FieldCondition(key="deprecated_at"))])
# ── Гео-поиск ─────────────────────────────────────────────────────
f = Filter(must=[FieldCondition(
key="location",
geo_radius=GeoRadius(center=GeoPoint(lat=55.75, lon=37.62), radius=5000.0), # 5 км
)])
# ── Поиск по tags (массив keyword) ────────────────────────────────
# Точка содержит "python" или "async" в теге
f = Filter(must=[FieldCondition(key="tags", match=MatchAny(any=["python", "async"]))])
Поиск: query_points и управление выдачей
from qdrant_client.models import (
Filter, FieldCondition, MatchValue,
SearchParams, Range,
)
import numpy as np
# ── Базовый поиск ─────────────────────────────────────────────────
results = client.query_points(
collection_name="docs",
query=embed_query("установка FastAPI").tolist(), # вектор запроса
limit=10,
)
for point in results.points:
print(f"id={point.id} score={point.score:.4f}")
print(f" {point.payload.get('text','')[:80]}")
# ── С фильтром ────────────────────────────────────────────────────
results = client.query_points(
collection_name="docs",
query=embed_query("async фреймворк").tolist(),
query_filter=Filter(
must=[
FieldCondition(key="source", match=MatchValue(value="docs")),
FieldCondition(key="year", range=Range(gte=2023)),
]
),
limit=10,
with_payload=True,
with_vectors=False, # не тянуть тяжёлые векторы
)
# ── score_threshold: порог релевантности ─────────────────────────
results = client.query_points(
collection_name="docs",
query=embed_query("что такое RAG").tolist(),
score_threshold=0.65, # вернуть только точки с score >= 0.65
limit=10,
)
# Если ничего не найдено выше порога — results.points будет пустым
# ── Пагинация через offset ────────────────────────────────────────
page = 2
page_size = 10
results = client.query_points(
collection_name="docs",
query=embed_query("запрос").tolist(),
limit=page_size,
offset=page * page_size, # пропустить первые N результатов
)
# ── Параметры точности поиска ─────────────────────────────────────
results = client.query_points(
collection_name="docs",
query=embed_query("запрос").tolist(),
search_params=SearchParams(
hnsw_ef=256, # ширина поиска (override collection-level)
exact=False, # True = brute-force (100% recall, медленно)
),
limit=10,
)
# ── Выбор конкретных полей из payload ────────────────────────────
from qdrant_client.models import PayloadSelectorInclude, PayloadSelectorExclude
results = client.query_points(
collection_name="docs",
query=embed_query("запрос").tolist(),
with_payload=PayloadSelectorInclude(include=["text", "source", "year"]),
limit=10,
)
# Или наоборот — исключить тяжёлые поля
results = client.query_points(
collection_name="docs",
query=embed_query("запрос").tolist(),
with_payload=PayloadSelectorExclude(exclude=["full_html", "raw_content"]),
limit=10,
)
Hybrid search: dense + sparse
Семантический поиск (dense) хорошо улавливает смысл, но теряет точные совпадения: артикулы, UUID, редкие термины, имена. BM25/SPLADE (sparse) наоборот — отлично находит точные термины, но не понимает синонимов. Hybrid search объединяет оба подхода через Reciprocal Rank Fusion (RRF).
"""
pip install fastembed
Или используйте свой sparse encoder (SPLADE, BM25).
"""
from qdrant_client.models import (
Prefetch, FusionQuery, Fusion, SparseVector,
NamedVector, NamedSparseVector,
)
# ── Получить sparse-вектор из SPLADE / fastembed ─────────────────
def get_sparse_vector(text: str) -> SparseVector:
"""
Пример через fastembed (Qdrant's встроенный sparse encoder).
В production можно использовать SPLADE или BM25.
"""
from fastembed import SparseTextEmbedding
model = SparseTextEmbedding(model_name="prithvida/Splade_PP_en_v1")
result = list(model.embed([text]))[0]
return SparseVector(
indices=result.indices.tolist(),
values=result.values.tolist(),
)
# ── Hybrid search через Prefetch + Fusion (RRF) ───────────────────
query_text = "установка async фреймворка python"
dense_vec = embed_query(query_text).tolist()
sparse_vec = get_sparse_vector(query_text)
results = client.query_points(
collection_name="docs_hybrid",
prefetch=[
# Первый префетч: поиск по dense-вектору (top-50)
Prefetch(
query=dense_vec,
using="dense",
limit=50,
),
# Второй префетч: поиск по sparse-вектору (top-50)
Prefetch(
query=sparse_vec,
using="sparse",
limit=50,
),
],
# Финальное слияние: RRF объединяет два ранжирования
query=FusionQuery(fusion=Fusion.RRF),
limit=10,
with_payload=True,
)
# ── С фильтрами в hybrid search ───────────────────────────────────
results = client.query_points(
collection_name="docs_hybrid",
prefetch=[
Prefetch(query=dense_vec, using="dense", limit=50),
Prefetch(query=sparse_vec, using="sparse", limit=50),
],
query=FusionQuery(fusion=Fusion.RRF),
query_filter=Filter(
must=[FieldCondition(key="source", match=MatchValue(value="docs"))]
),
limit=10,
)
Квантизация: 4× меньше памяти
1M векторов × 1536 float32 = 6.1 ГБ RAM. Квантизация сжимает векторы с минимальной потерей качества. Qdrant поддерживает три типа.
from qdrant_client.models import (
ScalarQuantizationConfig, ScalarType,
ProductQuantizationConfig, CompressionRatio,
BinaryQuantizationConfig,
)
# ── Scalar Quantization (рекомендуется как первый шаг) ───────────
client.create_collection(
collection_name="docs_scalar",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
quantization_config=ScalarQuantizationConfig(
type=ScalarType.INT8,
quantile=0.99, # 1% выбросов обрезается (точнее, чем 1.0)
always_ram=True, # держать квантизованный индекс в RAM
),
)
# ── Product Quantization ──────────────────────────────────────────
client.create_collection(
collection_name="docs_pq",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
quantization_config=ProductQuantizationConfig(
compression=CompressionRatio.X16, # X4, X8, X16, X32, X64
always_ram=True,
),
)
# ── Binary Quantization ───────────────────────────────────────────
client.create_collection(
collection_name="docs_binary",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
quantization_config=BinaryQuantizationConfig(always_ram=True),
)
# ── Поиск с rescore (компенсация потери точности) ─────────────────
from qdrant_client.models import QuantizationSearchParams
results = client.query_points(
collection_name="docs_scalar",
query=embed_query("запрос").tolist(),
search_params=SearchParams(
quantization=QuantizationSearchParams(
ignore=False, # False = использовать квантизованный индекс
rescore=True, # пересчитать top результаты по оригинальным float32
oversampling=2.0, # взять 2× больше кандидатов перед rescore
),
hnsw_ef=128,
),
limit=10,
)
# oversampling=2.0 + rescore даёт почти такой же recall, как без квантизации
Scroll API: итерация по всем точкам
query_points ограничен по offset при больших корпусах.
Для полного обхода всех точек используйте Scroll API —
он работает через курсор и масштабируется на любой размер коллекции.
from qdrant_client.models import Filter, FieldCondition, MatchValue
# ── Итерация по всем точкам ───────────────────────────────────────
def scroll_all(client, collection_name: str, batch_size: int = 1000):
"""Итерация по всем точкам коллекции через cursor-based scroll."""
offset = None
total = 0
while True:
points, next_offset = client.scroll(
collection_name=collection_name,
scroll_filter=None, # без фильтра — все точки
limit=batch_size,
offset=offset, # None = начало, потом → следующий курсор
with_payload=True,
with_vectors=False,
)
if not points:
break
for point in points:
yield point
total += len(points)
if next_offset is None:
break
offset = next_offset
print(f"Итого: {total} точек")
# Использование
for point in scroll_all(client, "docs"):
process(point.payload)
# ── Scroll с фильтром: обход только верифицированных ─────────────
points, offset = client.scroll(
collection_name="docs",
scroll_filter=Filter(must=[
FieldCondition(key="verified", match=MatchValue(value=True)),
]),
limit=500,
with_payload=["text", "source"],
)
# ── Экспорт для дообучения / аудита ──────────────────────────────
import json
def export_to_jsonl(client, collection_name: str, path: str):
with open(path, "w") as f:
for point in scroll_all(client, collection_name):
f.write(json.dumps({"id": str(point.id), **point.payload}) + "\n")
export_to_jsonl(client, "docs", "backup.jsonl")
Recommend API: поиск по примерам
Recommend API позволяет искать «похожее на это, но не похожее на то» — без явного вектора запроса. Полезно для систем рекомендаций, дедупликации и кластеризации.
# ── Recommend: похожее на positive, непохожее на negative ────────
results = client.recommend(
collection_name="docs",
positive=["id_good_doc_1", "id_good_doc_2"], # ID хороших примеров
negative=["id_bad_doc_1"], # ID нежелательных
limit=10,
with_payload=True,
)
# ── Discover: поиск по примерам + направление изменения ──────────
from qdrant_client.models import ContextExamplePair
results = client.discover(
collection_name="docs",
target="id_target_doc", # к чему двигаемся
context=[
ContextExamplePair(positive="id_pos_1", negative="id_neg_1"),
ContextExamplePair(positive="id_pos_2", negative="id_neg_2"),
],
limit=10,
)
Группировка результатов
Когда из одного документа создано несколько чанков,
поиск возвращает несколько чанков одного источника подряд.
search_groups группирует результаты по полю payload
и возвращает топ-N групп с топ-M результатами в каждой.
# ── Поиск с группировкой по document_id ──────────────────────────
# Каждый документ — максимум 2 чанка в результатах
groups = client.query_points_groups(
collection_name="docs",
query=embed_query("установка FastAPI").tolist(),
group_by="document_id", # поле payload для группировки
limit=5, # топ-5 групп (документов)
group_size=2, # до 2 чанков на документ
with_payload=True,
)
for group in groups.groups:
print(f"\n📄 document_id: {group.id}")
for hit in group.hits:
print(f" score={hit.score:.3f}: {hit.payload['text'][:60]}")
# ── Полезно для parent-child retrieval ────────────────────────────
# child-чанки ищем, группируем по parent_id, возвращаем лучший чанк
# на каждый родительский документ
Алиасы: zero-downtime реиндексация
При смене embedding-модели нужна полная переиндексация. Алиасы позволяют переключиться атомарно — без простоя.
# ── Zero-downtime blue-green реиндексация ────────────────────────
# 1. Приложение работает с алиасом «docs» → указывает на «docs_v1»
client.create_alias(collection_name="docs_v1", alias_name="docs")
# 2. Создаём новую коллекцию с новой моделью
client.create_collection(
collection_name="docs_v2",
vectors_config=VectorParams(size=3072, distance=Distance.COSINE),
)
# 3. Фоновая переиндексация (приложение продолжает использовать docs_v1)
for point in scroll_all(client, "docs_v1"):
new_vec = new_embed_fn(point.payload["text"])
client.upsert("docs_v2", points=[
PointStruct(id=point.id, vector=new_vec.tolist(), payload=point.payload)
])
# 4. Атомарное переключение алиаса (< 1 мс)
client.update_collection_aliases(change_aliases_operations=[
DeleteAliasOperation(delete_alias=DeleteAlias(alias_name="docs")),
CreateAliasOperation(create_alias=CreateAlias(
collection_name="docs_v2",
alias_name="docs",
)),
])
# 5. После проверки — удаляем старую коллекцию
client.delete_collection("docs_v1")
# ── Просмотр алиасов ─────────────────────────────────────────────
print(client.get_aliases().aliases)
print(client.get_collection_aliases("docs_v2").aliases)
Снапшоты и резервные копии
# ── Создать снапшот коллекции ─────────────────────────────────────
snapshot = client.create_snapshot(collection_name="docs")
print(snapshot.name) # «docs-1234567890.snapshot»
# ── Список снапшотов ─────────────────────────────────────────────
snapshots = client.list_snapshots(collection_name="docs")
# ── Скачать снапшот ───────────────────────────────────────────────
client.download_snapshot(
collection_name="docs",
snapshot_name=snapshot.name,
target_path="./backups/docs.snapshot",
)
# ── Восстановить на другом сервере ───────────────────────────────
client.recover_snapshot(
collection_name="docs_restored",
location="./backups/docs.snapshot",
)
# ── Удалить снапшот ──────────────────────────────────────────────
client.delete_snapshot(collection_name="docs", snapshot_name=snapshot.name)
Типичные ошибки
Filter(should=[...]) в одиночку означает «хотя бы одно условие».
Если коллекция большая, а условие в should не очень селективное —
Qdrant вернёт огромное количество точек. Типичная ошибка при портировании
логики из SQL (WHERE x OR y).
upsert(..., wait=False) — асинхронная запись: метод вернётся
сразу, но данные ещё не индексированы. Сразу после этого вызвать
query_points — точки могут не найтись.
В тестах выглядит как нестабильные flaky tests.
vec.astype(np.float32).tolist() в embed-функции, до передачи в Qdrant.Шпаргалка
КЛИЕНТЫ:
QdrantClient(":memory:") → RAM, тесты
QdrantClient(path="./storage") → файл, без сервера
QdrantClient(host=..., port=6333) → Docker / Cloud
prefer_grpc=True → быстрее для батчей
СОЗДАНИЕ КОЛЛЕКЦИИ:
client.create_collection(
collection_name="docs",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)
# Сразу создать индексы:
client.create_payload_index("docs", "source", PayloadSchemaType.KEYWORD)
client.create_payload_index("docs", "year", PayloadSchemaType.INTEGER)
УPSERT:
client.upsert("docs", points=[PointStruct(id=..., vector=..., payload=...)])
ФИЛЬТРЫ:
Filter(must=[...]) → AND
Filter(should=[...]) → OR (хотя бы одно)
Filter(must_not=[...]) → NOT
FieldCondition(key="f", match=MatchValue(value="v")) → exact
FieldCondition(key="f", match=MatchAny(any=["a","b"])) → $in
FieldCondition(key="f", range=Range(gte=2023)) → range
FieldCondition(key="f", match=MatchText(text="query")) → fulltext
ПОИСК:
results = client.query_points(
collection_name="docs",
query=vec.tolist(),
query_filter=Filter(...),
score_threshold=0.65,
limit=10,
with_payload=PayloadSelectorInclude(include=["text","source"]),
search_params=SearchParams(hnsw_ef=256),
)
HYBRID SEARCH (RRF):
Prefetch(query=dense_vec, using="dense", limit=50)
Prefetch(query=sparse_vec, using="sparse", limit=50)
query=FusionQuery(fusion=Fusion.RRF)
КВАНТИЗАЦИЯ (экономия памяти):
ScalarQuantizationConfig(type=ScalarType.INT8) → 4× меньше RAM
BinaryQuantizationConfig() → 32× меньше RAM
+ rescore=True, oversampling=2.0 → компенсация recall
ZERO-DOWNTIME РЕИНДЕКСАЦИЯ:
1. create_alias("docs_v1", alias_name="docs")
2. Создать docs_v2, переиндексировать
3. update_collection_aliases: docs → docs_v2
4. delete_collection("docs_v1")
SCROLL (обход всех точек):
points, next_offset = client.scroll("docs", limit=1000, offset=cursor)
SCORE при COSINE: score = cosine_similarity ∈ [−1, 1]
score >= 0.65 → хорошая релевантность (порог калибровать!)
Практические задания
-
Влияние payload indexes на скорость.
Создайте коллекцию, добавьте 50k точек с полями source, year, tags.
Измерьте время query_points с фильтром
year >= 2023без индекса и с индексом. Разница должна быть 10–100×. Используйтеtime.perf_counter(). - Hybrid search vs dense-only. Проиндексируйте корпус с named vectors «dense» и «sparse». Составьте 10 запросов: 5 семантических («принципы async IO») и 5 точных («SKU-20481», «CVE-2023-1234»). Сравните MRR@5 для dense-only и RRF hybrid. Для каких запросов hybrid выигрывает больше всего?
-
Квантизация: recall vs память.
Создайте три коллекции: без квантизации, Scalar INT8, Binary.
Добавьте одинаковые 10k векторов. Сравните:
объём занятой памяти (
client.get_collection().result.vectors_count), recall@10 относительно brute-force (search_params=SearchParams(exact=True)), время запроса. При каком oversampling Binary-квантизация достигает recall ≥ 0.95?