第 8 期:检索——embedding 当节点还是当工具
get_policy 只覆盖三个枚举值:改期、退款、使用方式。客人问“行李箱能带多大“
或者“支持支付宝吗“,这个工具接不住:数据都在,只是问题没法套进三个枚举
参数里。这一期加一批通用政策问答(FAQ),用语义检索接住这类问题,同时把
检索该放在哪一层的两种做法都实现出来,直接对比:一种让模型自己判断要不要
查,一种每一轮不问就先查一次。
DeepSeek 走的是 chat 接口,不提供 embedding。这一期换成一个纯本地跑的中文
embedding 模型(BAAI/bge-small-zh-v1.5),跟模型网关完全无关。
敲进去
第 8 期的代码在 code/ep08/。get_order、get_policy、cancel_order、
remember_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_PREFIX 是 bge 系列自己的约定:查询侧要加这句提示,文档侧不加,
换一个 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.py 在 agent 前面加一个 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"] 这个新字段传给 agent,agent 拼进系统提示词。
state.py 多了这一个字段,不走 add_messages 那套 reducer,就是普通的
“写了就覆盖”。prompts.py 的 system_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_faq。get_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_note、cancel_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。
加分练习
- 给方案二加一道相似度阈值:低于某个分数就不把
retrieved塞进系统 提示词,重跑实验四,看看提示词有没有干净一点。 - 让方案一的
search_faq自己控制top_k,改成一个参数让模型自己填, 想一想这样做有没有风险。 - 把 FAQ 数据源从
faq.json换成一堆 Markdown 文件,retrieval.py要不要跟着改?改动应该出现在哪一层,哪一层不该动。 - 查一下 FAISS 或者 pgvector 跟这一期“整份数据进内存、每次算全量余弦“ 的实现思路差多少,数据量到什么规模就该换。