Enterprise RAG: фильтрация по двум таблицам и anchor detection | Гайд 2026 | AiManual
AiManual Logo Ai / Manual.
28 Июн 2026 Гайд

Новый подход к Enterprise RAG: фильтрация по двум таблицам и параллельное детектирование anchors

Как объединить фильтрацию по line_df/toc_df и параллельный anchor detection в RAG. Пошаговый pipeline, код, нюансы продакшна.

Реклама
cliv2

В 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). Имеет эмбеддинг и позиционный индекс.

Затем на этапе инференса запускаем параллельный пайплайн:

  1. Анализ запроса — LLM (например, Mistral Large 2.5, июнь 2026) генерирует JSON с anchors: ключевые слова, мета-фильтры, сущности.
  2. Фильтрация toc_df — по мета-фильтрам отбираем подмножество документов (SQL или pandas query).
  3. Параллельный поиск по line_df — для отобранных doc_id запускаем одновременно keyword search (BM25 из Tantivy) и embedding search (например, Cohere embed v3).
  4. Агрегация — собираем топ-k чанков, дедуплицируем (по doc_id+offset).
  5. Один 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'}]}
💡
Важно: мета-фильтры должны совпадать с именами колонок в toc_df. Используйте маппинг, если названия различаются.

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% на ваших кейсах.

Подписаться на канал