Когда одного поиска недостаточно

Большинство вопросов в production-системах — простые и однозначные: «что такое X», «как сделать Y». Один поиск находит релевантный чанк, LLM отвечает. Всё работает.

Но часть вопросов составная: ответ требует информации из нескольких мест, или ответ на одну часть вопроса зависит от ответа на другую. Для таких запросов один поиск принципиально недостаточен.

Составной вопрос
«Какой timeout выставить для Redis в production и как обработать ConnectionError в Python-приложении?»
Содержит два независимых подвопроса: (1) рекомендуемый timeout, (2) обработка ошибок подключения.
Цепочка зависимостей
«Чем отличается тариф Enterprise от Business и есть ли скидка при оплате на год?»
Нужно сначала найти сравнение тарифов, потом — политику годовой оплаты. Второй поиск осмысленен только после первого.
Неполный первый ответ
«Как мигрировать данные между двумя векторными БД без даунтайма?»
Первый поиск находит общий migration guide. Но нет конкретики про dual-write паттерн и rollback-стратегию — нужен второй целевой поиск.

Одноразовый retrieval ищет по одному усреднённому embedding всего вопроса. При составных вопросах это embedding «тянет в разные стороны» — и не попадает точно ни в один из нужных чанков. Multi-step retrieval разбивает задачу: каждый шаг ищет по одному конкретному аспекту.

Архитектура: итеративный цикл с накоплением контекста

Ключевое отличие от одноразового retrieval — контекст накапливается, а не заменяется. Каждый шаг добавляет новые чанки к уже найденным. Агент видит полную картину после каждой итерации и решает: достаточно ли для ответа, или нужно искать ещё.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ИТЕРАТИВНЫЙ ЦИКЛ — КОНТЕКСТ НАКАПЛИВАЕТСЯ AGENT LOOP (max 3 steps) 1 2 3 «Redis timeout error» первоначальный вопрос Retrieve top-4 chunks ctx = [c1, c2, c3] · 3 чанка общая инфо, нет примеров нет → ↻ уточняем нет конкретных значений «Redis socket_timeout connect_timeout ms» Retrieve top-4 chunks ctx = [c1..c5] · 5 чанков есть значения, нет обработки ошибок нет → ↻ уточняем нет примера ConnectionError «Redis ConnectionError retry Python asyncio» Retrieve top-4 chunks ctx = [c1..c7] · 7 чанков есть timeout + retry с кодом ✓ достаточно score ≥ threshold → стоп LLM + 7 чанков generate answer Полный ответ timeout + retry пример кода Накопленный контекст [c1, c2, …, c7]

Три ключевых свойства, которые отличают multi-step от обычного retrieval:

  • Контекст накапливается, не заменяется — каждый шаг добавляет к общему пулу
  • Запрос уточняется на основе того, что уже нашлось и чего не хватает
  • Агент сам решает когда остановиться — по достаточности информации, не по счётчику шагов

Три подхода к multi-step retrieval

ReAct
Reason → Act → Observe
Агент явно рассуждает перед каждым поиском: зачем ищу, что ожидаю найти. После получения результатов — снова рассуждает: нашёл ли нужное, что ещё нужно.
Лучше всего: открытые исследовательские вопросы
Decompose
Декомпозиция на подвопросы
Сначала LLM разбивает вопрос на N подвопросов (до начала поиска). Каждый подвопрос ищется последовательно — результат предыдущего уточняет запрос следующего.
Лучше всего: составные вопросы с явными частями
Gap Analysis
Анализ пробелов
После каждого поиска LLM оценивает: какой информации не хватает? На основе этого формулирует целевой следующий запрос. Нет предварительного плана — только реактивное уточнение.
Лучше всего: технические вопросы с неизвестной глубиной

ReAct: думать — искать — наблюдать

ReAct (Reasoning + Acting) — паттерн, предложенный в работе Yao et al. (2022). Идея простая: перед каждым действием агент явно формулирует свои мысли. Это заставляет LLM рассуждать последовательно, а не прыгать к ответу напрямую.

Пример трассировки ReAct

Вопрос: «Как настроить connection pool для Redis в FastAPI и обработать таймауты?»

💭
Thought 1
Вопрос составной: нужна информация о connection pool и обработке таймаутов. Начну с общего поиска по connection pool в Redis + FastAPI.
🔍
Action 1
search_docs("Redis connection pool FastAPI configuration")
👁
Observation 1
Найдено 3 чанка: описание aioredis.ConnectionPool, параметры max_connections, пример инициализации при старте приложения. Нет информации о таймаутах.
💭
Thought 2
Нашёл connection pool, но не нашёл про таймауты. Нужно искать отдельно: socket_timeout и connect_timeout у aioredis.
🔍
Action 2
search_docs("aioredis socket_timeout connect_timeout parameters")
👁
Observation 2
Найдено 4 чанка: параметры timeout в aioredis.from_url(), рекомендуемые значения (socket_timeout=5, connect_timeout=3), но нет примера обработки TimeoutError.
💭
Thought 3
Есть конфигурация, есть значения. Не хватает паттерна обработки ошибок — как поймать TimeoutError и что делать (retry, fallback, логировать).
🔍
Action 3
search_docs("Redis TimeoutError ConnectionError retry backoff Python")
👁
Observation 3
Найдено 3 чанка: retry с exponential backoff через tenacity, паттерн circuit breaker для Redis, пример FastAPI middleware для ошибок подключения.
Final Answer
Теперь у меня есть: конфигурация connection pool, параметры таймаутов и паттерн обработки ошибок. Генерирую полный ответ.

Реализация ReAct через tool calling

python — ReAct через tool calling
import json
import asyncio
from openai import OpenAI

client = OpenAI()

REACT_SYSTEM = """You are a research assistant using the ReAct pattern.

For each step:
1. THINK: reason about what information you need next and why
2. SEARCH: call search_docs with a specific, focused query
3. OBSERVE: analyze what you found and what's still missing

Rules:
- Use one focused search per step (not a broad query)
- Refine your query based on what you found previously
- Stop when you have complete information to answer the question
- Maximum 4 search steps

Answer in the same language as the question."""

SEARCH_TOOL = {
    "type": "function",
    "function": {
        "name": "search_docs",
        "description": "Search the knowledge base. Use focused, specific queries. Refine based on previous results.",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Specific search query. Be precise, not broad.",
                },
                "reason": {
                    "type": "string",
                    "description": "Why you're searching this — what info you expect to find",
                },
            },
            "required": ["query", "reason"],
        },
    },
}


async def react_retrieve(question: str, retriever, max_steps: int = 4) -> tuple[str, list]:
    """
    ReAct loop: возвращает (ответ, список всех найденных чанков).
    """
    messages = [
        {"role": "system", "content": REACT_SYSTEM},
        {"role": "user", "content": question},
    ]

    all_chunks = []
    step = 0

    while step < max_steps:
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=[SEARCH_TOOL],
            tool_choice="auto",
            temperature=0.1,
        )
        msg = response.choices[0].message

        # Если нет вызовов инструментов — агент решил, что данных достаточно
        if not msg.tool_calls:
            return msg.content, all_chunks

        # Выполняем поиск
        messages.append(msg.model_dump(exclude_unset=True))

        for tc in msg.tool_calls:
            args = json.loads(tc.function.arguments)
            query = args["query"]

            # Поиск с дедупликацией
            new_chunks = retriever.search(query, top_k=4)
            existing_ids = {c.metadata.get("chunk_id") for c in all_chunks}
            novel_chunks = [c for c in new_chunks if c.metadata.get("chunk_id") not in existing_ids]
            all_chunks.extend(novel_chunks)

            # Формируем observation для агента
            observation = _format_observation(novel_chunks, step + 1)
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": observation,
            })

        step += 1

    # Достигли лимита — генерируем ответ с накопленным контекстом
    context = "\n\n---\n\n".join(c.page_content for c in all_chunks)
    messages.append({
        "role": "user",
        "content": f"Maximum steps reached. Generate final answer using context:\n{context}"
    })
    final = client.chat.completions.create(
        model="gpt-4o", messages=messages, temperature=0.1
    )
    return final.choices[0].message.content, all_chunks


def _format_observation(chunks: list, step: int) -> str:
    if not chunks:
        return f"Step {step}: No new relevant chunks found."
    parts = [f"Step {step}: Found {len(chunks)} new chunks:\n"]
    for i, chunk in enumerate(chunks[:3], 1):
        preview = chunk.page_content[:200].replace("\n", " ")
        source = chunk.metadata.get("source", "unknown")
        parts.append(f"[{i}] ({source}) {preview}...")
    return "\n".join(parts)
Зачем передавать reason в инструмент? Поле reason не используется в поиске, но заставляет LLM явно сформулировать цель запроса перед его выполнением. Это улучшает качество самого запроса — LLM не может написать «что ищу» без понимания «зачем». Inspect этого поля в логах даёт хорошую диагностику.

Декомпозиция на подвопросы

Иногда структура вопроса известна заранее: «расскажи про A и B» явно содержит два аспекта. Вместо того чтобы ждать и реагировать (как в ReAct), агент сначала строит план — разбивает вопрос на последовательные подвопросы — и затем выполняет их по порядку.

Ключевая идея: более поздние подвопросы могут ссылаться на результаты ранних. Это создаёт цепочку зависимостей — как в SQL JOIN: сначала получаем один результат, потом используем его для следующего запроса.

Пример декомпозиции

Вопрос: «Чем отличается тариф Enterprise от Business и какую скидку дают при оплате за год?»

1
search("тариф Enterprise возможности и лимиты")
→ нашли: Enterprise: SSO, SLA 99.99%, API rate 10k/min, выделенный менеджер
Базовый факт — что входит в Enterprise. Нужен для шага 2 (сравнения).
2
search("тариф Business возможности и лимиты")
→ нашли: Business: SAML, SLA 99.9%, API rate 1k/min, email-поддержка
Второй факт для сравнения. Можно было спросить вместе с шагом 1, но отдельно — точнее.
3
search("скидка годовая оплата подписка")
→ нашли: Annual billing: 20% скидка для всех тарифов, оплата авансом
Независимый вопрос про скидки. Не зависит от шагов 1-2, но нужен для полного ответа.
python — декомпозиция и последовательный поиск
from dataclasses import dataclass

DECOMPOSE_PROMPT = """Break this question into sequential sub-queries for a knowledge base.

Rules:
- Each sub-query should focus on ONE specific aspect
- If sub-query N depends on result of sub-query M, note it
- Use concise search phrases, not full sentences
- Maximum 4 sub-queries
- Order them so dependencies come first

Question: {question}

Return JSON array:
[
  {{"step": 1, "query": "...", "purpose": "what info this gets", "depends_on": null}},
  {{"step": 2, "query": "...", "purpose": "...", "depends_on": 1}}
]"""


@dataclass
class SubQuery:
    step: int
    query: str
    purpose: str
    depends_on: int | None = None


def decompose_question(question: str) -> list[SubQuery]:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": DECOMPOSE_PROMPT.format(question=question)}],
        response_format={"type": "json_object"},
        temperature=0,
    )
    data = json.loads(resp.choices[0].message.content)
    items = data if isinstance(data, list) else list(data.values())[0]
    return [SubQuery(**item) for item in items]


ENRICH_PROMPT = """Enrich this search query using the context from previous search steps.
Replace placeholders like {{answer_from_step_N}} with actual data.

Previous results:
{prev_results}

Query to enrich: {query}

Return the enriched search query (just the query string, nothing else):"""


def enrich_query(query: str, prev_results: list[str]) -> str:
    """Подставляет результаты предыдущих шагов в запрос следующего."""
    if "{{" not in query or not prev_results:
        return query  # Нет плейсхолдеров — возвращаем как есть

    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": ENRICH_PROMPT.format(
            prev_results="\n\n".join(f"Step {i+1}: {r}" for i, r in enumerate(prev_results)),
            query=query,
        )}],
        temperature=0,
        max_tokens=60,
    )
    return resp.choices[0].message.content.strip()


async def decomposed_retrieve(question: str, retriever) -> tuple[str, list]:
    """Декомпозирует вопрос и выполняет поиски последовательно."""
    subqueries = decompose_question(question)
    all_chunks = []
    step_results: list[str] = []  # Краткие summary каждого шага для enrich_query

    for sq in sorted(subqueries, key=lambda x: x.step):
        # Обогащаем запрос результатами предыдущих шагов
        final_query = enrich_query(sq.query, step_results)

        # Поиск
        chunks = retriever.search(final_query, top_k=4)
        new_chunks = _deduplicate(chunks, all_chunks)
        all_chunks.extend(new_chunks)

        # Краткий summary для следующих шагов
        if new_chunks:
            summary = " | ".join(c.page_content[:100] for c in new_chunks[:2])
            step_results.append(summary)

    # Генерируем финальный ответ
    context = "\n\n---\n\n".join(c.page_content for c in all_chunks)
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Answer using the provided context."},
            {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
        ],
        temperature=0.1,
    )
    return resp.choices[0].message.content, all_chunks


def _deduplicate(new_chunks: list, existing: list) -> list:
    existing_ids = {c.metadata.get("chunk_id", c.page_content[:50]) for c in existing}
    return [c for c in new_chunks if c.metadata.get("chunk_id", c.page_content[:50]) not in existing_ids]

Gap analysis: уточнение по пробелам

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

python — gap analysis с оценкой полноты
GAP_ANALYSIS_PROMPT = """Analyze if the context is sufficient to fully answer the question.

Question: {question}

Current context ({n_chunks} chunks):
{context_preview}

Evaluate:
1. What aspects of the question ARE covered?
2. What specific information is MISSING?
3. What search query would best find the missing info?
4. Sufficiency score 0-100 (0=useless, 50=partial, 100=complete)

Return JSON:
{{
  "score": int,
  "covered": ["aspect1", "aspect2"],
  "missing": ["missing1", "missing2"],
  "followup_query": "specific search query",
  "sufficient": bool  // true if score >= 75
}}"""


def analyze_gaps(question: str, chunks: list) -> dict:
    """Анализирует что нашли и что ещё нужно."""
    context_preview = "\n\n".join(c.page_content[:300] for c in chunks[:5])

    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": GAP_ANALYSIS_PROMPT.format(
            question=question,
            n_chunks=len(chunks),
            context_preview=context_preview,
        )}],
        response_format={"type": "json_object"},
        temperature=0,
    )
    return json.loads(resp.choices[0].message.content)


async def gap_retrieve(question: str, retriever, max_steps: int = 4) -> tuple[str, list]:
    """Итеративный поиск с gap analysis после каждого шага."""
    all_chunks = []
    current_query = question
    history: list[dict] = []  # История шагов для отладки

    for step in range(max_steps):
        # Поиск
        new_chunks = retriever.search(current_query, top_k=4)
        all_chunks.extend(_deduplicate(new_chunks, all_chunks))

        # Gap analysis
        analysis = analyze_gaps(question, all_chunks)
        history.append({
            "step": step + 1,
            "query": current_query,
            "found": len(new_chunks),
            "score": analysis["score"],
            "missing": analysis["missing"],
        })

        # Достаточно?
        if analysis["sufficient"] or step == max_steps - 1:
            break

        # Уточняем запрос
        current_query = analysis["followup_query"]

    # Генерируем ответ
    context = "\n\n---\n\n".join(c.page_content for c in all_chunks)
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "Answer the question using the provided context. Be specific."},
            {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
        ],
        temperature=0.1,
    )
    return resp.choices[0].message.content, all_chunks, history

Критерии остановки

Без чёткого критерия остановки агент будет искать до лимита шагов на каждый вопрос — расточительно. Три подхода к остановке от простого к умному:

Только счётчик (плохо)
# Всегда делает ровно N шагов
for step in range(3):
  chunks = retriever.search(query)
  all_chunks.extend(chunks)
  # нет остановки по смыслу
Тратит 3 шага на простые вопросы, которым нужен один
Адаптивная остановка
# Стоп при любом из условий
if analysis["score"] >= 75: break
if len(new_chunks) == 0: break
if overlap_ratio(new_chunks, old) > 0.8:
  break # ищем по кругу
if step >= max_steps: break
Останавливается тогда, когда действительно нужно
python — умные критерии остановки
from dataclasses import dataclass, field


@dataclass
class StopCondition:
    max_steps: int = 4
    min_score: float = 0.75    # Gap analysis score threshold
    min_novel_chunks: int = 1  # Минимум новых чанков за шаг
    max_overlap: float = 0.8   # Максимальный overlap с предыдущими результатами


def check_overlap(new_chunks: list, existing: list) -> float:
    """Доля новых чанков, которые уже есть в existing."""
    if not new_chunks:
        return 1.0
    existing_ids = {c.metadata.get("chunk_id", c.page_content[:50]) for c in existing}
    duplicates = sum(
        1 for c in new_chunks
        if c.metadata.get("chunk_id", c.page_content[:50]) in existing_ids
    )
    return duplicates / len(new_chunks)


def should_stop(
    step: int,
    new_chunks: list,
    all_chunks: list,
    analysis: dict,
    cond: StopCondition,
) -> tuple[bool, str]:
    """Возвращает (нужно_ли_остановиться, причина)."""
    # 1. Достигли лимита шагов
    if step >= cond.max_steps:
        return True, f"max_steps={cond.max_steps} reached"

    # 2. LLM считает контекст достаточным
    if analysis.get("score", 0) >= cond.min_score * 100:
        return True, f"sufficient: score={analysis['score']}"

    # 3. Ничего нового не нашли
    novel = [c for c in new_chunks
             if c.metadata.get("chunk_id", c.page_content[:50]) not in
             {e.metadata.get("chunk_id", e.page_content[:50]) for e in all_chunks}]
    if len(novel) < cond.min_novel_chunks:
        return True, "no new relevant chunks found"

    # 4. Ищем по кругу (высокий overlap)
    overlap = check_overlap(new_chunks, all_chunks[:-len(new_chunks)])
    if overlap >= cond.max_overlap:
        return True, f"circular search: overlap={overlap:.0%}"

    return False, ""


# Использование в main loop
stop_cond = StopCondition(max_steps=4, min_score=0.75, min_novel_chunks=1)

for step in range(stop_cond.max_steps):
    new_chunks = retriever.search(current_query)
    all_chunks.extend(new_chunks)
    analysis = analyze_gaps(question, all_chunks)

    stop, reason = should_stop(step + 1, new_chunks, all_chunks, analysis, stop_cond)
    if stop:
        print(f"Stopped at step {step+1}: {reason}")
        break

    current_query = analysis["followup_query"]

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

Собираем все три подхода в один класс с конфигурируемой стратегией. В production удобно выбирать стратегию на уровне запроса — простые вопросы идут через gap analysis (1–2 шага), явно составные — через decompose.

python — MultiStepRAG с выбором стратегии
from enum import StrEnum
from dataclasses import dataclass, field
from typing import Protocol


class Strategy(StrEnum):
    REACT      = "react"       # Reason-Act-Observe loop
    DECOMPOSE  = "decompose"   # Предварительная декомпозиция
    GAP        = "gap"         # Реактивный gap analysis
    AUTO       = "auto"        # Выбор стратегии автоматически


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


@dataclass
class MultiStepConfig:
    strategy: Strategy = Strategy.AUTO
    max_steps: int = 4
    top_k: int = 4
    score_threshold: float = 0.75
    model: str = "gpt-4o-mini"
    reasoning_model: str = "gpt-4o"  # Для финального ответа


@dataclass
class RetrievalResult:
    answer: str
    chunks: list
    steps: int
    strategy: Strategy
    stop_reason: str = ""


class MultiStepRAG:
    def __init__(self, retriever: Retriever, config: MultiStepConfig = None):
        self.retriever = retriever
        self.config = config or MultiStepConfig()

    async def answer(self, question: str, strategy: Strategy = None) -> RetrievalResult:
        strategy = strategy or self.config.strategy

        if strategy == Strategy.AUTO:
            strategy = self._choose_strategy(question)

        if strategy == Strategy.REACT:
            text, chunks = await react_retrieve(
                question, self.retriever, max_steps=self.config.max_steps
            )
        elif strategy == Strategy.DECOMPOSE:
            text, chunks = await decomposed_retrieve(question, self.retriever)
        else:  # GAP (default)
            text, chunks, history = await gap_retrieve(
                question, self.retriever, max_steps=self.config.max_steps
            )

        return RetrievalResult(
            answer=text,
            chunks=chunks,
            steps=len(chunks) // self.config.top_k,
            strategy=strategy,
        )

    def _choose_strategy(self, question: str) -> Strategy:
        """Эвристика выбора стратегии без LLM-вызова."""
        q = question.lower()

        # Явно составной вопрос — decompose
        COMPOSITE_MARKERS = ["и ", " а также", "плюс ", "кроме того", "помимо"]
        QUESTION_WORDS = ["чем отличается", "compare", "vs ", "versus"]
        if any(m in q for m in COMPOSITE_MARKERS + QUESTION_WORDS):
            return Strategy.DECOMPOSE

        # Исследовательский / открытый вопрос — ReAct
        OPEN_MARKERS = ["расскажи подробно", "объясни как", "how does", "explain"]
        if any(m in q for m in OPEN_MARKERS) or len(question.split()) > 20:
            return Strategy.REACT

        # По умолчанию — gap analysis (хорошо для технических вопросов)
        return Strategy.GAP


# Использование
async def main():
    rag = MultiStepRAG(
        retriever=my_vector_db,
        config=MultiStepConfig(max_steps=4, score_threshold=0.75),
    )

    questions = [
        "Как настроить Redis timeout?",                           # GAP (1-2 steps)
        "Чем Enterprise отличается от Business и какие скидки?",  # DECOMPOSE
        "Расскажи подробно про архитектуру нашей системы кэширования",  # REACT
    ]

    for q in questions:
        result = await rag.answer(q)
        print(f"Q: {q}")
        print(f"  Strategy: {result.strategy}, Steps: {result.steps}")
        print(f"  Chunks: {len(result.chunks)}")
        print(f"  Answer: {result.answer[:100]}...")
        print()

Сравнение стратегий

Стратегия LLM-вызовов Задержка Лучше всего Хуже всего
Gap Analysis 1 + N×1 ~300 мс × N Технические вопросы, глубина неизвестна Явно составные вопросы
Decompose 1 (план) + N×0 +200 мс на план Вопросы с явными частями, сравнения Вопросы с неизвестной структурой
ReAct N×1 ~400-600 мс × N Исследовательские, открытые вопросы Простые вопросы (избыточно)
Multi-step = больше токенов. Каждый шаг добавляет контекст в накопленный пул. При N=4 шагах и top-k=4 чанках в промпте LLM может оказаться 16 чанков — это ~8k–16k токенов только на контекст. Используйте contextual compression между шагами, чтобы удалять нерелевантные части найденных чанков до добавления в пул.

Шпаргалка

Выбор стратегии:

  • Gap Analysis — дефолт для большинства случаев, простейшая реализация
  • Decompose — если вопрос содержит «и», «а также», «чем отличается»
  • ReAct — если вопрос открытый: «расскажи о...», «объясни как работает...»

Ключевые правила:

  • Контекст накапливается — дедуплицируйте чанки по chunk_id
  • Уточняйте запрос, не расширяйте — «Redis socket_timeout ms» лучше «Redis всё о таймаутах»
  • Используйте reason в tool-вызовах — заставляет LLM думать перед поиском
  • Проверяйте overlap: если ≥ 80% чанков повторяются — ищете по кругу, пора остановиться
  • Gap score ≥ 75 — хороший порог для остановки
  • max_steps=4 разумный лимит; для документооборота может быть 6–8

Минимальный gap-analysis loop:

python
all_chunks, query = [], question
for step in range(4):
    new = retriever.search(query, top_k=4)
    all_chunks.extend(_deduplicate(new, all_chunks))
    analysis = analyze_gaps(question, all_chunks)
    if analysis["sufficient"]: break
    query = analysis["followup_query"]
answer = generate(question, all_chunks)

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

  1. Gap Analysis на реальных вопросах. Возьмите 10 вопросов из вашей базы. Для каждого запустите gap_retrieve с логированием шагов. Сколько шагов требуется в среднем? На каких вопросах агент делает 3+ шагов и почему? Попробуйте снизить порог min_score с 75 до 60 — как это меняет количество шагов и качество ответов?
  2. ReAct trace анализ. Возьмите сложный технический вопрос, требующий 3+ шагов. Запустите ReAct и залогируйте все reason поля каждого tool-вызова. Оцените: агент ищет по нарастающей конкретности или хаотично? Улучшите REACT_SYSTEM промпт, чтобы каждый следующий запрос был точнее предыдущего.
  3. Deduplicate vs накопление. Реализуйте два варианта: (a) чанки накапливаются с дедупликацией, (b) каждый шаг заменяет предыдущие чанки. Запустите оценку через RAGAS для обоих вариантов на 20 вопросах. Сравните faithfulness и context_recall. Когда накопление помогает, а когда мешает?