跳转至

Retrieval Loop:查到够为止

旅游助手只搜一次,如果 query 不好,答案就完了。用户问"杭州茶文化轻松步行路线",但 query 变成"杭州景点",检索回来的是泛泛的热门景点列表。

Retrieval Loop 不是完整的 Agent。它在 RAG 上加一个小循环:提 query → 检索 → 判断证据够不够 → 够就回答,不够就改 query 再查。

一句话

Retrieval Loop 把"一次检索就回答"变成"query → 检索 → 判断 → 回答或重查",让简单 RAG 能修复弱检索。

它修什么失败

问题 表面看起来 实际风险
只检索一次 query 不好等于答案不好
没有证据充分性检查 有 context 模型可能在弱证据上硬答
检索结果没记录 有引用 难以复现为什么用了这些文档

它引入什么复杂度

负责什么
模型 提 query 和判断 done/not done
检索器 返回片段和 doc id
Python 控制轮次、存命中、写 trace

它比 Agentic RAG 窄:循环里唯一的动作就是检索。

和其他模式的关系

flowchart LR
  RAG["RAG(一次检索)"] -->|"一次不够"| RL["Retrieval Loop"]
  RL -->|"需要多种工具"| ARAG["Agentic RAG"]
  RL -.->|"长报告按章节查"| STM["STORM"]
  • RAG:Retrieval Loop 是 RAG 的升级版。如果一次检索够用,不需要 loop。
  • Agentic RAG:如果除了检索还需要其他工具(天气、订票、计算),就超出了 Retrieval Loop 的范围。
  • STORM:长报告按章节分别检索,是 Retrieval Loop 的结构化应用。

完整实现:旅游信息多轮检索

"""retrieval_loop_travel.py — 查到够为止"""
from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any


# ── 极简运行时 ──────────────────────────────────────────

@dataclass(frozen=True)
class Message:
    role: str
    content: str

class MockLLM:
    def __init__(self, responses: list[str]):
        self._responses = list(responses)
        self._idx = 0
    def complete(self, messages: list[Message]) -> str:
        if self._idx >= len(self._responses):
            raise RuntimeError("MockLLM 回复用完了")
        out = self._responses[self._idx]
        self._idx += 1
        return out

@dataclass(frozen=True)
class Document:
    doc_id: str
    text: str

@dataclass(frozen=True)
class SearchResult:
    doc: Document
    score: int


class SimpleSearchIndex:
    """词频检索。"""
    def __init__(self, docs: list[Document]):
        self._docs = docs
    def search(self, query: str, k: int = 3) -> list[SearchResult]:
        terms = _tokenize(query)
        if not terms:
            return []
        scored = []
        for doc in self._docs:
            doc_tokens = _tokenize(doc.text)
            score = sum(1 for t in terms for d in doc_tokens if t == d)
            if score > 0:
                scored.append(SearchResult(doc=doc, score=score))
        scored.sort(key=lambda r: r.score, reverse=True)
        return scored[:k]


def _tokenize(text: str) -> list[str]:
    tokens, buf = [], ""
    for ch in text.lower():
        if ch.isalnum() or '\u4e00' <= ch <= '\u9fff':
            buf += ch
        else:
            if buf:
                tokens.append(buf)
                buf = ""
    if buf:
        tokens.append(buf)
    return tokens


# ── Retrieval Loop 核心逻辑 ─────────────────────────────

@dataclass(frozen=True)
class RetrievalResult:
    answer: str
    evidence: list[SearchResult]

@dataclass(frozen=True)
class Decision:
    done: bool
    answer: str


def retrieval_loop(
    model: MockLLM,
    *,
    question: str,
    index: SimpleSearchIndex,
    max_rounds: int = 3,
    top_k: int = 3,
) -> RetrievalResult:
    """
    query → 检索 → 判断 → 回答或重查。
    """
    evidence: list[SearchResult] = []
    seen: set[str] = set()
    notes = ""

    for r in range(max_rounds):
        # 模型提 query
        raw_query = model.complete([
            Message(role="system", content="提出下一个检索 query。只返回 JSON。"),
            Message(role="user", content=f"问题:\n{question}\n\n已有笔记:\n{notes}"),
        ])
        query = json.loads(raw_query)["query"]
        print(f"[轮次 {r + 1}] query: {query}")

        # 检索
        results = index.search(query, k=top_k)
        for res in results:
            if res.doc.doc_id not in seen:
                seen.add(res.doc.doc_id)
                evidence.append(res)
        notes = "\n".join(
            f"- ({res.doc.doc_id}, score={res.score}) {res.doc.text[:80]}"
            for res in evidence
        )
        print(f"  命中 {len(results)} 条,累计证据 {len(evidence)} 条")

        # 判断够不够
        raw_decision = model.complete([
            Message(role="system", content="判断证据是否充分。只返回 JSON。"),
            Message(role="user", content=f"问题:\n{question}\n\n证据:\n{notes}"),
        ])
        dec = json.loads(raw_decision)
        print(f"  done={dec['done']}")

        if dec["done"]:
            return RetrievalResult(answer=dec["answer"], evidence=evidence)

    # 到上限,用最后的证据硬答
    fallback = model.complete([
        Message(role="system", content="用已有证据回答。"),
        Message(role="user", content=f"问题:\n{question}\n\n证据:\n{notes}"),
    ])
    return RetrievalResult(answer=fallback, evidence=evidence)


# ── 运行 ────────────────────────────────────────────────

def main() -> None:
    docs = [
        Document("tea_museum", "中国茶叶博物馆,龙井路 88 号,9:00-17:00,周一闭馆,免费。"),
        Document("tea_village", "龙井村产龙井茶,可体验采茶,步行友好,约 2 小时游览。"),
        Document("silk_museum", "中国丝绸博物馆,玉皇山路 73 号,室内展馆,免费,适合雨天。"),
        Document("west_lake", "西湖全天开放,免费,环湖步行约 3 小时。"),
    ]
    index = SimpleSearchIndex(docs)

    model = MockLLM([
        # 轮次 1:query 太宽泛
        '{"query":"杭州景点"}',
        # 轮次 1:不够,缺茶文化和步行信息
        '{"done": false, "answer": ""}',
        # 轮次 2:更具体的 query
        '{"query":"杭州茶文化步行"}',
        # 轮次 2:够了
        json.dumps({
            "done": True,
            "answer": (
                "推荐路线:上午龙井村采茶体验(步行友好,约 2 小时)→ "
                "中午茶叶博物馆(免费,9:00-17:00)。"
                "如下雨可转丝绸博物馆。[tea_village] [tea_museum] [silk_museum]"
            )
        }, ensure_ascii=False),
    ])

    result = retrieval_loop(model, question="杭州茶文化轻松步行路线", index=index, max_rounds=3)
    print(f"\n回答:\n{result.answer}")
    print(f"证据来源:{[r.doc.doc_id for r in result.evidence]}")


if __name__ == "__main__":
    main()

运行日志

[轮次 1] query: 杭州景点
  命中 3 条,累计证据 3 条
  done=False
[轮次 2] query: 杭州茶文化步行
  命中 2 条,累计证据 4 条
  done=True

回答:
推荐路线:上午龙井村采茶体验(步行友好,约 2 小时)→ 中午茶叶博物馆(免费,9:00-17:00)。如下雨可转丝绸博物馆。[tea_village] [tea_museum] [silk_museum]
证据来源:['tea_museum', 'west_lake', 'tea_village', 'silk_museum']

第一轮 query 太宽泛,模型判断证据不够,第二轮换了更具体的 query,拿到茶文化相关的文档后回答。

调试:trace 里看什么

要看的 怎么看 说明什么
每轮 query trace 里的 query 字段 query 没变说明模型没学会改写
每轮新增证据 added 连续两轮 0 新增 = 停滞
done 判断 第几轮 done=true 太早 done 说明充分性检查太松
最终证据列表 evidence 的 doc_id 如果答案引用了不在列表里的来源 = 幻觉

工程笔记

成本公式

每轮 = 1(提 query)+ 1 次检索 + 1(判断)= 2 次模型调用 + 1 次检索
总调用 = 轮次 × 2
最好 = 2(第 1 轮就够)
最坏 = max_rounds × 2 + 1(fallback)

常见坑

现象 修法
query 原地打转 多轮 query 几乎一样 加停滞检测(连续 0 新增就停)
假引用 引用了不相关的文档 强制答案只从命中文档生成
context 膨胀 每轮所有结果都追加 只保留 top-k 和摘要
query 太宽泛 只拿到通用文档 要求模型输出具体的 query 字段
永远不 done 充分性检查太严 加轮次上限 + fallback

什么时候用

  • 一次检索经常不够。
  • query 需要改写。
  • 你想 trace 每轮检索。
  • 完整的 Agent loop 太重。

什么时候别用

  • 一次检索就够。
  • 检索质量差,多查只加噪音。
  • 需要多种工具,那是 Agentic RAG 或 ReAct。
  • 没有轮次上限。

读完以后

Retrieval Loop 是 RAG 里的小循环。

如果检索只是多种工具之一,读 Agentic RAG。 如果要写长报告按章节检索,读 STORM。 如果一次检索就够,停在 RAG