Первый 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. Конвейер трёхфазный, и это не украшение архитектуры:
- Рендеринг. Каждая страница PDF превращается в PNG на 300 dpi (
fitz, он же PyMuPDF), файлы кладутся вscans/с номером в имени. - Распознавание. Каждый скан уходит в VLM с промптом «переведи страницу в Markdown», результат сохраняется отдельным файлом
pages/page_001.md. - Сборка. Все страничные файлы склеиваются в один
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_page–end_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, гибридный поиск с реранкером, чат и оценку — и прогоняем его на реальной документации.