Урок курса

Qdrant на практике

Vector DB & RAG Developer

Создание коллекции и загрузка данных

Начнём с того, что создадим коллекцию и загрузим в неё первые данные. В качестве рабочего примера возьмём набор коротких текстовых описаний — достаточно небольшой, чтобы всё было наглядно, и достаточно реалистичный, чтобы поиск давал осмысленные результаты.

Создаём коллекцию

При создании коллекции обязательно задаём два параметра: размерность векторов и метрику сходства. Оба фиксируются навсегда — изменить их потом нельзя без пересоздания коллекции.

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

client.create_collection(
    collection_name="articles",
    vectors_config=models.VectorParams(
        size=384,
        distance=models.Distance.COSINE
    )
)

Если коллекция с таким именем уже существует, метод выбросит ошибку. При повторных запусках можно предварительно удалить её: client.delete_collection("articles").

Конфигурация для больших коллекций

Минимальный вариант выше хорошо работает для прототипов и небольших данных. Когда коллекция растёт до миллиона записей и выше, имеет смысл задать параметры явно — иначе дефолты могут не совпасть с тем, что нужно.

client.create_collection(
    collection_name="articles_large",
    vectors_config=models.VectorParams(
        size=384,
        distance=models.Distance.COSINE
    ),
    hnsw_config=models.HnswConfigDiff(
        m=16,               # число связей каждой вершины; 16 — разумный баланс
        ef_construct=100,   # глубина построения графа; выше → качественнее, медленнее индексирование
        on_disk=False       # True — граф на диске, экономит RAM ценой latency
    ),
    optimizers_config=models.OptimizersConfigDiff(
        default_segment_number=4,  # целевое число сегментов; больше → быстрее запись, медленнее поиск
        indexing_threshold=20000   # минимум точек в сегменте перед построением HNSW-индекса
    ),
    quantization_config=models.ScalarQuantization(
        scalar=models.ScalarQuantizationConfig(
            type=models.ScalarType.INT8,  # сжатие float32 → int8, память ×4 меньше
            quantile=0.99,                # игнорируем выбросы при калибровке
            always_ram=True               # держать сжатые векторы в RAM даже при on_disk=True
        )
    )
)

Несколько ориентиров. m=16 и ef_construct=100 — хорошая отправная точка для большинства задач: при необходимости повышаем m до 32–48 за счёт большего потребления памяти. indexing_threshold=20000 означает, что небольшие свежие сегменты обслуживаются плоским перебором до набора нужного размера — это нормально и не влияет на корректность результатов. Scalar quantization на INT8 даёт четырёхкратную экономию памяти при минимальной потере точности и почти всегда оправдана на коллекциях от 500k векторов.

Готовим данные и строим эмбеддинги

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

documents = [
    {"id": 1, "text": "Нейронные сети произвели революцию в обработке изображений", "category": "ml", "year": 2023},
    {"id": 2, "text": "Трансформеры стали основой современных языковых моделей",    "category": "ml", "year": 2022},
    {"id": 3, "text": "Квантовые компьютеры решают задачи оптимизации",             "category": "quantum", "year": 2023},
    {"id": 4, "text": "Векторные базы данных ускоряют семантический поиск",         "category": "db", "year": 2024},
    {"id": 5, "text": "Reinforcement learning обучает агентов через награды",       "category": "ml", "year": 2022},
    {"id": 6, "text": "Графовые базы данных хранят связи между объектами",          "category": "db", "year": 2023},
]

texts = [doc["text"] for doc in documents]
vectors = model.encode(texts, normalize_embeddings=True)

Флаг normalize_embeddings=True приводит векторы к единичной длине — это важно при косинусной метрике, поскольку косинусное сходство для нормализованных векторов совпадает со скалярным произведением и вычисляется быстрее.

Загружаем точки в коллекцию

points = [
    models.PointStruct(
        id=doc["id"],
        vector=vectors[i].tolist(),
        payload={
            "text":     doc["text"],
            "category": doc["category"],
            "year":     doc["year"]
        }
    )
    for i, doc in enumerate(documents)
]

client.upload_points(
    collection_name="articles",
    points=points,
    batch_size=100
)

Метод upload_points автоматически разбивает данные на батчи нужного размера. Для нашего небольшого набора это не принципиально, но на тысячах и миллионах записей batch_size прямо влияет на throughput. Если точка с таким id уже существует — она будет перезаписана.

Создаём индекс по payload-полю

Фильтры по метаданным работают и без индекса — Qdrant просто перебирает payload всех точек. Но на больших коллекциях это медленно. Для полей, по которым часто фильтруем, создаём явный индекс:

client.create_payload_index(
    collection_name="articles",
    field_name="category",
    field_schema=models.PayloadSchemaType.KEYWORD
)

Тип KEYWORD подходит для строк с точным совпадением. Для числовых полей используем INTEGER или FLOAT, для полнотекстового поиска — TEXT.

Проверяем состояние коллекции

info = client.get_collection("articles")

print("Точек в коллекции:", info.points_count)
print("Статус:", info.status)
print("Оптимизатор:", info.optimizer_status)

Статус green означает, что коллекция полностью готова к запросам. Статус yellow — фоновый оптимизатор ещё работает, индексирует сегменты. Это нормально сразу после загрузки данных: поиск уже работает, просто HNSW-граф ещё не полностью построен, и часть запросов временно обслуживается через плоский перебор. После нескольких секунд статус переходит в green.

Поиск: от базового kNN до фильтрации по метаданным

Данные загружены — теперь ищем. На этом шаге разберём базовый векторный поиск, добавим к нему фильтрацию по payload и посмотрим, как параметр hnsw_ef влияет на баланс между скоростью и точностью.

Базовый поиск ближайших соседей

Для поиска нужно сначала построить эмбеддинг запроса той же моделью, которой кодировали документы:

from qdrant_client import QdrantClient, models
from sentence_transformers import SentenceTransformer

client = QdrantClient(url="http://localhost:6333")
model = SentenceTransformer("all-MiniLM-L6-v2")

query = "Как машинное обучение используется в поиске информации"
query_vector = model.encode(query, normalize_embeddings=True).tolist()

results = client.query_points(
    collection_name="articles",
    query=query_vector,
    limit=3,
    with_payload=True
)

for point in results.points:
    print(f"score={point.score:.3f} | {point.payload['text']}")

В ответе каждая точка содержит id, score (косинусное сходство от 0 до 1) и поля payload. Результаты отсортированы по убыванию score — самый похожий документ идёт первым.

Поиск с фильтром по метаданным

Добавим условие: искать только среди статей категории ml.

results = client.query_points(
    collection_name="articles",
    query=query_vector,
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="category",
                match=models.MatchValue(value="ml")
            )
        ]
    ),
    limit=3,
    with_payload=True
)

for point in results.points:
    print(f"score={point.score:.3f} | {point.payload['text']}")

Qdrant применяет фильтр до финального ранжирования — сначала отсеивает неподходящие точки, затем ищет ближайших соседей среди оставшихся. Если проиндексировали поле category на предыдущем шаге, этот отбор пройдёт по индексу, а не полным перебором.

Фильтры можно комбинировать. Например, статьи категории ml или db, опубликованные не раньше 2023 года:

results = client.query_points(
    collection_name="articles",
    query=query_vector,
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="year",
                range=models.Range(gte=2023)
            )
        ],
        should=[
            models.FieldCondition(
                key="category",
                match=models.MatchValue(value="ml")
            ),
            models.FieldCondition(
                key="category",
                match=models.MatchValue(value="db")
            )
        ]
    ),
    limit=3,
    with_payload=True
)

Условия в must обязательны для всех результатов, условия в should работают как OR — достаточно хотя бы одного.

Multi-tenancy: одна коллекция — несколько пользователей

В реальных приложениях часто нужно разграничить данные разных пользователей или организаций. Создавать отдельную коллекцию на каждого — плохая идея: это дорого по ресурсам и неудобно в обслуживании. Стандартный паттерн — хранить всё в одной коллекции, добавляя в payload поле tenant_id, и фильтровать по нему при каждом запросе:

# При загрузке — добавляем tenant_id в payload каждой точки
documents = [
    {"id": 1, "text": "Документ пользователя"},
    {"id": 2, "text": "Ещё один документ"},
]
vectors = model.encode([d["text"] for d in documents], normalize_embeddings=True)

client.upload_points(
    collection_name="articles",
    points=[
        models.PointStruct(
            id=doc["id"],
            vector=vectors[i].tolist(),
            payload={
                "text":      doc["text"],
                "tenant_id": "user_42"   # ← идентификатор владельца
            }
        )
        for i, doc in enumerate(documents)
    ]
)

# При поиске — всегда фильтруем по tenant_id
results = client.query_points(
    collection_name="articles",
    query=query_vector,
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="tenant_id",
                match=models.MatchValue(value="user_42")
            )
        ]
    ),
    limit=5,
    with_payload=True
)

Чтобы фильтрация по tenant_id работала быстро, создаём для него payload-индекс типа KEYWORD — так же, как делали для поля category на предыдущем шаге.

Управление точностью через hnsw_ef

По умолчанию Qdrant использует hnsw_ef, равный ef_construct коллекции. Это разумный баланс, но при необходимости его можно менять для каждого запроса отдельно:

# Быстрый поиск с меньшей глубиной обхода графа
results_fast = client.query_points(
    collection_name="articles",
    query=query_vector,
    limit=3,
    search_params=models.SearchParams(hnsw_ef=32)
)

# Более точный поиск с большей глубиной
results_accurate = client.query_points(
    collection_name="articles",
    query=query_vector,
    limit=3,
    search_params=models.SearchParams(hnsw_ef=256)
)

На нашей маленькой коллекции разница незаметна — слишком мало данных. На сотнях тысяч векторов более высокий hnsw_ef увеличивает recall (долю правильно найденных соседей), но замедляет запрос. Значение 128 — хорошая отправная точка для большинства production-задач.

Если нужно получить эталонный результат для сравнения — например, при оценке качества квантования — можно включить точный перебор:

results_exact = client.query_points(
    collection_name="articles",
    query=query_vector,
    limit=3,
    search_params=models.SearchParams(exact=True)
)

Режим exact=True гарантирует нахождение истинных ближайших соседей, но работает за линейное время — только для отладки и бенчмаркинга.

Получение точек по ID

Иногда нужно не искать, а просто забрать конкретные записи по известным идентификаторам:

points = client.retrieve(
    collection_name="articles",
    ids=[1, 3, 5],
    with_payload=True
)

for point in points:
    print(point.id, point.payload["text"])

Это быстрая операция по хэшу — без обращения к HNSW-индексу.

CRUD: обновление и удаление данных

CRUD — это четыре базовые операции с данными: Create (создать), Read (прочитать), Update (обновить), Delete (удалить). Qdrant поддерживает полный набор операций над данными — не только добавление и поиск, но и обновление векторов, изменение метаданных и удаление точек. Разберём каждую из них.

Upsert: добавление и обновление

Метод upsert работает по принципу «вставить или обновить»: если точка с таким id уже существует — перезаписывает её, если нет — создаёт новую. Это удобно, когда не нужно отдельно проверять существование записи:

from qdrant_client import QdrantClient, models
from sentence_transformers import SentenceTransformer

client = QdrantClient(url="http://localhost:6333")
model = SentenceTransformer("all-MiniLM-L6-v2")

new_text = "Диффузионные модели генерируют изображения из шума"
new_vector = model.encode(new_text, normalize_embeddings=True).tolist()

client.upsert(
    collection_name="articles",
    points=[
        models.PointStruct(
            id=7,
            vector=new_vector,
            payload={"text": new_text, "category": "ml", "year": 2024}
        )
    ]
)

Qdrant обновляет HNSW-индекс инкрементально — перестройка всей структуры не требуется.

Обновление только вектора

Если содержимое документа изменилось и нужно заменить эмбеддинг, не трогая метаданные:

updated_text = "Диффузионные модели — новый стандарт генерации изображений"
updated_vector = model.encode(updated_text, normalize_embeddings=True).tolist()

client.update_vectors(
    collection_name="articles",
    points=[
        models.PointVectors(id=7, vector=updated_vector)
    ]
)

Обновление payload без изменения вектора

Часто нужно обновить только метаданные — например, исправить категорию или добавить новое поле. set_payload записывает указанные поля, не затрагивая остальные:

# Добавляем поле и обновляем существующее
client.set_payload(
    collection_name="articles",
    payload={"category": "generative", "reviewed": True},
    points=[7]
)

Если нужно убрать конкретное поле из payload:

client.delete_payload(
    collection_name="articles",
    keys=["reviewed"],
    points=[7]
)

Удаление точек

Удаление по списку идентификаторов:

client.delete(
    collection_name="articles",
    points_selector=models.PointIdsList(points=[7])
)

Удаление по фильтру — например, убрать все статьи 2022 года:

client.delete(
    collection_name="articles",
    points_selector=models.FilterSelector(
        filter=models.Filter(
            must=[
                models.FieldCondition(
                    key="year",
                    match=models.MatchValue(value=2022)
                )
            ]
        )
    )
)

Как работает удаление под капотом

Qdrant не удаляет точки немедленно из сегментов на диске. Вместо этого он помечает их как удалённые через механизм tombstone — специальную запись-захоронение. Такие точки сразу перестают появляться в результатах поиска, но физически остаются в файлах до следующего compaction.

Фоновый оптимизатор периодически сливает сегменты и вычищает tombstone-записи. Именно поэтому после массового удаления занятое место на диске уменьшается не сразу, а через некоторое время. Для нас это означает, что удаление безопасно вызывать в любой момент — оно не блокирует поиск и не требует ручного обслуживания.

После операций можно убедиться, что всё применилось:

info = client.get_collection("articles")
print("Точек осталось:", info.points_count)

Гибридный поиск: sparse-векторы, dense-векторы и их объединение

Прежде чем перейти к коду, стоит разобраться, что такое sparse-векторы и почему в гибридном поиске нужны оба типа.

Dense и sparse: два разных взгляда на текст

С dense-векторами мы уже хорошо знакомы. Это плотные эмбеддинги из нейросетевых моделей — массив из нескольких сотен или тысяч чисел, где почти все компоненты ненулевые. Каждое измерение не несёт явного смысла, но вместе они кодируют семантику текста: синонимы и близкие по смыслу фразы оказываются рядом в пространстве.

Sparse-вектор устроен принципиально иначе. Его размерность равна размеру словаря модели — десятки тысяч измерений, — но ненулевые значения есть только у тех слов, которые встречаются в тексте (или семантически с ними связаны). Поиск по таким векторам работает через перевёрнутый индекс: мы ищем документы, у которых пересекаются ненулевые позиции с запросом, и взвешиваем их важность. Это та же логика, что лежит в основе поисковых систем уже несколько десятилетий.

Чтобы понять разницу наглядно, представим два документа:

  1. «HNSW — графовый алгоритм для поиска ближайших соседей»
  2. «Иерархические навигируемые малые миры используются в векторных индексах»

Dense-поиск найдёт оба по запросу «алгоритм для векторного поиска» — они семантически близки. Но по запросу «HNSW» sparse-поиск точнее попадёт в первый документ, потому что буквально содержит этот термин. Dense-модель может решить, что «HNSW» семантически ближе к чему-то другому — это классическая проблема vocabulary mismatch, несовпадения словаря запроса и документа.

Отсюда и идея гибридного поиска: dense ловит смысл, sparse ловит термины. Вместе они перекрывают слабые стороны друг друга.

Как устроены sparse-модели

Простейший вариант sparse-векторов — BM25. Это классический алгоритм информационного поиска: вес каждого термина считается через частоту его встречаемости в документе и обратную частоту по всему корпусу (IDF). BM25 работает без обучения и очень быстро, но страдает от vocabulary mismatch — он не знает, что «машинное обучение» и «ML» это одно и то же.

Более продвинутый вариант — нейросетевые sparse-модели типа SPLADE (Sparse Lexical and Expansion). SPLADE берёт за основу трансформер с головой masked language modeling и обучает его присваивать веса не только токенам из исходного текста, но и семантически связанным — это называется расширением терминов (term expansion). Документ про «машинное обучение» получит ненулевые веса в том числе у измерений «ML», «нейросети», «модели» — и при поиске по любому из этих терминов будет найден. Именно поэтому SPLADE на практике заметно превосходит BM25: сохраняет точность ключевого поиска и при этом справляется с вариациями словаря.

FastEmbed: библиотека от Qdrant

FastEmbed — это легковесная библиотека для генерации эмбеддингов, разработанная и поддерживаемая командой Qdrant. В отличие от sentence-transformers, она не тянет за собой PyTorch и работает через ONNX Runtime, что делает её быстрой и удобной для серверных окружений и serverless-функций.

Устанавливается стандартно, либо с поддержкой GPU:

pip install fastembed

# или с поддержкой GPU
pip install fastembed-gpu

GPU-версия ускоряет кодирование больших корпусов, но для большинства учебных задач и небольших батчей CPU-версии достаточно.

Где искать sparse-модели

У sparse-моделей нет единого тега на Hugging Face, поэтому ориентируемся по-другому в зависимости от того, через какую библиотеку планируем их использовать.

Если используем FastEmbed — проще всего посмотреть прямо в коде, какие модели поддерживаются:

from fastembed import SparseTextEmbedding
SparseTextEmbedding.list_supported_models()

Это вернёт актуальный список с названиями, размерами и описаниями. Полный список также есть в документации FastEmbed: qdrant.github.io/fastembed/examples/Supported_Models. Там же можно найти все доступные dense-модели для TextEmbedding.

Если используем Sentence Transformers — искать стоит через организацию sparse-encoder на Hugging Face. Это курируемая коллекция моделей, совместимых с классом SparseEncoder из sentence-transformers. Там же есть подборка Splade Models с наиболее рекомендуемыми вариантами.

Кроме того, модели можно искать ключевым словом splade в поиске по моделям на Hugging Face — так находятся и модели вне официальных коллекций.

Конкретные варианты, которые стоит знать.

  • naver/splade-v3 — флагманская модель от авторов SPLADE, высокая точность, но требует отдельных весов для документов и запросов.
  • prithivida/Splade_PP_en_v1 — сбалансированный вариант с одной моделью для обоих направлений, доступен через FastEmbed.
  • Отдельного упоминания заслуживает BAAI/bge-m3 — одновременно dense и sparse модель в одном, возвращает оба типа векторов за один проход.

Существуют также inference-free SPLADE — варианты, где вектор запроса строится почти мгновенно, как в BM25, а качество близко к полноценному SPLADE; удобно, когда latency критична.

Большинство перечисленных SPLADE-моделей обучены на английском. В нашем примере мы используем Qdrant/bm25 — реализацию BM25 в FastEmbed с многоязычным токенизатором, которая корректно обрабатывает русский.

Коллекция с именованными векторами

Для гибридного поиска создаём коллекцию, которая хранит одновременно плотный и разреженный вектор для каждой точки. Обратите внимание: для BM25-коллекции нужно указать модификатор Modifier.IDF — он говорит Qdrant вычислять IDF на стороне сервера, как это делает классический BM25:

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

client.create_collection(
    collection_name="articles_hybrid",
    vectors_config={
        "dense": models.VectorParams(
            size=384,
            distance=models.Distance.COSINE
        )
    },
    sparse_vectors_config={
        "sparse": models.SparseVectorParams(
            modifier=models.Modifier.IDF  # нужно для BM25
        )
    }
)

Загружаем данные с двумя типами векторов

from sentence_transformers import SentenceTransformer
from fastembed import SparseTextEmbedding

dense_model  = SentenceTransformer("all-MiniLM-L6-v2")
sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")

documents = [
    {"id": 1, "text": "HNSW — графовый алгоритм для поиска ближайших соседей", "category": "algorithms"},
    {"id": 2, "text": "Трансформеры изменили подход к обработке текста",        "category": "ml"},
    {"id": 3, "text": "Векторные базы данных хранят эмбеддинги и ищут по ним",  "category": "db"},
    {"id": 4, "text": "FAISS реализует IVF и HNSW для эффективного поиска",     "category": "algorithms"},
    {"id": 5, "text": "Косинусное сходство измеряет угол между векторами",       "category": "math"},
]

texts = [doc["text"] for doc in documents]

dense_vectors  = dense_model.encode(texts, normalize_embeddings=True)
sparse_vectors = list(sparse_model.embed(texts))

points = []
for i, doc in enumerate(documents):
    sv = sparse_vectors[i]
    points.append(
        models.PointStruct(
            id=doc["id"],
            vector={
                "dense": dense_vectors[i].tolist(),
                "sparse": models.SparseVector(
                    indices=sv.indices.tolist(),
                    values=sv.values.tolist()
                )
            },
            payload={"text": doc["text"], "category": doc["category"]}
        )
    )

client.upload_points(collection_name="articles_hybrid", points=points)

Обратите внимание на структуру sparse-вектора: это не массив фиксированной длины, а пара списков. indices хранит номера ненулевых позиций в словаре, values — их веса. Вместо десятков тысяч нулей мы передаём только ненулевые компоненты — именно поэтому такие векторы называются разреженными.

FusionQuery: объединение через RRF

query_text = "алгоритм HNSW для поиска ближайших соседей"

dense_query = dense_model.encode(query_text, normalize_embeddings=True).tolist()

sparse_result = list(sparse_model.embed([query_text]))[0]
sparse_query  = models.SparseVector(
    indices=sparse_result.indices.tolist(),
    values=sparse_result.values.tolist()
)

results = client.query_points(
    collection_name="articles_hybrid",
    prefetch=[
        models.Prefetch(query=dense_query,  using="dense",  limit=5),
        models.Prefetch(query=sparse_query, using="sparse", limit=5),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=3,
    with_payload=True
)

for point in results.points:
    print(f"score={point.score:.3f} | {point.payload['text']}")

RRF работает только с позициями в списках, не завися от абсолютных значений score. Это делает его устойчивым, когда dense и sparse дают оценки в разных диапазонах. DBSF нормализует сами значения — он лучше, когда важен реальный масштаб оценок, а не только порядок.

FormulaQuery: явные веса для каждой модальности

results = client.query_points(
    collection_name="articles_hybrid",
    prefetch=[
        models.Prefetch(query=dense_query,  using="dense",  limit=5),  # $score[0]
        models.Prefetch(query=sparse_query, using="sparse", limit=5),  # $score[1]
    ],
    query=models.FormulaQuery(
        formula=models.SumExpression(sum=[
            models.MultExpression(mult=[0.7, "$score[0]"]),
            models.MultExpression(mult=[0.3, "$score[1]"])
        ])
    ),
    limit=3,
    with_payload=True
)

Индексы $score[0] и $score[1] соответствуют порядку подзапросов в prefetch. Здесь мы отдаём приоритет семантическому поиску (0.7), оставляя ключевому (0.3) роль уточняющего сигнала. Для задач с большим количеством точных терминов — технической документации, кодовой базы, юридических текстов — имеет смысл попробовать обратные веса. Оптимальное соотношение стоит подбирать по метрике Recall@K на тестовых запросах.

Full-text поиск: третья модальность

Помимо dense и sparse векторов, в Qdrant есть ещё один инструмент для работы с текстом — full-text индекс по payload. Это не векторный поиск: он работает как обычный текстовый поиск с токенизацией, поддержкой морфологии и поиском по подстроке. Его главное преимущество — он встроен в Qdrant и не требует никаких дополнительных моделей.

Создаём полнотекстовый индекс по полю text:

client.create_payload_index(
    collection_name="articles_hybrid",
    field_name="text",
    field_schema=models.TextIndexParams(
        type=models.TextIndexType.TEXT,
        tokenizer=models.TokenizerType.WORD,
        min_token_len=2,
        max_token_len=30,
        lowercase=True
    )
)

Теперь можно делать текстовую фильтрацию прямо внутри векторного запроса. Например, найти семантически похожие документы, в тексте которых обязательно встречается слово «HNSW»:

results = client.query_points(
    collection_name="articles_hybrid",
    query=dense_query,
    using="dense",
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="text",
                match=models.MatchText(text="HNSW")
            )
        ]
    ),
    limit=3,
    with_payload=True
)

MatchText ищет вхождение термина с учётом токенизации и регистра (если задан lowercase=True). Это третий способ работать с текстом в Qdrant — не вместо dense/sparse, а в дополнение к ним. Полезно, когда нужно гарантированно включить документы с конкретным термином, даже если семантически они не самые близкие: например, найти все статьи, где явно упоминается название алгоритма или имя собственное.

Итоги урока

За этот урок мы прошли полный практический цикл работы с Qdrant: создали коллекцию, загрузили данные батчами с payload-индексами, реализовали поиск с фильтрами и настройкой точности через hnsw_ef, разобрались с полным CRUD — как независимо обновлять векторы и метаданные, как работает tombstone при удалении. Финальным шагом собрали гибридный поиск: разобрались, чем sparse-векторы принципиально отличаются от dense, что такое BM25 и SPLADE, как term expansion решает проблему vocabulary mismatch, и реализовали объединение двух типов поиска через FusionQuery и FormulaQuery. Также посмотрели на full-text индекс как третью модальность — инструмент для точного поиска по терминам без дополнительных моделей. В следующем уроке встроим всё это в RAG-пайплайн через LangChain.

Groups и Scroll: разнообразие результатов и обход коллекции

Проблема дублирования в RAG

Когда мы разбиваем документы на чанки и индексируем их по отдельности, возникает неочевидная проблема: топ-N результатов поиска нередко оказываются чанками из одного и того же документа. Длинная статья может занять пять из десяти позиций — и языковая модель получит пять немного различающихся фрагментов одного текста вместо пяти разных источников. Контекст раздувается, полезная информация разбавляется, качество ответа падает.

Qdrant решает это через группировку результатов — запрос возвращает не просто топ-N точек, а по N лучших точек из каждой группы, где группа определяется значением payload-поля.

query_points_groups: один результат на документ

Предположим, каждый чанк хранит поле doc_id — идентификатор исходного документа. Тогда запрос с группировкой выглядит так:

from qdrant_client import QdrantClient, models
from sentence_transformers import SentenceTransformer

client = QdrantClient(url="http://localhost:6333")
model  = SentenceTransformer("all-MiniLM-L6-v2")

# Загружаем чанки с привязкой к исходному документу
chunks = [
    {"id": 1, "text": "HNSW строит многоуровневый граф для поиска",           "doc_id": "doc_a", "chunk": 0},
    {"id": 2, "text": "Верхние уровни HNSW разрежены для быстрой навигации",   "doc_id": "doc_a", "chunk": 1},
    {"id": 3, "text": "Нижний уровень HNSW плотный, финальный поиск идёт там", "doc_id": "doc_a", "chunk": 2},
    {"id": 4, "text": "Векторные базы хранят эмбеддинги на диске",             "doc_id": "doc_b", "chunk": 0},
    {"id": 5, "text": "Qdrant использует HNSW как основной индекс",            "doc_id": "doc_b", "chunk": 1},
    {"id": 6, "text": "Трансформеры обучаются на задаче предсказания токенов", "doc_id": "doc_c", "chunk": 0},
]

vectors = model.encode([c["text"] for c in chunks], normalize_embeddings=True)

client.upload_points(
    collection_name="articles",
    points=[
        models.PointStruct(
            id=c["id"],
            vector=vectors[i].tolist(),
            payload={"text": c["text"], "doc_id": c["doc_id"], "chunk": c["chunk"]}
        )
        for i, c in enumerate(chunks)
    ]
)

Теперь делаем поиск с группировкой по doc_id — получим по одному лучшему чанку из каждого документа:

query = "как устроен алгоритм HNSW"
query_vector = model.encode(query, normalize_embeddings=True).tolist()

groups = client.query_points_groups(
    collection_name="articles",
    query=query_vector,
    group_by="doc_id",      # поле payload для группировки
    limit=3,                # сколько групп (документов) вернуть
    group_size=1,           # сколько чанков из каждой группы
    with_payload=True
)

for group in groups.groups:
    print(f"Документ: {group.id}")
    for point in group.hits:
        print(f"  score={point.score:.3f} | {point.payload['text']}")
    print()

Результат: три документа, по одному лучшему чанку из каждого — вместо трёх чанков из одного документа. Можно задать group_size=2, если хочется немного больше контекста на документ, но не терять разнообразия.

Scroll: постраничный обход всей коллекции

Иногда нужно не искать, а перебрать все точки коллекции — например, чтобы перестроить эмбеддинги при смене модели, сделать экспорт данных или пересчитать какое-то поле в payload. Для этого в Qdrant есть метод scroll.

Он работает по принципу курсора: каждый вызов возвращает порцию точек и offset для следующего запроса. Когда offset становится None — коллекция закончилась.

offset = None

while True:
    result, next_offset = client.scroll(
        collection_name="articles",
        limit=100,              # точек за один запрос
        offset=offset,
        with_payload=True,
        with_vectors=False      # не тащим векторы, если не нужны — экономим память
    )

    for point in result:
        # обрабатываем каждую точку
        print(point.id, point.payload.get("text", "")[:60])

    if next_offset is None:
        break

    offset = next_offset

Параметр with_vectors=False здесь принципиален: если в коллекции миллион векторов по 384 числа каждый, загрузка их всех в память одним запросом легко приводит к OOM. Запрашиваем векторы только тогда, когда они действительно нужны — например, при переиндексировании.

Scroll с фильтром: выборочный обход

Scroll можно комбинировать с фильтрами — например, чтобы перебрать только точки конкретного пользователя или только документы определённой категории:

result, next_offset = client.scroll(
    collection_name="articles",
    scroll_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="doc_id",
                match=models.MatchValue(value="doc_a")
            )
        ]
    ),
    limit=100,
    with_payload=True,
    with_vectors=True   # здесь уже нужны векторы — будем переиндексировать
)

Типичный сценарий: при смене модели эмбеддингов мы проходим через всю коллекцию по scroll, пересчитываем векторы новой моделью и делаем upsert обновлённых точек. Это безопасная операция — старые данные остаются доступными для поиска до момента перезаписи.

Задача 1

Почему флаг normalize_embeddings=True важен при использовании косинусной метрики?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Что означает статус yellow у коллекции сразу после загрузки данных?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Чем set_payload отличается от upsert при обновлении метаданных точки?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Почему после удаления точек занятое место на диске уменьшается не сразу?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

В чём принципиальное отличие RRF от DBSF при слиянии результатов?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 6

Для какого типа поля payload нужно указывать схему KEYWORD при создании индекса?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 7

Какие утверждения об операторе should в фильтрах Qdrant верны?

Выберите все подходящие варианты
Без регистрации · результат не сохраняется

Задача 8

Какие утверждения о методе update_vectors верны?

Выберите все подходящие варианты
Без регистрации · результат не сохраняется

Задача 9

Сопоставьте операцию с её описанием

Сопоставьте элементы

Без регистрации · результат не сохраняется

Задача 10

Сопоставьте компонент гибридного поиска с его ролью

Сопоставьте элементы

Без регистрации · результат не сохраняется

Задача 11

Зачем при scroll-обходе большой коллекции рекомендуется передавать with_vectors=False?

Выберите один вариант
Без регистрации · результат не сохраняется

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку