Почему Qdrant, а не Chroma

Chroma — отличный старт для прототипа. Но у неё есть потолок. Вот конкретные ситуации, когда нужен Qdrant:

Задача
Chroma
Qdrant
Несколько векторов на документ
Нет
Named vectors — да
Hybrid search (dense + sparse)
Нет
Sparse vectors + RRF fusion
Фильтры должен/может/нельзя
$and/$or только
must / should / must_not
Сжатие памяти
Нет квантизации
Scalar / Product / Binary
Корпус > 5M документов
Нестабильно
Шардирование + репликация
Fulltext поиск внутри payload
$contains (LIKE)
MatchText с токенизацией
Zero-downtime реиндексация
Нет
Collection aliases
gRPC API
Нет
Да (быстрее REST ~2–3×)

Архитектура: 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).

100%
колёсико — масштаб · зажать и тянуть — перемещение
Архитектура Qdrant Python Client / REST / gRPC qdrant_client.QdrantClient WAL Write-Ahead Log Collection «my_docs» Segment 1 immutable HNSW index Payload store Vector store Segment 2 immutable HNSW index Payload store Vector store Segment 3 mutable ← новые flat index (добавление) Payload store Vector store Optimizer: merge segments → rebuild HNSW 📁 ./qdrant_storage/ (on-disk или RAM) Структура Point ID «3f8a2c1d-...» или 42 (UUID / integer) NAMED VECTORS «dense» size=1536, distance=Cosine [0.21, -0.84, 0.53, ...] «sparse» SparseVectorParams indices:[4,71,1203] values:[0.8,...] PAYLOAD (богатые метаданные) source «wiki» keyword — точный поиск + фильтр year 2024 integer — range-фильтры score 0.92 float — range-фильтры verified true bool — Match created_at «2024-03-15T...» datetime — range-фильтры content «FastAPI async ...» text — fulltext MatchText location {lat:55.7, lon:37.6} geo — GeoRadius / GeoBBox tags ["python","async"] keyword[] — MatchAny ⚡ Payload indexes обязательны для быстрой фильтрации

Установка и клиенты

# ── 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:

doc_001
dense 1536d · cosine sparse BM25 title-vec 384d
doc_002
dense 1536d · cosine sparse BM25 title-vec 384d
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 мс.

Тип поля
PayloadSchemaType
Поддерживаемые операции
keyword
KEYWORD
Match (exact), MatchAny ($in), MatchExcept ($nin), IsEmpty, IsNull
integer
INTEGER
Range ($gte/$lte/$gt/$lt), Match (exact)
float
FLOAT
Range
bool
BOOL
Match (true/false)
datetime
DATETIME
Range (ISO 8601 строки сравниваются как даты)
text (fulltext)
TEXT
MatchText (токенизированный полнотекстовый поиск)
geo
GEO
GeoBoundingBox, GeoRadius, GeoPolygon
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.

Filter(
must = [ FieldCondition(...), FieldCondition(...) ], ← все обязаны выполниться (AND)
should = [ FieldCondition(...), FieldCondition(...) ], ← хотя бы один (OR)
must_not = [ FieldCondition(...) ], ← ни один не должен (NOT)
)
Условие
Типы
Описание
MatchValue
str · int · bool
Точное совпадение: match=MatchValue(value="wiki")
MatchAny
list[str · int]
Вхождение в список: match=MatchAny(any=["wiki","docs"])
MatchExcept
list[str · int]
Исключение из списка: match=MatchExcept(except=["draft"])
MatchText
str (fulltext)
Токенизированный поиск: match=MatchText(text="fastapi async")
Range
int · float · datetime
range=Range(gte=2023, lte=2024). Поля: gt, gte, lt, lte.
GeoBoundingBox
geo {lat, lon}
Прямоугольная область по координатам
GeoRadius
geo {lat, lon}
Круговая область с радиусом в метрах
IsEmpty
any
Поле отсутствует или равно null/[]
IsNull
any
Поле существует, но равно null
HasId
list[id]
Точка входит в список ID: HasId(has_id=[1,2,3])
Nested
object
Фильтрация по полям вложенных объектов в payload
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"]))])
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 поддерживает три типа.

Scalar (INT8)
4× меньше
float32 → int8. Каждое число занимает 1 байт вместо 4. Квантизует каждую размерность независимо, сохраняя распределение.
✓ Быстро, recall ~99%, хорош для старта
✗ Потеря точности при экстремальных значениях
Product (PQ)
8–64× меньше
Делит вектор на подвекторы, каждый заменяет индексом в кодовой книге. 1536d → 96 подвекторов × 8 bit.
✓ Максимальное сжатие при большом корпусе
✗ Заметная потеря recall, нужен rescore
Binary (BQ)
32× меньше
float32 → 1 бит (знак числа). 1536d → 48 байт. Расстояние через POPCNT — очень быстро на CPU.
✓ Скоростной, огромный корпус (сотни миллионов)
✗ Требует обязательный rescore, recall ниже
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)

Типичные ошибки

Забыли создать payload indexes перед первым запросом с фильтром
Без индексов фильтрация работает через полное сканирование. На 100k документов — 100–500 мс вместо <5 мс. Ошибок нет, запросы работают — просто очень медленно. Обнаруживается только при нагрузочном тестировании.
✓ Сразу после create_collection создавайте индексы на все поля фильтрации. Оберните в функцию setup_collection().
should без must — возвращает ВСЕ точки
Filter(should=[...]) в одиночку означает «хотя бы одно условие». Если коллекция большая, а условие в should не очень селективное — Qdrant вернёт огромное количество точек. Типичная ошибка при портировании логики из SQL (WHERE x OR y).
✓ should всегда комбинируйте с must для ограничения базового набора. Либо использовать must=[Filter(should=[...])].
score_threshold без понимания метрики
При Distance.COSINE Qdrant возвращает score = cosine_similarity ∈ [−1, 1]. При Distance.DOT — score = dot product ∈ (−∞, +∞). При Distance.EUCLID — score = −distance (чем меньше расстояние, тем выше score). Порог 0.7 для Cosine работает. Тот же порог для Dot — бессмысленен.
✓ Уточняйте интерпретацию score в документации под выбранную метрику. Calibrate на своих данных.
Не использовать wait=True при критичных операциях
upsert(..., wait=False) — асинхронная запись: метод вернётся сразу, но данные ещё не индексированы. Сразу после этого вызвать query_points — точки могут не найтись. В тестах выглядит как нестабильные flaky tests.
✓ Для тестов и синхронных пайплайнов: wait=True (default). wait=False — только для высокопроизводительных батчевых пайплайнов, где вы контролируете порядок.
Изменить размерность вектора после создания коллекции
Переключили модель с 1536d на 3072d. Попробовали upsert — Qdrant выдаёт ошибку несовпадения размерности. Нет возможности изменить VectorParams у существующей коллекции.
✓ Смена модели = полная переиндексация через aliases (blue-green). Версионируйте коллекции: docs_v1, docs_v2.
Хранить float16-массивы как есть
BGE-M3 с use_fp16=True возвращает numpy float16. Qdrant принимает только float32 или list[float]. Передача float16 → ошибка или тихое усечение.
✓ Конвертируйте: 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 → хорошая релевантность (порог калибровать!)
        

Практические задания

  1. Влияние payload indexes на скорость. Создайте коллекцию, добавьте 50k точек с полями source, year, tags. Измерьте время query_points с фильтром year >= 2023 без индекса и с индексом. Разница должна быть 10–100×. Используйте time.perf_counter().
  2. 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 выигрывает больше всего?
  3. Квантизация: 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?