Три проблемы стандартного RAG

В production-системе пользователи задают вопросы разного типа: технические вопросы по продукту, вопросы об условиях работы, математику, приветствия, философские рассуждения. Стандартный RAG-пайплайн не делает различий — он механически запускает retrieval на каждый запрос из одной и той же коллекции. Это ломается в трёх местах.

1
Лишний поиск
На запрос «Привет! Как дела?» система идёт в векторную БД и ищет релевантные чанки. Retrieval занимает 100–300 мс, возвращает нерелевантный мусор, LLM игнорирует контекст и всё равно отвечает приветствием. Лишние затраты: время, деньги на embedding, токены в промпте.
2
Не та коллекция
У компании три базы знаний: техническая документация, HR-политики, FAQ по продукту. Вопрос «Сколько дней отпуска?» попадает в технический индекс — нерелевантные чанки, плохой ответ. Один retriever на всё не работает при нескольких коллекциях.
3
Неправильный запрос
Пользователь спрашивает «Что лучше — ваш продукт или Redis?» Стандартный RAG ищет «что лучше ваш продукт или Redis» дословно. Нет переформулировки под коллекцию. Нужно разбить на два поиска: «сравнение с Redis» в technical_docs и «ключевые преимущества продукта» в product_faq.

Agentic RAG решает все три проблемы, добавляя слой принятия решений между вопросом пользователя и механизмом retrieval. Агент анализирует вопрос и принимает три решения.

Архитектура Agentic RAG: три решения

1
Нужен ли поиск?
Классификация намерения: можно ли ответить без обращения к базе знаний? Часть запросов решается прямым ответом LLM.
DIRECT: «Привет», «2+2=?», «что такое async/await»
SEARCH: «Как настроить наш продукт», «отпуск после 3 лет»
2
По какой коллекции?
Маршрутизация к нужной базе знаний. При нескольких коллекциях агент выбирает одну или несколько в зависимости от темы.
technical_docs: API, архитектура, код
hr_policies: отпуск, зарплата, льготы
product_faq: цены, фичи, сравнение
3
Каким запросом искать?
Переформулировка пользовательского вопроса под язык документации. Одна коллекция — один запрос. Несколько коллекций — несколько параллельных запросов.
User: «Есть ли у вас поддержка JWT?»
Query: «JWT authentication token integration»
100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ТРИ СЦЕНАРИЯ AGENTIC RAG Agent Router «Привет! Как дела?» приветствие DIRECT LLM Ответ ← без retrieval 0 мс задержки «Какой отпуск положен?» HR-вопрос → hr_policies hr_policies DB LLM + ctx Ответ «Сравни цены и льготы» multi-source вопрос → faq + hr_policies faq DB hr DB LLM + 2 ctx Ответ ● Сценарий 1: Нет retrieval → 0 LLM calls + 0 DB calls ● Сценарий 2: 1 коллекция → 1 LLM call + 1 DB call ● Сценарий 3: N коллекций → 1 LLM call + N DB calls

Решение 1: нужен ли поиск вообще?

Первый вопрос — стоит ли тратить время на retrieval. Цель не сократить качество, а избежать лишней работы: для приветствий, математики и общих знаний векторный поиск ничего не добавит к ответу.

Категории намерений

DIRECT Прямой ответ LLM
«Привет! Как дела?»
«Переведи слово на английский»
«Что такое рекурсия?»
«Посчитай 15% от 3200»

Реализация: три подхода

Правила и эвристики
# Быстро, предсказуемо, но хрупко
DIRECT_PATTERNS = [
  r'^(привет|здравствуй|добр)',
  r'^\d+\s*[\+\-\*\/]\s*\d+',
  r'^переведи\s',
]

# Нет гибкости: «Добрый день!» ≠ «Привет»
Подходит только как pre-filter для очевидных случаев
LLM-классификатор
# Гибко, точно, но +100–200 мс
prompt = """Classify the intent:
- DIRECT: general knowledge,
  math, greetings, definitions
- SEARCH: product/company-specific
Query: {query}
JSON: {{"intent": "DIRECT"|"SEARCH"}}"""
Точно понимает edge cases: «что нового в v2.1?» → SEARCH
python — классификатор намерения
import re
import json
from enum import StrEnum
from openai import OpenAI

client = OpenAI()


class Intent(StrEnum):
    DIRECT = "direct"
    SEARCH = "search"


# Эвристики для быстрой фильтрации очевидных случаев
DIRECT_PATTERNS = [
    r'^(привет|здравствуй|добрый|добрый день|hi|hello)',
    r'^\s*\d+[\s]*[\+\-\*\/\^][\s]*\d+',           # Математика
    r'^(переведи|перефразируй|исправь|объясни\s+слово)',
    r'^(спасибо|ок|понял|ясно|хорошо)\s*[\.!]?\s*$',
]

INTENT_PROMPT = """Classify this user query. Be precise.

DIRECT — answer from general knowledge, NO company docs needed:
  - Greetings, small talk
  - Basic math or unit conversions
  - Generic CS/programming definitions
  - Language/translation tasks

SEARCH — needs company-specific knowledge base:
  - Product features, pricing, integrations
  - Company policies (HR, legal, ops)
  - Specific versions, release notes, changelogs
  - Support questions about this product

Query: {query}

Return JSON: {{"intent": "DIRECT" | "SEARCH", "reason": "one line"}}"""


def classify_intent(query: str, use_llm_fallback: bool = True) -> Intent:
    # 1. Быстрые правила
    q_lower = query.strip().lower()
    if any(re.match(p, q_lower) for p in DIRECT_PATTERNS):
        return Intent.DIRECT

    if not use_llm_fallback:
        return Intent.SEARCH  # Безопасный дефолт

    # 2. LLM-классификация для неоднозначных случаев
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": INTENT_PROMPT.format(query=query)}],
        response_format={"type": "json_object"},
        temperature=0,
        max_tokens=60,
    )
    result = json.loads(resp.choices[0].message.content)
    return Intent(result["intent"].lower())


# Проверка
print(classify_intent("Привет!"))                       # Intent.DIRECT
print(classify_intent("Что такое async/await?"))        # Intent.DIRECT (общее знание)
print(classify_intent("Поддерживаете ли вы Kafka?"))   # Intent.SEARCH
print(classify_intent("Что нового в версии 2.1?"))     # Intent.SEARCH
Дефолт — всегда SEARCH. Если классификатор не уверен, лучше выполнить лишний поиск, чем пропустить нужный. False positive (ненужный поиск) стоит 100 мс. False negative (пропущенный поиск) стоит качеством ответа.

Решение 2: по какой коллекции искать?

Когда retrieval нужен, агент должен выбрать коллекцию. Типичная боевая система содержит несколько специализированных баз знаний — по каждой теме свой индекс. Роутинг к правильной коллекции критичен: чанки из технических документов не помогут ответить на HR-вопрос.

Пример структуры коллекций

technical_docs
API-документация, руководства по интеграции, архитектурные гайды, changelog
→ api, sdk, endpoint, token, rate-limit, webhook, версия
hr_policies
Правила компании: отпуска, больничные, зарплата, корпоративные льготы, онбординг
→ отпуск, зарплата, льготы, испытательный срок, увольнение
product_faq
Ответы на частые вопросы о продукте: тарифы, функции, сравнение с конкурентами
→ цена, тариф, Enterprise, trial, сравнение, функции

Реализация роутинга

python — embedding-based + LLM роутинг
import numpy as np
from sentence_transformers import SentenceTransformer
from dataclasses import dataclass

embed_model = SentenceTransformer("intfloat/multilingual-e5-large")


@dataclass
class Collection:
    name: str
    description: str           # Для embedding-роутинга
    keywords: list[str]        # Для keyword-роутинга
    embedding: np.ndarray | None = None


# Определяем коллекции с описаниями
COLLECTIONS: list[Collection] = [
    Collection(
        name="technical_docs",
        description="API documentation, SDK integration, webhooks, authentication, rate limits, changelog",
        keywords=["api", "sdk", "endpoint", "интеграция", "токен", "webhook", "ошибка", "код"],
    ),
    Collection(
        name="hr_policies",
        description="vacation days, sick leave, salary, benefits, employment rules, onboarding",
        keywords=["отпуск", "зарплата", "больничный", "льготы", "офис", "испытательный", "увольнение"],
    ),
    Collection(
        name="product_faq",
        description="product features, pricing plans, trial, comparison with competitors, enterprise",
        keywords=["цена", "тариф", "enterprise", "пробный", "функции", "сравнение", "конкурент"],
    ),
]


def _precompute_embeddings():
    """Считаем embeddings описаний один раз при старте."""
    for coll in COLLECTIONS:
        coll.embedding = embed_model.encode(
            coll.description, normalize_embeddings=True
        )


_precompute_embeddings()


def route_by_embedding(query: str, top_k: int = 1) -> list[str]:
    """Embedding-based роутинг: O(n) сравнений, без LLM."""
    q_emb = embed_model.encode(query, normalize_embeddings=True)
    scores = [
        (coll.name, float(np.dot(q_emb, coll.embedding)))
        for coll in COLLECTIONS
    ]
    scores.sort(key=lambda x: x[1], reverse=True)
    return [name for name, _ in scores[:top_k]]


ROUTING_PROMPT = """You have these knowledge bases:
{collections}

Which knowledge bases are needed to answer this query?
Select ALL that apply. If the query spans multiple topics, select multiple.

Query: {query}

Return JSON: {{"collections": ["name1", "name2"], "reason": "brief"}}"""


def route_by_llm(query: str) -> list[str]:
    """LLM-based роутинг: точнее, медленнее (+100-200ms)."""
    coll_desc = "\n".join(
        f"- {c.name}: {c.description}"
        for c in COLLECTIONS
    )
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": ROUTING_PROMPT.format(
            collections=coll_desc, query=query
        )}],
        response_format={"type": "json_object"},
        temperature=0,
        max_tokens=80,
    )
    result = json.loads(resp.choices[0].message.content)
    # Валидируем: возвращаем только существующие коллекции
    valid = {c.name for c in COLLECTIONS}
    return [c for c in result.get("collections", []) if c in valid]


def route_query(query: str, use_llm: bool = True) -> list[str]:
    """Гибридный роутинг: embedding для скорости + LLM для точности."""
    if use_llm:
        return route_by_llm(query)
    return route_by_embedding(query, top_k=1)


# Примеры
print(route_query("Как подключить OAuth?"))     # ['technical_docs']
print(route_query("Отпуск и тарифы — расскажи"))  # ['hr_policies', 'product_faq']
print(route_query("Что нового в v2.1 API?"))    # ['technical_docs']
Когда использовать embedding-роутинг, а когда LLM. Embedding-роутинг работает за ~5 мс и хорошо справляется с однозначными запросами. LLM-роутинг точнее для неоднозначных случаев (вопрос затрагивает несколько тем) и лучше понимает контекст, но добавляет 100–200 мс. Оптимальная стратегия: сначала embedding-роутинг, при low-confidence — LLM уточнение.

Tool-calling подход: агент как RAG-оркестратор

Вместо двух отдельных шагов (классификация → роутинг) можно использовать нативный tool-calling LLM. Каждая коллекция оформляется как инструмент — агент сам решает, какие инструменты вызвать и с какими параметрами. Это самый гибкий подход: агент может вызвать инструменты несколько раз, уточнять запросы, комбинировать результаты.

Как это работает

1
Определение инструментов
Каждая коллекция — отдельный инструмент с описанием:
search_technical_docs: «поиск по API, SDK, интеграциям»
search_hr_policies: «поиск по HR-политикам, отпускам»
search_product_faq: «поиск по тарифам, функциям»
2
Агент решает
LLM получает вопрос + список инструментов и возвращает tool_calls.
«Какой отпуск?»search_hr_policies(query="количество дней отпуска")
«Привет!»нет tool_calls → прямой ответ
«Цены и API?»search_product_faq + search_technical_docs
3
Выполнение + синтез
Инструменты выполняются (параллельно для speed).
Результаты добавляются в messages как tool-сообщения.
LLM делает финальный вызов с контекстом → ответ
python — tool-calling RAG агент
import asyncio
from typing import Any

# Определение инструментов для OpenAI API
RAG_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search_technical_docs",
            "description": (
                "Search technical documentation: API reference, SDK integration guides, "
                "webhooks, authentication, rate limits, error codes, changelogs. "
                "Use for: 'how to integrate', 'API endpoint', 'SDK error', version info."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Search query optimized for technical documentation",
                    }
                },
                "required": ["query"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "search_hr_policies",
            "description": (
                "Search HR policies and company rules: vacation days, sick leave, "
                "salary, benefits, office hours, remote work, onboarding, termination. "
                "Use for: 'how many days', 'can I', 'what is the policy on'."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "HR policy search query"}
                },
                "required": ["query"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "search_product_faq",
            "description": (
                "Search product FAQ: pricing plans, features, trial period, "
                "comparison with competitors, enterprise options, support tiers. "
                "Use for: 'how much', 'pricing', 'does it support', 'compared to'."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Product FAQ search query"}
                },
                "required": ["query"],
            },
        },
    },
]


async def execute_tool(tool_name: str, arguments: dict, collections: dict) -> str:
    """Выполняет поиск в нужной коллекции."""
    collection_map = {
        "search_technical_docs": "technical_docs",
        "search_hr_policies": "hr_policies",
        "search_product_faq": "product_faq",
    }
    coll_name = collection_map[tool_name]
    db = collections[coll_name]
    results = db.search(arguments["query"], top_k=4)
    return "\n\n---\n\n".join(r.page_content for r in results)


SYSTEM_PROMPT = """You are a helpful assistant for our company.
Answer questions using the provided tools to search our knowledge bases.
If a question doesn't require searching (greetings, general knowledge, math),
answer directly without using any tools.
Always answer in the same language as the question."""


async def agentic_rag(question: str, collections: dict) -> str:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": question},
    ]

    # Шаг 1: Первый вызов — агент решает, нужны ли инструменты
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages,
        tools=RAG_TOOLS,
        tool_choice="auto",  # Агент сам решает
        temperature=0.1,
    )
    msg = response.choices[0].message

    # Если инструментов нет — прямой ответ (грит, математика, общее знание)
    if not msg.tool_calls:
        return msg.content

    # Шаг 2: Выполняем все инструменты параллельно
    messages.append(msg.model_dump(exclude_unset=True))

    tool_results = await asyncio.gather(*[
        execute_tool(tc.function.name, json.loads(tc.function.arguments), collections)
        for tc in msg.tool_calls
    ])

    for tc, result in zip(msg.tool_calls, tool_results):
        messages.append({
            "role": "tool",
            "tool_call_id": tc.id,
            "content": result,
        })

    # Шаг 3: Финальный ответ с контекстом
    final = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages,
        temperature=0.1,
    )
    return final.choices[0].message.content
tool_choice="auto" vs "required". При auto агент решает сам — именно это нам нужно для agentic RAG. При required инструмент вызовется всегда, теряя преимущество интеллектуального пропуска. При none — инструменты недоступны (полезно для тестирования прямых ответов).

Решение 3: переформулировка запроса под коллекцию

Пользователь пишет на разговорном языке: «Есть ли у вас нормальная поддержка платёжек?». Документация написана технически: «Payment gateway integration», «PCI DSS compliance». Агент должен переформулировать запрос под язык конкретной коллекции — иначе embedding-поиск не найдёт релевантные чанки.

python — адаптация запроса к коллекции
REWRITE_PROMPT = """Rewrite the user's question as a search query optimized for this knowledge base.

Knowledge base: {collection_name}
Description: {collection_desc}

Rules:
- Use technical/domain-specific terminology from the knowledge base
- Remove conversational filler ("do you have", "is there", "can I")
- Focus on the core concept being asked about
- Keep it concise (5-15 words)

User question: {question}
Search query:"""


@dataclass
class CollectionQuery:
    collection: str
    query: str


def rewrite_for_collection(question: str, collection_name: str) -> str:
    """Переформулирует вопрос под конкретную коллекцию."""
    coll = next(c for c in COLLECTIONS if c.name == collection_name)
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": REWRITE_PROMPT.format(
            collection_name=collection_name,
            collection_desc=coll.description,
            question=question,
        )}],
        temperature=0,
        max_tokens=40,
    )
    return resp.choices[0].message.content.strip()


async def multi_collection_search(
    question: str,
    target_collections: list[str],
    dbs: dict,
) -> list[str]:
    """Параллельный поиск с адаптацией запроса под каждую коллекцию."""
    async def search_one(coll_name: str) -> list[str]:
        # Переформулируем под коллекцию
        query = rewrite_for_collection(question, coll_name)
        results = dbs[coll_name].search(query, top_k=3)
        return [f"[{coll_name}] {r.page_content}" for r in results]

    all_results = await asyncio.gather(*[
        search_one(coll) for coll in target_collections
    ])
    # Плоский список чанков из всех коллекций
    return [chunk for results in all_results for chunk in results]


# Пример
# question = "Есть ли нормальная поддержка платёжек?"
# rewrite_for_collection(question, "technical_docs")
# → "payment gateway integration PCI DSS compliance"

Полный AgenticRAG пайплайн

Собираем все три компонента в единый класс. В production используют один из двух подходов: явный роутинг (classify → route → search) или tool-calling (агент всё решает сам). Первый — предсказуемее и дешевле, второй — гибче для сложных сценариев.

python — AgenticRAG с явным роутингом
from dataclasses import dataclass, field
from typing import Protocol


class VectorDB(Protocol):
    def search(self, query: str, top_k: int = 4) -> list: ...


@dataclass
class AgentConfig:
    model: str = "gpt-4o-mini"
    router_model: str = "gpt-4o-mini"  # Можно дешевле для роутинга
    top_k: int = 4
    temperature: float = 0.1
    use_llm_routing: bool = True


@dataclass
class AgenticRAG:
    dbs: dict[str, VectorDB]
    config: AgentConfig = field(default_factory=AgentConfig)

    SYSTEM_PROMPT = """You are a helpful assistant. Answer questions based on the provided context.
If context is provided, use it. Cite specific information when possible.
If no context is provided, answer from general knowledge.
Answer in the same language as the question."""

    async def answer(self, question: str) -> dict:
        """Полный цикл: classify → route → search → generate."""
        trace = {"question": question}

        # ── Шаг 1: Нужен ли поиск? ──────────────────────
        intent = classify_intent(question)
        trace["intent"] = intent

        if intent == Intent.DIRECT:
            # Прямой ответ без retrieval
            resp = client.chat.completions.create(
                model=self.config.model,
                messages=[
                    {"role": "system", "content": self.SYSTEM_PROMPT},
                    {"role": "user", "content": question},
                ],
                temperature=self.config.temperature,
            )
            return {"answer": resp.choices[0].message.content, **trace, "contexts": []}

        # ── Шаг 2: Роутинг коллекции ─────────────────────
        target_colls = route_query(question, use_llm=self.config.use_llm_routing)
        trace["collections"] = target_colls

        # Оставляем только существующие коллекции
        target_colls = [c for c in target_colls if c in self.dbs]
        if not target_colls:
            target_colls = list(self.dbs.keys())[:1]  # Fallback

        # ── Шаг 3: Параллельный поиск ────────────────────
        contexts = await multi_collection_search(question, target_colls, self.dbs)
        trace["context_chunks"] = len(contexts)

        # ── Шаг 4: Генерация ответа ───────────────────────
        context_text = "\n\n---\n\n".join(contexts[:self.config.top_k * len(target_colls)])
        resp = client.chat.completions.create(
            model=self.config.model,
            messages=[
                {"role": "system", "content": self.SYSTEM_PROMPT},
                {"role": "user", "content": (
                    f"Context:\n{context_text}\n\nQuestion: {question}"
                )},
            ],
            temperature=self.config.temperature,
        )
        return {
            "answer": resp.choices[0].message.content,
            **trace,
            "contexts": contexts,
        }


# Использование
async def main():
    rag = AgenticRAG(
        dbs={
            "technical_docs": technical_db,
            "hr_policies": hr_db,
            "product_faq": faq_db,
        },
        config=AgentConfig(top_k=4, use_llm_routing=True),
    )

    questions = [
        "Привет! Как дела?",                   # DIRECT → 0 DB calls
        "Сколько дней отпуска в год?",          # hr_policies
        "Как настроить OAuth callback URL?",    # technical_docs
        "Цены на Enterprise и SLA гарантии?",  # product_faq
    ]

    for q in questions:
        result = await rag.answer(q)
        print(f"Q: {q}")
        print(f"  Intent: {result['intent']}, Collections: {result.get('collections', [])}")
        print(f"  Answer: {result['answer'][:100]}...")
        print()

Сравнение подходов

ПодходСкоростьТочностьПредсказуемостьКогда
Правила + эвристики ~1 мс Низкая Высокая Pre-filter очевидных случаев
Embedding роутинг ~10 мс Средняя Высокая Однозначные, чёткие коллекции
LLM классификация 100–200 мс Высокая Высокая Неоднозначные, смешанные запросы
Tool-calling агент 200–400 мс Очень высокая Средняя Сложные multi-step сценарии
Рекомендованная стратегия. Комбинируйте: сначала эвристика (~1 мс) для очевидных DIRECT-случаев → если не сработало, embedding-роутинг (~10 мс) → если confidence < 0.7, LLM-классификация. Это даёт оптимальный баланс скорость / точность для большинства production-сценариев.

Шпаргалка

Три решения агента:

  • Intent: нужен ли вообще поиск? → DIRECT или SEARCH
  • Routing: в какую коллекцию? → embedding sim или LLM
  • Rewrite: как переформулировать запрос под язык коллекции?

Быстрый старт с tool-calling:

python — минимальный пример
# Самый простой Agentic RAG: один инструмент, агент решает сам
from openai import OpenAI

client = OpenAI()

def search_docs(query: str) -> str:
    # ваш vector search здесь
    results = vector_db.search(query, top_k=4)
    return "\n---\n".join(r.page_content for r in results)

tools = [{
    "type": "function",
    "function": {
        "name": "search_docs",
        "description": "Search company knowledge base. Use for company-specific questions.",
        "parameters": {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
    },
}]

def answer(question: str) -> str:
    messages = [
        {"role": "system", "content": "Answer using tools when needed. Direct answer for greetings/math."},
        {"role": "user", "content": question},
    ]
    resp = client.chat.completions.create(
        model="gpt-4o-mini", messages=messages, tools=tools, tool_choice="auto"
    )
    msg = resp.choices[0].message
    if not msg.tool_calls:
        return msg.content  # Прямой ответ

    # Выполняем поиск
    context = search_docs(json.loads(msg.tool_calls[0].function.arguments)["query"])
    messages.extend([
        msg.model_dump(exclude_unset=True),
        {"role": "tool", "tool_call_id": msg.tool_calls[0].id, "content": context},
    ])
    return client.chat.completions.create(
        model="gpt-4o-mini", messages=messages
    ).choices[0].message.content

Ключевые принципы:

  • Дефолт — SEARCH: лучше лишний поиск, чем упущенный контекст
  • Описания инструментов важнее кода: LLM routing работает через них
  • Параллельный поиск: asyncio.gather() для нескольких коллекций
  • Переформулировка запроса под коллекцию повышает recall на 15–30%
  • Логируйте intent и collections для отладки и RAGAS-оценки

Практические задания

  1. Intent classifier. Соберите 30 тестовых вопросов: 15 DIRECT (приветствия, математика, общие знания) и 15 SEARCH (product, HR, technical). Запустите classifier с правилами и с LLM. Сравните точность и latency. Какие случаи правила не покрывают?
  2. Routing с двумя коллекциями. Создайте два отдельных Chroma-коллекции (например, техническая документация и FAQ). Напишите embedding-роутер и проверьте: на каких запросах он ошибается? Как изменяется точность при улучшении описаний коллекций?
  3. Tool-calling агент. Реализуйте полный tool-calling RAG с двумя инструментами поиска. Запустите 20 вопросов, залогируйте tool_calls для каждого. Найдите запросы, где агент вызвал «не тот» инструмент — что в описании инструмента надо изменить?