RAG в режиме запроса при десяти миллионах документов: воронки, маршрутизаторы, агенты и оценки
RAG с десятью миллионами документов: механизм поиска с временем отклика менее 200 мс, включающий фильтры SQL, гибридный поиск и переранжирование, а также маршрутизацию запросов, оценку результатов и скрипт Qdrant.
Источники: Scaling RAG to 10 Million Documents Part 2: Optimizing retrieval and generation
Загрузка десяти миллионов документов в векторную базу данных — это лишь половина работы. В первой части этой темы рассматривались процессы ввода данных: маршрутизаторы документов вроде Docling, Unstructured и Apache Tika, разбиение на части с учетом структуры, индексация с использованием MinHash и концепция двойного хранилища, позволяющая индексировать реальные файлы без повреждения данных и чрезмерного увеличения затрат.
Вторая часть посвящена времени выполнения запросов. Миллионы встроенных фрагментов данных бесполезны, если поиск занимает восемь секунд, возникают проблемы с определенными кодами ошибок, утечка конфиденциальных данных о заработной плате или обнаружение внедренных угроз в старых файлах. Далее рассматривается механизм поиска, обеспечивающий ответ за менее чем 200 мс, умная сортировка запросов, оркестрация действий, непрерывная оценка эффективности и полный набор скриптов.
1. Многоэтапный механизм поиска
В типичных учебниках рекомендуется включить запрос пользователя и выполнить поиск по схожести с параметром top_k=5 во всей базе данных. При наличии десяти миллионов документов это приводит к двум серьезным проблемам.
- Плотные эмбеддинги хорошо передают абстрактный смысл, но не справляются с точными ключевыми словами, алфавитно-цифровыми номерами деталей и кодами ошибок. Поиск по
Error 504 Gateway Timeoutможет вернуть общие материалы о проблемах с временем ожидания, а не конкретную страницу с решением проблемы. - Оценка каждого кандидата с использованием сложной модели релевантности слишком медленна для интерактивного использования.
Поэтому в производственных системах процесс поиска рассматривается как конус, постепенно сужающий список кандидатов:
[ 10,000,000 Total Document Chunks ]
│
▼
[ Step 1: SQL Pre-Filter ] ────────► Filter by Tenant / Dept / Role / Region
│
▼
[ ~50,000 Candidates ]
│
▼
[ Step 2: Hybrid Search ] ─────────► Dense Vectors (Qdrant) + Sparse BM25
│
▼
[ Top 100 Candidates ]
│
▼
[ Step 3: Cross-Encoder ] ───────► Cohere Rerank / BGE-Reranker
│
▼
[ Final Top 5 Chunks ] ──────────► Passed to LLM Context Window
Этап 1: предварительная фильтрация на основе связей
Прежде чем применять какие-либо высокомерные математические методы, фильтры метаданных, основанные на роли пользователя, отделе или статусе документа, сужают пространство поиска. Сокращение количества элементов с десяти миллионов до примерно 50 000 позволяет уменьшить задержку и одновременно обеспечить контроль доступа:
from qdrant_client.models import Filter, FieldCondition, MatchValue
# Restrict search space by user session permissions before distance scoring
user_access_filter = Filter(
must=[
FieldCondition(key="department", match=MatchValue(value="Engineering")),
FieldCondition(key="is_active", match=MatchValue(value=True))
]
)
Этап 2: гибридный поиск
Реальные поисковые операции существуют в двух взаимодополняющих формах, и важно понимать обе из них перед тем, как рассматривать алгоритмы их слияния.
Плотный или семантический поиск преобразует текст в векторные представления в векторной базе данных, располагая слова с похожим значением рядом друг с другом. Он хорошо справляется с парафразами, синонимами и разговорной формулировкой, однако может пропустить точный ключевой слово и вернуть документы, которые лишь разделяют общую концепцию.
Редкий или поисковый по ключевым словам, использующий алгоритмы вроде BM25 или более старый TF-IDF, оценивает термин в зависимости от того, как часто он встречается в документе по сравнению с его редкостью в всем корпусе. Он надежно находит точные слова, но не учитывает их значение.
Привлекательным решением кажется запуск обоих алгоритмов и суммирование их результатов, и именно здесь на сцену выходит метод слияния рангов по принципу взаимности. RRF объединяет несколько ранжированных списков в один единый список без необходимости нормализации исходных оценок. Документ, занимающий высокие позиции в обоих ранжированиях, оказывается в верхней части объединенного списка.
Этап 3: переранжирование с использованием кросс-кодера
Гибридный поиск быстро выдает примерно сто кандидатов, но сортировка их исключительно по векторному расстоянию может привести к появлению нерелевантных результатов. Для решения этой проблемы существуют два способа кодирования.
Обычный векторный поиск основан на двухкодировщике. При обработке документы преобразуются в векторы, а запрос пользователя — в еще один вектор в момент поиска; затем для сравнения используются простые геометрические метрики, такие как косинусное сходство или скалярное произведение. Этот метод быстр, но обеспечивает лишь умеренную точность.
Кросс-энкодер считывает запрос и документ одновременно и выдает оценку того, насколько данный документ релевантен этому запросу. Он работает тщательно, поэтому медленно — оценка миллиона документов для одного поиска занимает часы. Решением является их объединение. Би-энкодер находит 100 лучших кандидатов, после чего кросс-энкодер переупорядочивает их, оставляя только десять лучших, что занимает менее 100 мс.
2. Условный маршрутизатор запросов
Большинство интерфейсов чата представляют собой простое текстовое поле, поэтому пользователи могут вводить что угодно. Такие сообщения, как «подготовьте краткое резюме нашего разговора» или «переведите результаты на немецкий», совсем не требуют поиска документов, и их обработка по полной схеме тратит время и деньги.
Условный маршрутизатор располагается на входе в качестве быстрого классификатора, который определяет, куда направить каждое сообщение:
User Query
│
▼
[ Intent Classifier / Router ]
│
├── Simple Math / Logic ──────────► Direct Calculator / Python REPL
├── Conversational / Follow-up ───► Direct LLM Memory Context
└── Domain Knowledge Request ─────► Full Hybrid RAG Pipeline
Концептуально небольшой промпт просит модель классифицировать запрос на один из нескольких вариантов обработки, таких как поиск во внутренней документации или прямой ответ:
# Conceptual Router Pattern
def route_query(user_query: str) -> str:
prompt = f"""Classify the user query into one of these routes:
- RETRIEVE: Needs internal company documentation/database lookup.
- COMPUTE: Pure math, calculation, or logic.
- DIRECT: Conversational, greetings, or basic language rewrites.
Query: {user_query}
Classification:"""
# Run a fast, lightweight classifier (or small local SLM)
decision = fast_classifier(prompt).strip()
return decision
3. Более чем простой поиск: оркестрация и обратная связь
Многие реальные вопросы требуют нескольких этапов обработки информации. Возьмём, к примеру, запрос на сравнение показателей продаж в регионе APAC за 2025 год с руководящими принципами соблюдения норм за 2024 год с выявлением несоответствий. Этот запрос делится на три подзадачи: поиск и извлечение данных о продажах в регионе APAC за 2025 год, получение руководящих принципов соблюдения норм за 2024 год и выявление конфликтов между ними.
Система, работающая в один проход, терпит неудачу в таких случаях, поскольку пытается ответить на всё за один запрос. Агентные системы типа RAG решают эту проблему с помощью фреймворков оркестрации, таких как LangGraph или CrewAI, которые делят задачу на скоординированные специализированные действия. Планировщик может создать несколько агентов, каждый из которых использует ту же самую систему поиска для сбора необходимых данных:
[ Orchestrator / Planner ]
│
┌─────────────────┴─────────────────┐
▼ ▼
[ Researcher Agent ] [ Compliance Agent ]
• Retrieves 2025 Sales Data • Retrieves 2024 Regulations
• Extracts regional tables • Parses policy constraints
│ │
└─────────────────┬─────────────────┘
▼
[ Synthesis Agent ]
• Reconciles numbers
• Validates output consistency
│
Confidence Score Check
│ │
[ Low Confidence ] [ High Confidence ]
│ │
▼ ▼
Loop back & refine Final Guardrail Validation
Самокоррекция
Когда механизм поиска возвращает фрагменты с низкими показателями сходства или когда агент-синтезатор обнаруживает противоречие, он должен реагировать. Он отклоняет некачественный текст, переписывает запрос, расширяя сокращения или используя синонимы, и снова запрашивает информацию у движка поиска, вместо того чтобы давать выдуманный универсальный ответ. Агенты координируют действия и принимают решения, но без надежной инфраструктуры RAG у них нет ничего, с чем можно было бы работать.
4. Постоянная оценка
Промышленной системе необходима постоянная оценка с помощью автоматизированных фреймворков, таких как Ragas, TruLens или DeepEval. Они учитывают несколько критериев:
┌──► Faithfulness (Is the answer grounded in the retrieved chunks?)
RAG Evaluation ─┼──► Answer Relevance (Did it actually answer the user's prompt?)
├──► Context Recall (Did retrieval find all necessary reference chunks?)
└──► System Latency & Token Cost (Is it cost-effective at scale?)
Помимо операционных показателей, существует несколько индикаторов уровня обслуживания, позволяющих оценить эффективность процесса извлечения информации:
- Степень соответствия — это доля утверждений в ответе, которые подтверждаются извлеченным контекстом; это служит проверкой на галлюцинации.
- Точность контекста — это доля извлеченных фрагментов среди всех тех, что были указаны в запросе, которые действительно релевантны.
- Полнота воспроизведения контекста — это проверка на то, были ли извлечены все факты, необходимые для ответа на вопрос.
- Задержка, как в целом, так и на каждом этапе, включает время обработки маршрутизатором, фильтром, гибридным поиском, ранжировщиком и генерацией ответа.
Каждый из этих индикаторов должен иметь целевое значение, чтобы можно было контролировать качество и производительность.
5. Полный скрипт извлечения и генерации
Приведённый ниже скрипт на Python отвечает за этап выполнения. Он выполняет предварительную фильтрацию метаданных, загружает векторы из Qdrant, собирает контекст и вызывает локальный сервер LM Studio для выполнения инференса:
from openai import OpenAI
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
# 1. Initialize Clients
# Pointing to local LM Studio running on port 8080
ai_client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="lm-studio")
qdrant_client = QdrantClient(url="http://localhost:6333")
COLLECTION_NAME = "enterprise_knowledge_base"
EMBEDDING_MODEL = "nomic-ai/nomic-embed-text-v1.5"
def retrieve_and_generate(user_query: str, user_department: str) -> str:
print(f"\n🔍 Processing query: \"{user_query}\" for department: [{user_department}]")
# 2. Vectorize the User Query
query_resp = ai_client.embeddings.create(
input=[user_query],
model=EMBEDDING_MODEL
)
query_vector = query_resp.data[0].embedding
# 3. Stage 1 & 2: SQL Pre-Filter + Vector Search
# Filter by user department and active document status
access_filter = Filter(
must=[
FieldCondition(key="department", match=MatchValue(value=user_department))
]
)
search_results = qdrant_client.search(
collection_name=COLLECTION_NAME,
query_vector=query_vector,
query_filter=access_filter,
limit=3
)
if not search_results:
return "No relevant or authorized documents found."
# 4. Context Assembly with Breadcrumbs
context_blocks = []
for hit in search_results:
breadcrumb = hit.payload.get("breadcrumb", "General")
text = hit.payload.get("text", "")
context_blocks.append(f"[{breadcrumb}]\n{text}")
full_context = "\n\n---\n\n".join(context_blocks)
# 5. Generation via Local LLM
system_prompt = (
"You are an enterprise technical assistant. "
"Answer the user query strictly using the provided context. "
"If the context does not contain the answer, explicitly state that you do not know.\n\n"
f"Context:\n{full_context}"
)
completion = ai_client.chat.completions.create(
model="local-model",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query}
],
temperature=0.1
)
return completion.choices[0].message.content
# Example Execution
if __name__ == "__main__":
response = retrieve_and_generate(
user_query="How do I enable TLS 1.3 in config.yaml?",
user_department="Engineering"
)
print("\n🤖 Final Answer:\n", response)
Форма архитектуры производства
Переход от прототипа к системе, обслуживающей миллионы корпоративных документов, подразумевает рассмотрение технологии RAG как целостной инженерной дисциплины, а не просто как одного вызова библиотеки. Это можно свести к четырем принципам:
- Чистая обработка данных. Структурирование важнее изощренности, поэтому следует использовать специализированные парсеры, сохранять структуру данных и избегать повторной индексации неизменных файлов с помощью дельта-хэширования.
- Ранняя фильтрация. Вопросы безопасности и реляционные метаданные должны обрабатываться на этапе SQL до начала дорогостоящих векторных вычислений.
- Сочетание стратегий поиска. Необходимо сочетать плотные векторы с поиском по ключевым словам с использованием алгоритма BM25 и дополнительным ранжировщиком на основе кросс-кодирования.
- Защита границ. Необходимо условно направлять запросы, чтобы избежать ненужных поисков, применять ограничения к входным и выходным данным, а также вовлекать людей при выполнении действий с высоким риском.
Связанные материалы
- Проектирование RAG для 100 миллионов документов: шардинг, гибридный поиск, затраты — Что меняется, когда система RAG растет до миллиардов фрагментов: асинхронная обработка данных, маршрутизация, шардинг, гибридный способ поиска, переранжирование результатов, авторизация, кэширование и контроль затрат.
- Как отладить ситуацию, когда система RAG не возвращает ничего — Пошаговый чек-лист для систем RAG, которые не возвращают полезных фрагментов: обработка данных, пороговые значения оценки, фильтры, переписывание запросов, гибридный поиск, переранжирование результатов, альтернативные решения и сбор телеметрии.