Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 8 期:检索——embedding 当节点还是当工具

get_policy 只覆盖三个枚举值:改期、退款、使用方式。客人问“行李箱能带多大“ 或者“支持支付宝吗“,这个工具接不住:数据都在,只是问题没法套进三个枚举 参数里。这一期加一批通用政策问答(FAQ),用语义检索接住这类问题,同时把 检索该放在哪一层的两种做法都实现出来,直接对比:一种让模型自己判断要不要 查,一种每一轮不问就先查一次。

DeepSeek 走的是 chat 接口,不提供 embedding。这一期换成一个纯本地跑的中文 embedding 模型(BAAI/bge-small-zh-v1.5),跟模型网关完全无关。

敲进去

第 8 期的代码在 code/ep08/get_orderget_policycancel_orderremember_note 原样搬过来。新增 retrieval.py,核心是两个函数:

def _encoder() -> SentenceTransformer:
    global _model
    if _model is None:
        try:
            _model = SentenceTransformer(MODEL_NAME, local_files_only=True)
        except OSError:
            _model = SentenceTransformer(MODEL_NAME)
    return _model


def search_faq(query: str, top_k: int = 2) -> list[dict]:
    """返回最相关的 top_k 条 FAQ,按相似度降序,附带得分。"""
    q_vec = _embed([QUERY_PREFIX + query])[0]
    sims = _faq_vectors() @ q_vec  # 两边都归一化过,点积就是余弦相似度
    order = np.argsort(-sims)[:top_k]
    return [{**FAQ[i], "score": float(sims[i])} for i in order]

local_files_only=True 这一行是真机跑出来的教训,见下面“发生了什么“。 QUERY_PREFIXbge 系列自己的约定:查询侧要加这句提示,文档侧不加, 换一个 embedding 模型要重新查对方的用法。

检索怎么接进图里,这一期写了两个方案。

方案一:当工具。 tools.py 里加一个 search_faq

@tool
def search_faq(query: str) -> str:
    """查通用政策问答:行李规定、支付方式、发票、儿童票、极端天气、团体优惠、
    电子票、改手机号这类问题。get_policy 只覆盖改期/退款/使用方式三类,
    问不到的都用这个。"""
    hits = retrieval.search_faq(query, top_k=2)
    return "\n".join(f"{h['question']}:{h['answer']}" for h in hits)

graph_tool.py 的节点和边结构跟第 6-7 期一模一样,search_faq 只是 TOOLS 列表里多的一项,图不需要多想什么。

方案二:当节点。 graph_node.pyagent 前面加一个 retrieve 节点, 每一轮对话开始都会跑,不经过模型判断:

def retrieve(state: AgentState) -> dict:
    last_human = next(m for m in reversed(state["messages"]) if isinstance(m, HumanMessage))
    hits = retrieval.search_faq(last_human.content, top_k=2)
    formatted = "\n".join(f"- {h['question']}:{h['answer']}(相似度 {h['score']:.2f})" for h in hits)
    return {"retrieved": formatted}
builder.add_edge(START, "retrieve")
builder.add_edge("retrieve", "agent")
...
builder.add_edge("tools", "agent")  # 工具循环回到 agent,不重新经过 retrieve

这个方案里 agent 的工具列表里没有 search_faq,检索结果通过 state["retrieved"] 这个新字段传给 agentagent 拼进系统提示词。 state.py 多了这一个字段,不走 add_messages 那套 reducer,就是普通的 “写了就覆盖”。prompts.pysystem_prompt 也多一个参数:

def system_prompt(note: str | None, retrieved: str | None = None) -> str:
    ...
    retrieved_section = f"\n参考资料(系统检索到的,不保证跟问题一定相关):\n{retrieved}\n" if retrieved else ""

方案一从不传 retrieved,这一段永远是空的;方案二每一轮都传。

main.py 把两个方案接进同一个入口,第一个参数选方案:

BUILDERS = {
    "tool": (graph_tool.build_graph, TOOLS),
    "node": (graph_node.build_graph, BASE_TOOLS),
}

两个方案共用同一个 user_id 记忆库(memory.sqlite),因为客人的偏好 跟走哪条检索路线没关系;但各自的对话 checkpoint 分开存 (checkpoints_tool.sqlite / checkpoints_node.sqlite),因为两边的图 结构不一样。

跑起来

cd code
uv run python -m ep08.main tool wang t1 "帮我查一下 KL-778 的改期政策"
uv run python -m ep08.main tool wang t1 "机场大巴能带多大的行李箱"

uv run python -m ep08.main node wang n1 "机场大巴能带多大的行李箱"
uv run python -m ep08.main node wang n2 "帮我查一下 KL-778 的改期政策"

你应该看到什么

实验一:方案一,不该查的时候没查

[agent] 要调 get_order({'order_id': 'KL-778'})
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'reschedule'})
[agent] 回答:KL-778(东京迪士尼一日票,出行日 2026-09-07)的改期政策:出行日前 3 天
可免费改期一次,改期后不可再改;出行日前 3 天内不支持改期。

今天是 9/3,距出行还有 4 天,目前仍可免费改期一次。需要我帮您办理吗?

从头到尾没有调 search_faqget_policy 够用,模型没有多此一举。

实验二:方案一,该查的时候查了,还会改写问题

[agent] 要调 search_faq({'query': '机场大巴 行李箱尺寸限制 行李规定'})
[tools] search_faq 返回:机场大巴能带多大的行李箱?:机场大巴每位乘客可免费携带一件不超过 20 公斤的行李,超出部分按每公斤 5 元收费,建议提前联系客服确认车型载重。
[agent] 回答:机场大巴每位乘客可免费携带一件不超过 20 公斤的行李,超出部分按每公斤 5 元收费。建议提前联系客服确认车型载重。

模型没有原样把客人的话丢给 search_faq,自己改写成了“机场大巴 行李箱 尺寸限制 行李规定“这样的检索式查询——这是模型自己的判断,我们没有教它 这么做。

实验三:方案二,无条件先查一次

[retrieve] 召回:
- 机场大巴能带多大的行李箱?:机场大巴每位乘客可免费携带一件不超过 20 公斤的行李,超出部分按每公斤 5 元收费,建议提前联系客服确认车型载重。(相似度 0.94)
- 多人一起订有没有团体优惠?:10 人及以上可申请团体票,享 9 折优惠,需提前 3 个工作日联系客服提交名单。(相似度 0.32)
[agent] 回答:机场大巴每位乘客可免费携带一件不超过20公斤的行李,超出部分按每公斤5元收费。建议提前联系客服确认车型载重哦。

第一条相似度 0.94,第二条 0.32——检索本身没有“及格线“,永远返回 top_k 条,不管好不好用。

实验四:方案二,问了个不需要检索的问题,还是查了

[retrieve] 召回:
- 下单时手机号填错了怎么改?:出行前联系客服提供订单号和正确手机号即可修改,出行当天无法修改,请务必提前处理。(相似度 0.34)
- 机场大巴能带多大的行李箱?:机场大巴每位乘客可免费携带一件不超过 20 公斤的行李,超出部分按每公斤 5 元收费,建议提前联系客服确认车型载重。(相似度 0.28)
[agent] 要调 get_order({'order_id': 'KL-778'})
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'reschedule'})
[agent] 回答:KL-778(东京迪士尼一日票,出行日2026-09-07)的改期政策:出行日前3天可免费改期一次,改期后不可再改;出行前3天内不支持改期。今天是9月3日,仍在可改期范围内。

这一次两条召回都不相关(0.34 和 0.28,比实验三那条 0.94 低了一大截), retrieve 节点照样跑了,系统提示词里照样塞了这两条不相关的参考资料。 好在模型没被带偏,还是老老实实调 get_order/get_policy 查真实数据, 最终答案正确——这只是模型这次判断力够用,这个方案本身并不提供这种保证, 见下面“发生了什么“。

顺带用 tool 方案回归了一遍 MCP 时间工具、remember_notecancel_order, 行为跟第 6、7 期一致,不占篇幅重复贴。

发生了什么

当工具:省不省事,全看模型判不判断得准。 实验一里模型正确地没有调 search_faq,一次多余的检索都没发生;实验二里模型不但调了,还自己把 问题改写成更适合检索的形式。好处是“用得上才查“,代价是这个判断权交给了 模型——如果模型误判“这个我知道不用查“,检索就被跳过了,而这个错误在这一期 的日志里根本看不出来,因为它表现为“什么都没发生“。

当节点:不会漏查,但用不上也要跑一次。 实验四是这条的直接证据: 两条召回都低于 0.35,跟问题毫不相关,retrieve 还是老老实实跑了、把 结果塞进了系统提示词。这一次模型顶住了噪音,换一个能力弱一点的模型, 不一定次次都能分清“这段参考资料跟我要回答的问题没关系“。

两个方案的“检索结果往哪传“,走的是两条不同的路。 方案一走 ToolMessage——检索结果作为一次工具调用的返回值,混在对话历史里, 下一轮 agent 看到的是完整的“我调用过什么、拿到了什么“。方案二走 state["retrieved"]——一个不参与对话历史、只在这一轮内部传递的字段, agent 读完就完事,不会留在 messages 里,也不会被 checkpointer 当成对话的一部分永久记住。

这一期的相似度计算是暴力法,数据量小才扛得住。 _faq_vectors() 把全部 FAQ 一次性向量化,search_faq 每次查询都跟全部向量算一遍矩阵 乘法。8 条 FAQ 这么做毫无压力,几千几万条的知识库要换向量数据库 (FAISS、pgvector 这类),原理不变,只是不再是一个 numpy 矩阵乘法。

常见问题

为什么不直接找 DeepSeek 要一个 embedding 接口? 它没有。聊天模型和 向量模型经常是两个不同的服务,即便同一家供应商也不一定两个都提供—— 这一期的“分头找“是很多真实项目的常态,这个系列没有特意绕路。

bge-small-zh-v1.5 那个前缀是什么讲究? 这个模型训练的时候,查询和 文档用了不对称的表示方式——查询侧要加一句提示,文档侧不加,这样算出来的 相似度才准。这是模型作者定的规矩,换一个 embedding 模型要重新查对方的 使用文档,不能想当然套用同一个前缀。

两个方案该怎么选? 看检索在整个能力里占的位置。如果大多数问题都需要 先查一遍知识库,当节点更简单,省了一次“要不要查“的判断;如果检索只是 众多能力里的一个,大多数问题根本用不上它,当工具更省资源,工具描述里 还能写清楚“什么时候该用“,把边界交给提示词判断,不用硬编码进图的结构。

这一期的向量检索需要联网吗? 模型权重第一次下载需要联网(约 100MB),下载完之后完全离线,不需要模型网关也不需要 key。

加分练习

  1. 给方案二加一道相似度阈值:低于某个分数就不把 retrieved 塞进系统 提示词,重跑实验四,看看提示词有没有干净一点。
  2. 让方案一的 search_faq 自己控制 top_k,改成一个参数让模型自己填, 想一想这样做有没有风险。
  3. 把 FAQ 数据源从 faq.json 换成一堆 Markdown 文件,retrieval.py 要不要跟着改?改动应该出现在哪一层,哪一层不该动。
  4. 查一下 FAISS 或者 pgvector 跟这一期“整份数据进内存、每次算全量余弦“ 的实现思路差多少,数据量到什么规模就该换。