Урок курса
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-вектор устроен принципиально иначе. Его размерность равна размеру словаря модели — десятки тысяч измерений, — но ненулевые значения есть только у тех слов, которые встречаются в тексте (или семантически с ними связаны). Поиск по таким векторам работает через перевёрнутый индекс: мы ищем документы, у которых пересекаются ненулевые позиции с запросом, и взвешиваем их важность. Это та же логика, что лежит в основе поисковых систем уже несколько десятилетий.
Чтобы понять разницу наглядно, представим два документа:
- «HNSW — графовый алгоритм для поиска ближайших соседей»
- «Иерархические навигируемые малые миры используются в векторных индексах»
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?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
