《AI Agent 开发入门》10.2 索引与检索工具

把 10.1 切好的块做成两个工具:search(query) 返回命中块(doc_id、chunk_id、片段),read(doc_id, chunk_id) 取回完整块。两者注册进第 4 章的 ToolRegistry,再用假模型脚本化一条多跳检索轨迹并真跑。本节聚焦「检索作为工具」,检索算法复用第 9 章的关键词 + 哈希向量混合思路。

10.2 索引与检索工具

10.1 把文档切成了 217 个块,但块躺在一个列表里,模型看不见也摸不着。要让 Agent 用上它们,得把"检索"这件事包成工具——模型像调用任何其他工具一样,发出一个 search 请求,拿回几段候选,再决定要不要 read 全文。

本节把 minagent 推进到 minagent/rag/tools.py:把块列表建成索引,包出 search 与 read 两个工具,注册进第 4 章的 ToolRegistry,并跑通一条 search → read → search → read → 综合的多跳检索轨迹。

10.2.1 检索作为工具,而不是检索算法

先划清边界。第 9 章已经讲过本地嵌入、numpy 向量索引、混合检索——那些是检索算法。本节不重复它们,而是问一个不同的问题:怎么把检索能力交给模型,让它自己决定搜什么、读什么、读几段?

这两件事是正交的:

关注点第 9 章(算法)本章(工具)
核心问题怎么算相关度怎么让模型驱动检索
输入query 向量模型发出的 ToolCall
输出排序好的块模型可读的 JSON 字符串
谁决定检索次数代码写死模型,按需多跳
失败模式召回不准模型搜错、读偏、循环不停

把检索做成工具,最大的收益是多跳。单轮 RAG 是"一搜一答",问题稍复杂就答不好;工具化的检索让模型能"先搜 A、读到线索后再搜 B",像人查资料一样逐步逼近答案。本节的重点全在这个"让模型自主检索"的闭环上。

10.2.2 建索引:块列表 → 可检索

10.1 的产物是 list[(doc_id, chunk_id, text)]。检索之前,要先把它变成"能打分的东西"。本节的打分很朴素,只用到两个信号:

  • 关键词分数:query 的词在块里出现了几次(词频)。
  • 哈希向量相似度:把 query 和块各自哈希成一个定长向量,算余弦相似度。

先定义分词。中文没有空格,用字符级 bigram 是廉价而稳的近似;英文则按单词切:

import re

def tokens(text: str) -> list[str]:
    text = text.lower()
    words = [w for w in re.findall(r"[a-z0-9_]+", text) if len(w) >= 2]
    cjk = re.findall(r"[一-鿿]", text)
    bigrams = ["".join(p) for p in zip(cjk, cjk[1:])]
    return words + bigrams

tokens("GIL 全局解释器锁") 会得到 ["gil", "全局", "局解", "解释", "释器", "器锁"]——英文单词加上中文 bigram。bigram 的好处是不需要词典,任何中文串都能切成可比较的单元。

哈希向量把每个 token 映射到固定维度的桶里,是一种不依赖嵌入模型的"穷人的向量":

import hashlib, math

DIM = 256

def vec(text: str) -> list[float]:
    v = [0.0] * DIM
    for t in tokens(text):
        h = int(hashlib.md5(t.encode()).hexdigest(), 16) % DIM
        v[h] += 1.0
    return v

def cosine(a: list[float], b: list[float]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na and nb else 0.0

哈希向量的核心是"同一个 token 永远落进同一个桶",于是词形相近的两段文本会在向量空间里靠近。它抓不住语义(“汽车"和"轿车"不会靠近),但足够让本节演示"混合打分"这件事,且完全确定、零依赖。真正要做语义检索,把 vec() 换成第 9 章的嵌入即可,工具层不用改一行。

本节选关键词 + 哈希向量这条最朴素的路,不是因为它最好,而是因为它完全确定——同样的 query 永远返回同样的排序,第 16 章的轨迹断言才写得出来。三条路的对照:

方案依赖适合
本节:关键词 + 哈希向量零教学、可断言
第 9 章:本地嵌入 + numpynumpy语义检索
生产:向量数据库(pgvector 等)重大规模、持久化

升级检索算法时,工具层的接口(search/read 的签名与返回结构)一行都不用改——这正是把"算法"和"工具"分开的价值。

10.2.3 search(query):混合打分

有了分词和向量,search 就是把两个分数加起来排序:

CHUNKS: list[tuple[str, int, str]] = load_corpus("content/posts/python")
_VECS = [(d, c, vec(t)) for d, c, t in CHUNKS]

def kw_score(qtoks: list[str], text: str) -> float:
    tt = tokens(text)
    return float(sum(tt.count(t) for t in qtoks))

def search(query: str, k: int = 3) -> list[dict]:
    qtoks = tokens(query)
    qv = vec(query)
    scored = []
    for (doc_id, cid, text), (_, _, cv) in zip(CHUNKS, _VECS):
        score = kw_score(qtoks, text) + 5.0 * cosine(qv, cv)
        if score > 0:
            scored.append((score, doc_id, cid, text))
    scored.sort(key=lambda x: (-x[0], x[1], x[2]))
    out = []
    for score, doc_id, cid, text in scored[:k]:
        out.append({"doc_id": doc_id, "chunk_id": cid,
                    "score": round(score, 3),
                    "snippet": text[:60].replace("\n", " ")})
    return out

两个设计点值得说明。第一,关键词分数与向量相似度直接相加,用 5.0 把余弦(值域 0~1)放大到与词频同一量级——这是个粗糙但够用的权重。第二,sort 的 key 是 (-score, doc_id, chunk_id):先按分数降序,分数相同时按 doc_id、chunk_id 升序。加上后两个键是为了让结果完全确定——否则同分块的相对顺序取决于字典遍历顺序,跑两次可能不一样,第 16 章的断言就无从写起。

search 返回的不是块全文,而是 snippet(前 60 字)。这是刻意的:搜索只负责"指路”,不负责"载货"。一屏里塞三段全文,既浪费上下文,又逼模型在一堆长文本里找重点。真要看全文,用下一个工具。

对 10.1 的 6 篇语料真跑一次 search("GIL 是什么", k=3),实测返回:

[{'doc_id': 'python-concurrency-performance', 'chunk_id': 0, 'score': 9.441,
  'snippet': '> 本文假设 Python 3.11+。所有代码可直接复制运行。  ---  ## 1. GIL(全局解释器锁)的真相 '},
 {'doc_id': 'python-concurrency-performance', 'chunk_id': 3, 'score': 6.145,
  'snippet': '0.85s Multi-thread: 1.05s   # 更慢!因为线程切换开销 + GIL 竞争 ```  ### '},
 {'doc_id': 'python-concurrency-performance', 'chunk_id': 1, 'score': 3.778,
  'snippet': '*例外:** 执行 C 扩展(如 NumPy 矩阵运算)时,C 代码可以释放 GIL。  ### 1.2 GIL 对性能'}]

排名第 0 的正是 GIL 定义那块,第 1、2 名也都来自同一篇文章——分别讲"GIL 竞争让多线程更慢"和"GIL 对性能的影响"。前三名同属一篇,说明关键词 GIL 在这篇文章里密度最高,混合打分把整个文档推了上来。这也暴露一个真实问题:同一文档霸榜会让结果缺乏多样性,工程上常用"每文档最多取 N 块"来抑制。

10.2.4 read(doc_id, chunk_id):取回完整块

read 是 search 的配套:拿 doc_id 和 chunk_id 把完整块取回来。

def read(doc_id: str, chunk_id: int) -> str:
    for d, c, t in CHUNKS:
        if d == doc_id and c == chunk_id:
            return t
    return json.dumps({"error": f"no chunk {doc_id}#{chunk_id}"},
                      ensure_ascii=False)

找不到时返回 {"error": ...} 而不是抛异常,这是第 4 章就立下的约定:工具出错要变成模型可读的反馈。模型看到一个 no chunk ... 的错误,会知道刚才的 chunk_id 编错了,可以重新 search 一次——而不是让整个循环崩掉。

search 与 read 的分工,本质是"索引"与"原文"的分工:

工具返回用途上下文成本
search若干块的 60 字片段 + 坐标定位候选低
read一个块的完整 400 字精读证据高

模型先 search 拿到坐标,再对少数几个坐标 read。这套"两段式"是 RAG 工具的标准形态——让廉价的搜索承担试错,让昂贵的读取只花在值得的地方。

10.2.5 注册进 ToolRegistry

把两个函数包成 Tool,加进第 4 章的注册表:

import json
from minagent.tools import Tool, ToolRegistry

def search_tool(query: str, k: int = 3) -> str:
    return json.dumps(search(query, k), ensure_ascii=False)

registry = ToolRegistry()
registry.add(Tool(
    name="search",
    description="在本地文档库中按关键词与语义混合检索,返回命中块(含 doc_id、chunk_id、片段)。",
    parameters={"type": "object",
                "properties": {"query": {"type": "string"},
                               "k": {"type": "integer"}},
                "required": ["query"], "additionalProperties": False},
    func=search_tool))
registry.add(Tool(
    name="read",
    description="按 doc_id 与 chunk_id 取回某个块的完整文本。",
    parameters={"type": "object",
                "properties": {"doc_id": {"type": "string"},
                               "chunk_id": {"type": "integer"}},
                "required": ["doc_id", "chunk_id"], "additionalProperties": False},
    func=read))

注意 search_tool 多包了一层 json.dumps:ToolRegistry.call 对非字符串结果会自动 json.dumps,但这里显式序列化,是为了保证 ensure_ascii=False——中文 doc_id 不被转义成 \uXXXX,模型读起来更省 token。两个工具的 description 都写清了"什么时候用它",这正是第 4 章强调的:工具描述是提示词的一部分。

注册完,registry.schemas() 就把这两条喂给模型。从此模型的"手"里多了检索能力。

10.2.6 真跑一条多跳轨迹

光看代码看不出多跳的价值,得真跑一条。用假模型脚本化一次两跳检索:先搜 GIL、读一块,再搜 asyncio、读一块,最后综合。

script = [
    [ToolCall("t1", "search", {"query": "GIL 全局解释器锁", "k": 2})],
    [ToolCall("t2", "read", {"doc_id": "python-concurrency-performance",
                             "chunk_id": 0})],
    [ToolCall("t3", "search", {"query": "asyncio 事件循环 并发", "k": 2})],
    [ToolCall("t4", "read", {"doc_id": "python-concurrency-performance",
                             "chunk_id": 9})],
    "综合两段材料:GIL 让 CPython 同一时刻只跑一个线程,"
    "因此 CPU 密集用多进程;I/O 密集用 asyncio 事件循环。",
]
messages, steps, final = run(FakeModel(script), registry,
                             "Python 里的并发有哪些方案?GIL 有什么影响?")

真跑(Python 3.14.6)后的轨迹:

[0] tool_call: search({'query': 'GIL 全局解释器锁', 'k': 2}) -> [{"doc_id": "python-concurrency-performance", ...
[1] tool_call: read({'doc_id': 'python-concurrency-performance', 'chunk_id': 0}) -> > 本文假设 Python 3.11+...
[2] tool_call: search({'query': 'asyncio 事件循环 并发', 'k': 2}) -> [{"doc_id": "python-concurrency-performance", ...
[3] tool_call: read({'doc_id': 'python-concurrency-performance', 'chunk_id': 9}) -> o.CancelledError: ...
[4] final: 综合两段材料:GIL 让 CPython 同一时刻只跑一个线程,因此 CPU 密集用多进程;I/O 密集用 asyncio 事件循环。

第一跳 search("GIL 全局解释器锁") 命中了 python-concurrency-performance#0——那正是文章开头讲 GIL 定义的那一块。read 把它完整取回后,模型(脚本)据此发出第二跳 search("asyncio 事件循环 并发"),命中 #9(asyncio 最佳实践那块),再 read,最后综合。

轨迹本身就是证据。把每一步的入参和出参打印出来,就能验证:命中块对不对、read 的坐标是不是来自上一步的 search、模型有没有在拿到材料后才收尾。这正是卷一坚持用假模型的原因——真实模型会把这些决策藏在一次响应里,而假模型把决策摊开成可断言的步骤。

多跳的价值也在这条轨迹里:如果只有单轮 RAG,模型只能拿第一跳的 GIL 材料作答,答不出 asyncio 那半句。有了 search/read 工具,它能把一个问题拆成"先搞清 GIL,再搞清 asyncio"两步,每步都取到真正相关的块。

一个容易忽略的细节:两次 search 返回的候选里,#0 和 #9 都来自同一篇文章。真实检索常常这样——同一文档的多个块命中同一问题。所以 read 的坐标不能只记 chunk_id,必须带 doc_id,否则"第 0 块"这种坐标会在不同文档间串味。

10.2.7 检索预算:别让模型无限搜下去

工具化检索把"搜几次"的控制权交给了模型,好处是灵活,风险是可能停不下来。一个没有约束的 Agent 可能:反复搜同一个词、read 一堆不相关的块、在"再搜一次"里打转。两种兜底手段:

  • 步数上限:第 3 章的 run(..., max_steps=8) 已经给了硬上限,超过就强制收尾。
  • 检索预算:给 search/read 各记一个调用计数,超过阈值就把工具从 schemas() 里摘掉,逼模型用现有材料作答。
约束位置效果
max_steps循环层硬性终止,可能截断
工具计数工具层精确控制检索量
结果去重search 内避免重复块占额度

卷一只做 max_steps 这一条,因为它最简单、最确定。检索预算和去重留到卷二的生产化加固。到这里,10.2 给了 minagent 一双"会查资料的手";但这双手查回来的东西可不可信,还没人验——那是 10.3 的事。

真实模型下会怎样:本节的多跳是脚本写死的——假模型不会自己决定"先搜什么、再搜什么",那四个 ToolCall 是我们预先排好的。真实 Claude 拿到 search/read 两个工具的 schema 后,会自己规划检索路径:先搜一个宽泛的词,看到片段后判断"还缺什么",再发一个更精确的 search,或直接 read 命中的块。它甚至可能一轮里并发发出多个 search(第 4.3 节的并行调用)。但这带来两个真实问题:一是检索次数不可控,模型可能反复搜同一个词空转,需要设一个 max_steps 或"检索预算"兜底;二是命中质量依赖工具描述,description 写得含糊时,模型会搜错词或读了不相关的块。这些在卷一只做"能跑通、能断言",策略化的部分留到卷二。

阅读导航:上一节:10.1 文档加载与切分 · 下一节:10.3 带引用的回答 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距