В 2026 году RAG-системы перестали быть экспериментами. Их внедряют в банки, медицинские центры, юридические фирмы. И каждый раз инженеры наступают на одни и те же грабли: векторный поиск находит похожие, но не те документы, LLM галлюцинирует, бизнес теряет деньги. Я уже разбирал две ключевые идеи, которые бьют по корню проблемы — фильтрацию вместо поиска (статья «Почему поиск в RAG должен быть фильтрацией, а не поиском») и параллельный anchor detection (статья «Anchor Detection для RAG: параллельный поиск + один LLM-вызов»).
В этой статье я объединяю обе концепции в единый, рабочий enterprise-пайплайн. Вы получите не абстрактную теорию, а конкретную архитектуру, код и список типичных ошибок. Поехали.
Проблема: «слепой» векторный поиск убивает enterprise-кейсы
Классический RAG берёт запрос, превращает его в эмбеддинг и ищет ближайших соседей в векторном пространстве. Работает, пока данные — это новости или статьи. Но в enterprise каждый документ имеет структуру: дата, номер договора, статус, исполнитель. Векторный поиск на это забивает.
Пример: запрос «Покажи договоры с ООО Ромашка за май 2026, где сумма выше 10 млн». Векторный поиск вернёт документы про ООО Ромашка, но может прихватить и апрельские, и на 5 млн — потому что «похожие». LLM запутается, выдаст неверный ответ.
Фильтрация по метаданным (по toc_df — таблице оглавления) отсекает всё лишнее. Но одной фильтрации мало: нужно ещё найти релевантные строки внутри отфильтрованных документов. Тут подключается parallel anchor detection — мы разбиваем запрос на якоря (ключевые слова, сущности, даты) и гоним их параллельно в разные ретриверы: keyword, embedding, rule-based. Так мы не теряем ни один важный кусочек информации.
Архитектура: line_df + toc_df + Anchor Detect
Сначала готовим две таблицы на этапе индексации:
- toc_df — оглавление. Колонки: doc_id, раздел, дата, статус, сумма, контрагент. Собирается из заголовков, мета-полей и первых параграфов.
- line_df — строчные данные. Каждая строка — фрагмент текста, но с привязкой к контексту (раздел, doc_id). Имеет эмбеддинг и позиционный индекс.
Затем на этапе инференса запускаем параллельный пайплайн:
- Анализ запроса — LLM (например, Mistral Large 2.5, июнь 2026) генерирует JSON с anchors: ключевые слова, мета-фильтры, сущности.
- Фильтрация toc_df — по мета-фильтрам отбираем подмножество документов (SQL или pandas query).
- Параллельный поиск по line_df — для отобранных doc_id запускаем одновременно keyword search (BM25 из Tantivy) и embedding search (например, Cohere embed v3).
- Агрегация — собираем топ-k чанков, дедуплицируем (по doc_id+offset).
- Один LLM-вызов — модель получает агрегированный контекст и запрос, возвращает ответ и citation.
1 Сбор таблиц: препроцессинг документов
На входе — PDF, DOCX, HTML. Используем структурированный парсер (например, python-docx, pypdf2 с разметкой заголовков). Извлекаем иерархию разделов и плоский текст. Сохраняем в pandas DataFrame.
import pandas as pd
toc_df = pd.DataFrame(columns=['doc_id', 'section', 'date', 'status', 'amount', 'counterparty'])
line_df = pd.DataFrame(columns=['doc_id', 'section', 'text', 'embedding'])
# Пример заполнения после парсинга
toc_df = toc_df.append({'doc_id': 'd001', 'section': 'Генеральный подряд',
'date': '2026-05-15', 'status': 'active',
'amount': 15_000_000, 'counterparty': 'ООО Ромашка'},
ignore_index=True)
line_df = line_df.append({'doc_id': 'd001', 'section': 'Генеральный подряд',
'text': 'Срок выполнения работ — 90 дней...',
'embedding': embedder.encode('...')},
ignore_index=True)
2 Парсинг запроса и извлечение anchors
Используем LLM (например, Mixtral 8x22B или Qwen2.5 32B — июнь 2026) для извлечения структурированных anchors. Это дешевле, чем дергать модель несколько раз.
from pydantic import BaseModel
class QueryAnchors(BaseModel):
keywords: list[str]
metadata_filters: dict[str, str | float]
entities: list[dict[str, str]] # name, type
# Промпт к LLM
anchors = llm.extract_anchors("Покажи договоры с ООО Ромашка за май 2026, где сумма выше 10 млн")
#> {'keywords': ['договор', 'ООО Ромашка'],
# 'metadata_filters': {'counterparty': 'ООО Ромашка', 'date': '2026-05', 'amount__gt': 10_000_000},
# 'entities': [{'name': 'ООО Ромашка', 'type': 'organization'}, {'name': '2026-05', 'type': 'date'}]}
3 Фильтрация toc_df + параллельный поиск по line_df
Применяем фильтры к toc_df — получаем подмножество doc_id. Для каждого doc_id запускаем параллельные ретриверы:
filtered_docs = toc_df
filtered_docs = filtered_docs[filtered_docs['counterparty'] == 'ООО Ромашка']
filtered_docs = filtered_docs[filtered_docs['date'].str.startswith('2026-05')]
filtered_docs = filtered_docs[filtered_docs['amount'] > 10_000_000]
doc_ids = filtered_docs['doc_id'].tolist()
# Параллельный поиск
from concurrent.futures import ThreadPoolExecutor
def retrive_chunks(doc_id):
# извлекаем line_df для doc_id
candidates = line_df[line_df['doc_id'] == doc_id]
# BM25 + Embedding параллельно
keyword_scores = bm25.search(anchors['keywords'], candidates)
embedding_scores = embedding_similarity(anchors['keywords'], candidates['embedding'])
# объединяем и сортируем
return combine_scores(candidates, keyword_scores, embedding_scores)
with ThreadPoolExecutor(max_workers=4) as ex:
results = list(ex.map(retrive_chunks, doc_ids))
4 Агрегация и дедупликация
Собираем все чанки, удаляем дубли по doc_id+offset, оставляем топ-N (например, 15). Передаём в один LLM-вызов для reranking и генерации ответа.
from sklearn.feature_extraction.text import TfidfVectorizer
def dedup_and_rerank(candidates_list):
seen = set()
unique = []
for ch in candidates_list:
key = (ch.doc_id, ch.offset)
if key not in seen:
seen.add(key)
unique.append(ch)
# сортируем по скорингу, берём top-15
unique.sort(key=lambda x: x.score, reverse=True)
return unique[:15]
# Один LLM-запрос
context = "\n---\n".join([c.text for c in top_chunks])
response = llm.generate(f"Контекст:\n{context}\n\nОтветь на запрос: {query}", temperature=0.1)
Типичные ошибки — как их не допустить
- Слабая toc_df. Если в оглавлении нет полей, по которым фильтрует пользователь — вся затея провалится. Добавьте автоматическое извлечение ключевых мета-данных через NLP-агента (например, Spacy + LLM).
- Синхронный поиск вместо параллельного. Последовательные запросы к ретриверам убивают latency. Используйте asyncio или ThreadPoolExecutor. В production — Celery или Ray.
- Игнорирование дедупликации. Один и тот же чанк может попасть из keyword и embedding поиска. Передавать дубли в LLM — пустая трата токенов и риск перекоса внимания.
- Смешивание anchors. Не пихайте все anchors в один запрос. Metadata-filter ретривер должен работать только по toc_df, а keyword/embedding — только по line_df. Иначе получите кашу.
- Забыли про ошибки парсинга PDF. Дата может храниться как «01.05.2026» или «1 мая 2026». Приведите к единому формату на этапе индексации, иначе фильтрация сломается.
Когда этот подход не сработает
Если документы полностью неструктурированы (например, отсканированные рукописи без метаданных) — вы не сможете построить toc_df. В таком случае лучше использовать агентный RAG поверх SQL-таблиц (статья «Агентный RAG поверх SQL-таблиц»), где структура выводится из вопросов.
Также подход требует предварительного знания, какие мета-поля будут фильтроваться. Если каждый новый запрос использует уникальные поля — toc_df станет огромной и замедлит фильтрацию. Решение — динамическое расширение toc_df через изменение схемы (ALERT TABLE), но это уже сложнее.
Что дальше?
В следующей статье я покажу, как объединить этот пайплайн с кэшированием на основе графов и семантическим контроль доступа (RLS для RAG). Подпишитесь, чтобы не пропустить. А пока — берите код, адаптируйте под свои данные и смотрите, как точность ответов вырастает с 50% до 85% на ваших кейсах.