纯 Python 实现会拒答的 RAG 问答
教程用约 300 行标准库代码实现文档分块、TF-IDF 检索、相关性门槛和来源引用,支持离线运行。配置 OpenAI API 密钥后可切换到嵌入与聊天模型,重点处理资料不足时的拒答。
AI 深度解读
给团队文档接上聊天机器人,最麻烦的往往是:资料没有答案,模型却照样给出一条像真的退款政策。这篇教程用纯 Python 标准库搭出一个最小检索流程,核心结论很明确:先查资料,相关度不够就停止,回答时附上来源。
它的价值在于把「允许回答的条件」写进程序。整套代码约 300 行、分布在三个文件中,适合用来理解 RAG 的边界。不过,检索分数过线和答案确实有据可依,仍然是两回事。
先把文档变成能定位的证据
RAG,也就是检索增强生成,会先从文档中找出相关片段,再让模型根据片段回答。这里的第一步不是调用模型,而是切文档:整份手册太大,检索应该定位到具体段落。
作者的 chunk_text 默认使用 chunk_size=600、overlap=100。它先按空行识别段落,尽量合并到大小上限;遇到单个超长段落,再用滑动窗口切开,步长是 max(1, chunk_size - overlap)。
有两个细节容易看漏。这里按 Python 字符串长度切分,600 不是 token 数;100 的重叠只用于超长段落的窗口切分,普通段落之间并不统一保留重叠。
每个片段还要保存源文件名和位置,后续才能输出类似 [Source: product-faq.txt, chunk 2] 的引用。没有这层关联,模型即使找到了正确内容,用户也很难回头核对。
离线和在线,共用流程但不能共用分数标准
离线模式使用 TF-IDF:统计词在片段中的出现次数,再降低常见词的权重。向量经过 L2 归一化后,可以用点积计算余弦相似度。大白话理解,就是更看重能区分文档的词,再衡量问题与片段有多接近。
实现只用到 collections.Counter、math.log 等标准库能力。作者认为,对于规模较小、关键词明确的文档集,这个办法已经有一定实用性,但没有给出系统评测。
配置 OPENAI_API_KEY 后,流程会切换到 OpenAI 的 text-embedding-3-small 和聊天模型。嵌入请求通过 urllib.request.Request 向 https://api.openai.com/v1/embeddings 发送 JSON POST,不依赖 SDK,示例超时设置是 timeout=60。原文没有说明聊天模型的具体名称。
两条路径最终都写入 index.json,保存片段、向量、运行模式和相关度阈值。问题也必须使用与建索引时相同的方式向量化,否则无法合理比较。
TF-IDF 的示例阈值是 0.10。作者解释,它的余弦分数通常低于嵌入相似度,因此两种模式各用自己的阈值;在线模式具体取多少,原文没有交代。
阈值是当前文档集的调参结果,不能当成通用常量。
拒答要发生在生成之前
这套方案最值得保留的是控制流:先检索,再检查最高分,通过后才允许生成。
下面这段代码负责取前 3 个片段,并在没有结果或最高分不足时结束请求:
scored = sorted(
((cosine(query_vec, vec), chunk)
for chunk, vec in zip(chunks, vectors)),
key=lambda pair: pair[0], reverse=True)[:3]
if not scored or scored[0][0] < threshold:
print(FALLBACK) # "I don't know -- that information isn't
return # in the documents provided."
关键是最后的 return:低相关度请求在这里停止,聊天模型不会被调用。它比只在提示词里要求「不知道就说不知道」多了一层程序约束。
通过检查后,在线模式把检索出的片段交给聊天模型,要求严格据此回答,并为每项主张标注来源。离线模式则没有生成模型,直接展示带引用的原文摘录,流畅度较低,但核对路径很直接。
示例可以这样运行,分别检查资料内和资料外的问题:
python ingest.py
python ask.py "How long is the warranty for the Nimbus kettle?"
python ask.py "What is the current stock price of Tesla?"
要留意后两条命令的区别:前者询问示例 FAQ 中的保修期限,后者询问资料未收录的股票价格,用来检查拒答路径。
作者提供的离线冒烟测试中,保修问题命中了 product-faq.txt 的两个片段,得分分别为 0.383 和 0.353。展示的摘录说明,虚构产品 Nimbus Smart Kettle 有 2 年保修,覆盖制造缺陷。Tesla 股价问题的最高分是 0.000,低于 0.1,程序返回资料中没有相关信息。
这证明了两个示例能按预期运行,还不能证明系统能可靠识别所有无法回答的问题。
我的判断
我认可把拒答写进控制流,也认可保留可回查的来源。相比一开始引入 LangChain、向量数据库和会话管理,这个教学项目让读者更容易看清检索、筛选、生成之间的关系。
但作者对门槛效果的描述需要收紧:低分时不调用模型,确实能阻止这一路径上的生成;高分时,仍可能检索到主题相关却无法回答问题的片段。
比如文档出现了「退款」,不代表它写明了退款期限。而且代码只检查最高分,另外两个片段即使分数较低,也可能随结果一起进入后续流程。相关度过线,只能说明值得继续检查,不能证明答案已经在资料里。
引用也有同样的边界。提示模型附上出处,有助于人工核验,却不等于每条引用都支持对应结论。原文没有展示对生成答案和引用关系的独立校验。
作者也坦承,TF-IDF 匹配关键词而非语义:用 guarantee 提问,可能漏掉只写了 warranty 的片段。JSON 索引适合数百个片段,不适合数百万个;项目没有 PDF 解析、网页采集、会话记忆或服务端,只是一次回答一个问题的 CLI。
文末售价 $49、每份许可对应一个商业项目的 Starter Kit,是作者销售的扩展版本。它宣称加入 FastAPI、聊天组件、Chroma/pgvector/Pinecone、会话记忆、按 IP 限流和配置化设置,但原文没有展示这些能力的对照测试。应把它与已展示的离线演示分开判断。
能拿来做什么
- 如果你准备给项目 FAQ 或内部说明做问答入口,可以先把
.txt、.md文件放进docs/,用离线 CLI 检查切片和来源定位是否合理,再考虑接入聊天界面。 - 为自己的文档准备三类问题:原文直接覆盖、改写表达后仍可回答、主题相关但缺少答案。用它们调整阈值,重点观察误拒答和误放行,不要直接照搬
0.10。 - 接入模型后,逐条核对回答中的事实与引用片段,尤其关注期限、价格和政策条件。前端展示来源时,应让用户能看清具体片段,而不只是看到文件名。
- 如果要扩成 Node 或其他服务端接口,可以沿用「检索、检查、生成、返回来源」的顺序;同时把低分拒答作为明确结果传给界面,让用户知道需要补充资料。