Qdrant для RAG: хранилище вместо индекса и качество извлечения

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

Ниже — разбор на живом проекте. RAG Studio: PDF-документы въезжают через VLM-конвейер, режутся на чанки, ложатся в Qdrant, ищутся гибридно и отжимаются реранкером. Плюс то, что обычно проваливают: качество извлечения. Хранилище — только половина дела.

Полный конвейер — от загрузки документов до оценки ответов — собираем на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов».

Чем Qdrant отличается от индекса-библиотеки?

Библиотека вроде FAISS — это структура данных внутри вашего процесса. Она умеет одно: по вектору найти ближайшие. Всё остальное — ваша забота: где хранить тексты, как связать вектор с документом, что делать при рестарте, как удалить устаревшее.

Qdrant — отдельный сервис (обычный контейнер Docker), у которого есть три понятия и на них всё держится:

  • Коллекция — именованное пространство векторов со своей конфигурацией: размерность, метрика расстояния, параметры HNSW-индекса. Разные коллекции живут независимо, и это уже даёт разделение «база знаний по продукту» и «архив договоров».
  • Точка — единица хранения: идентификатор, один или несколько векторов и payload — произвольный JSON рядом с вектором.
  • Фильтр — условие по payload, которое применяется вместе с векторным поиском, а не после него. Это принципиально: не «найди сто ближайших и отбрось чужие», а «ищи ближайших только среди тех, у кого directory_id = 3».

Коллекция в RAG Studio создаётся сразу под гибридный поиск — плотные векторы модели эмбеддингов и разреженные BM25 в одной точке:

vectors_config = {
    "dense": VectorParams(
        size=get_embedding_dim(),
        distance=Distance.COSINE,
        hnsw_config=HnswConfigDiff(
            m=settings.embedding.hnsw_m,
            ef_construct=settings.embedding.hnsw_ef_construct,
        ),
    ),
}
sparse_vectors_config = {"bm25": SparseVectorParams(modifier=Modifier.IDF)}

client.create_collection(
    collection_name=collection_name,
    vectors_config=vectors_config,
    sparse_vectors_config=sparse_vectors_config,
)

Размерность вектора зашита в коллекцию намертво: сменили модель эмбеддингов — старые точки становятся мусором, а Qdrant при вставке просто ругнётся на несовпадение. Поэтому ensure_collection перед работой сравнивает фактическую размерность коллекции с размерностью текущего бэкенда и, если они разошлись, пересоздаёт коллекцию честно и явно, а не роняет индексацию на середине.

Что класть в payload

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

В RAG Studio точка выглядит так:

point = PointStruct(
    id=chunk.id,
    vector={"dense": embeddings[i], "bm25": sparse_vectors[i]},
    payload={
        "chunk_id": chunk.id,
        "document_id": document_id,
        "directory_id": doc.directory_id,
        "chunk_index": chunk.chunk_index,
        "content": chunk.content,
        "context": chunk.context,
        "start_page": chunk.start_page,
        "end_page": chunk.end_page,
        "filename": doc.filename,
        "token_count": chunk.token_count,
    },
)

Здесь три группы полей. directory_id и document_id — оси фильтрации: поиск по одному каталогу, поиск внутри одного документа. content и context — то, что уйдёт в промпт модели. filename, start_page, end_page — то, из чего собирается ссылка на источник в ответе: «файл такой-то, страницы 14–15». Без последней группы RAG превращается в оракула, которому нечем верить.

Фильтр в запросе собирается из тех же ключей и уходит в поиск целиком:

must_conditions = []
if directory_id is not None:
    must_conditions.append(
        FieldCondition(key="directory_id", match=MatchValue(value=directory_id))
    )
query_filter = Filter(must=must_conditions) if must_conditions else None

dense_points = client.query_points(
    collection_name=collection_name,
    query=query_vector,
    using="dense",
    query_filter=query_filter,
    limit=top_k,
).points

Тот же фильтр решает и задачу удаления. Документ обновился — старые векторы надо убрать, и убрать точечно, не перестраивая коллекцию:

client.delete(
    collection_name=collection_name,
    points_selector=Filter(
        must=[FieldCondition(key="document_id", match=MatchValue(value=document_id))]
    ),
)

Три строки вместо ночной пересборки всей базы — вот, собственно, и весь ответ на вопрос «зачем нам база вместо индекса».

Почему RAG всё равно отвечает мимо?

Дальше начинается неприятное. Хранилище можно поднять за вечер, а качество ответов от этого не появится. Модель отвечает ровно тем, что ей подложили в контекст, поэтому все претензии к RAG — это почти всегда претензии к извлечению, а не к модели.

Извлечение ломается на трёх участках, и они идут по порядку конвейера: что попало в базу, в каком виде это нарезано и что из найденного реально доехало до промпта. Разберу каждый.

Вход: почему PDF не парсится текстовым экстрактором?

Корпоративная документация — это сканы, таблицы, схемы, колонтитулы и подписи под рисунками. Текстовый экстрактор на таком PDF выдаёт либо пустоту (страница — картинка), либо кашу: колонки склеиваются в одну строку, таблица разваливается на несвязанные числа, заголовок оказывается посреди абзаца. Векторизовать такую кашу можно, найти по ней что-то осмысленное — нет.

В RAG Studio вход устроен иначе: страница рендерится в картинку и отдаётся vision-модели, которая возвращает Markdown. Конвейер трёхфазный, и это не украшение архитектуры:

  1. Рендеринг. Каждая страница PDF превращается в PNG на 300 dpi (fitz, он же PyMuPDF), файлы кладутся в scans/ с номером в имени.
  2. Распознавание. Каждый скан уходит в VLM с промптом «переведи страницу в Markdown», результат сохраняется отдельным файлом pages/page_001.md.
  3. Сборка. Все страничные файлы склеиваются в один result.md, и перед каждой вставляется маркер <!-- page:14 -->.

Разделение на фазы даёт возобновление. Документ на триста страниц — это часы работы VLM, и обрыв на середине здесь норма жизни, а не исключение. Поэтому обе фазы перед работой смотрят на диск:

# Пропуск уже обработанных
if _is_valid_markdown(md_path):
    emit(InfoEvent("info", f"Страница {page_num}/{total_pages} — markdown уже есть, пропуск"))
    emit(PageProcessedEvent(page_number=page_num, markdown_path=str(md_path)))
    processed += 1
    continue

# Проверяем наличие скана
if not _is_valid_scan(scan_path):
    error_msg = f"Скан страницы {page_num} отсутствует или повреждён"
    emit(PageErrorEvent(page_number=page_num, error_message=error_msg))
    errors += 1
    continue

Обратите внимание: валидность проверяется не по факту существования файла, а по размеру — PNG меньше килобайта считается битым. Оборванная на середине запись оставляет на диске файл нулевой длины, и без такой проверки перезапуск бодро «пропускает» страницы, которых на самом деле нет.

Второй механизм — дедупликация по SHA-256. При сканировании каталога считается хеш содержимого файла и сравнивается с сохранённым:

file_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
if filename in existing_docs:
    doc = existing_docs[filename]
    if doc.file_hash == file_hash:
        skipped_files += 1          # файл не изменился
    else:
        doc.file_hash = file_hash   # содержимое поменялось —
        doc.status = DocumentStatus.pending   # переобработать
        changed_files += 1

Хеш по содержимому, а не по дате изменения. Даты врут постоянно: копирование по сети, выгрузка из архива, синхронизация папки — всё это переставляет mtime, не меняя ни байта внутри. Гонять на это VLM-конвейер стоимостью в часы GPU — дорогое удовольствие.

Маркеры страниц из третьей фазы, кстати, доживают до конца конвейера: чанкер по ним восстанавливает диапазон start_pageend_page каждого фрагмента, и именно эти числа потом лежат в payload точки и попадают в ссылку на источник.

Контекст чанка: зачем дописывать пояснение о его месте в документе?

Фрагмент, вырванный из середины документа, часто бессмысленен сам по себе. «Значение параметра указывается в третьей графе, при отсутствии — прочерк» — про что это? Про какой параметр, в какой форме, в каком регламенте? Человек, читающий документ подряд, знает ответ из предыдущих страниц. Вектор чанка — не знает, и на запросе «как заполнять графу 3 в отчёте по НДС» этот фрагмент не найдётся.

Лечится это приёмом, который Anthropic назвали Contextual Retrieval: перед векторизацией к каждому чанку дописывается короткое пояснение, сгенерированное моделью из полного текста документа. Промпт минимальный:

_CONTEXT_PROMPT_TEMPLATE = """<document>
{full_document}
</document>
Вот фрагмент из этого документа:
<chunk>
{chunk_content}
</chunk>
Напиши краткий контекст для позиционирования этого фрагмента в документе, \
чтобы улучшить поисковую выдачу. Отвечай только контекстом."""

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

if context_text:
    new_content = f"{context_text}\n\n{chunk.content}"
    metadata = dict(chunk.metadata) if chunk.metadata else {}
    metadata["original_content"] = chunk.content
    metadata["context"] = context_text

Почему именно приклеивается, а не кладётся отдельным полем: векторизуется и индексируется BM25 то, что лежит в content. Контекст в отдельном поле улучшил бы ровно ничего — он должен попасть в текст, по которому строится вектор и разреженный индекс. Отсюда и правило индексации в проекте: текст для эмбеддинга — это context + "\n\n" + content, а не голое содержимое.

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

Реранкер: почему top-k из поиска — это кандидаты, а не ответ?

Векторный поиск сравнивает два вектора, посчитанных независимо друг от друга: вектор запроса и вектор чанка. Модель эмбеддингов ни разу не видела их вместе. Она отвечает на вопрос «похожи ли эти тексты по смыслу», а не на вопрос «отвечает ли этот фрагмент на этот запрос». Разница между двумя вопросами и есть источник половины промахов: похожий по теме кусок легко обходит менее похожий, но единственный содержащий ответ.

Реранкер устроен наоборот — это cross-encoder, который получает запрос и документ в одном входе и выносит суждение о релевантности. Он на порядки дороже, поэтому его нельзя пустить по всей базе; его пускают по короткому списку кандидатов.

Отсюда рабочая схема поиска: взять широко и отжать точно. В RAG Studio широкий отбор — гибридный: плотный вектор ловит смысл, BM25 ловит точные вхождения (артикулы, номера приказов, названия полей — там, где эмбеддинги традиционно плывут). Оба списка складываются и целиком уходят в Qwen3-Reranker, поднятый на vLLM:

payload = {
    "model": self.model,
    "query": query,
    "items": formatted_items,
    "label_token_ids": _QWEN3_LABEL_TOKEN_IDS,   # id токенов ["no", "yes"]
}

Внутри — приём, который стоит понимать, а не копировать вслепую. Qwen3-Reranker не выдаёт число: он отвечает на вопрос «релевантен ли документ» словом yes или no, а скором служит вероятность токена yes. Каждый кандидат перед отправкой оборачивается в жёсткий формат — инструкция, запрос, документ:

def _format_item(instruction: str, query: str, doc: str) -> str:
    return f"<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {doc}"

Формат тут не декоративный: модель обучена именно на нём, и произвольная склейка полей заметно роняет качество ранжирования. Практический эффект от связки — можно спокойно брать широкий top_k из Qdrant, не боясь замусорить контекст: до промпта доедут два-три действительно релевантных фрагмента, а не десять «похожих по теме». Как устроен сам поисковый слой ниже реранкера — эмбеддинги, расстояния, пороги — разбирал отдельно в статье про семантический поиск и эмбеддинги.

Промпты реранкера, подбор инструкции под свою предметную область и калибровку порогов мы разбираем на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов» — там же собирается вся связка целиком, от VLM-конвейера до оценки.

Один пайплайн на чат и на оценку

Момент, который в проектах проваливают чаще всего. Сначала пишется чат — поиск, сборка контекста, генерация. Потом отдельно пишется скрипт оценки — и в нём поиск, сборка контекста и генерация пишутся заново, «чуть проще, для эксперимента». С этой минуты эксперимент меряет не то, что работает в проде: там другой top_k, другой системный промпт, контекст склеен другим разделителем.

В RAG Studio цикл вынесен в один модуль, а чат и оценка — два его вызова:

async def rag_answer(question, llm, directory_id=None, top_k=None, history=None):
    results = await search_documents(
        query=question,
        directory_id=directory_id,
        top_k=top_k or CONTEXT_TOP_K,
    )
    context_text = format_context(results)
    messages = build_messages(question, context_text, history)

    full_response = ""
    async for chunk in llm.astream(messages):
        if chunk.content:
            full_response += chunk.content
    return full_response, results

format_context и build_messages — те самые общие кирпичи. Форматирование источника («[Источник 1: файл, стр. 14-15]»), склейка контекста с содержимым, системный промпт с требованием отвечать только по переданным документам — всё это существует в единственном экземпляре. Оценочный прогон отличается ровно двумя вещами: он разворачивает вызов на явные шаги, чтобы повесить на каждый трассировку, и добавляет в конце LLM-судью со своей шкалой. Логика поиска и генерации при этом та же самая.

Судей, кстати, два и они меряют разное: один оценивает финальный ответ (шкала 1–10), другой — релевантность найденного контекста (1–3). Разделение полезное: когда общая оценка падает, сразу видно, кто виноват — извлечение или генерация. Про трассировку, датасеты и саму механику LLM-судьи я писал подробно в разборе тестирования LLM через Langfuse.

Когда хватит индекса в памяти, а когда нужна база?

Не всякому проекту нужен Qdrant, и притаскивать контейнер в прототип из принципа — плохая идея. Граница проходит примерно так.

Индекса в памяти достаточно, если корпус небольшой и целиком помещается в RAM, обновляется он редко и целиком (проще пересобрать за минуту, чем городить инкрементальное обновление), фильтрация не нужна или сводится к одному-двум значениям, а приложение живёт в одном процессе. Классический случай — бот по одной инструкции или демо на пару сотен документов.

База нужна, когда появляется хотя бы два признака из списка: несколько источников с разными правами доступа (значит, фильтрация обязательна и должна работать внутри поиска, а не после); документы обновляются по одному; в системе больше одного процесса — веб, воркер индексации, скрипт оценки, и всем нужен один и тот же индекс; корпус растёт непредсказуемо; нужны разные коллекции под разные задачи. И отдельный, самый прозаичный признак — данные должны переживать перезапуск без «подождите, идёт индексация».

Про сам конвейер RAG целиком — от загрузки документов до ответа с историей диалога — есть отдельный разбор: RAG-система на LangChain. Эта статья про то, что начинается уровнем ниже, когда прототип уже работает и упирается в хранилище.

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

Схема обновления, которая работает и не требует героизма:

  • Идентификатор точки выводится из вашей основной БД, а не генерируется случайно. В RAG Studio id точки — это chunk.id из PostgreSQL. Повторная индексация того же чанка перезаписывает точку, а не плодит дубликат.
  • Единица обновления — документ, а не чанк. Изменился файл — удалили все его точки по фильтру document_id, нарезали заново, залили. Пытаться сопоставить старые и новые чанки после правки текста — гиблое дело: сдвиг одного абзаца перекраивает всю нарезку.
  • Статус живёт в основной БД, а не в векторной. У документа есть поле состояния индексации, и оно переключается в indexing перед началом и в indexed или error после. Векторное хранилище — не место для рабочего процесса; оно должно отвечать на запросы, а не хранить, что там не доехало.
  • Загрузка идёт батчами. Точки уходят пачками, а не по одной и не одним гигантским запросом: и то и другое упирается в лимиты — по времени в первом случае, по размеру payload во втором.
  • Смена модели эмбеддингов = полная переиндексация. Векторы разных моделей несовместимы в принципе. Это не «желательно пересобрать», это «старые точки перестают что-либо значить».

Где проходит граница

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

За кадром осталось то, что определяет, будет ли эта конструкция работать на вашем корпусе: конфигурация коллекции под нагрузку (параметры HNSW, квантование, шардирование, индексы по полям payload), подбор моделей эмбеддингов и реранкера под язык и предметную область, промпты VLM под конкретный тип документов, балансировка широкого отбора и глубины реранка, стоимость контекстуализации на большом каталоге. Это разбирается на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов»: там мы собираем конвейер целиком — приём документов, чанкинг, Qdrant, гибридный поиск с реранкером, чат и оценку — и прогоняем его на реальной документации.

Частые вопросы