用 Python 和 SQLite 跑通文档问答
教程通过先检索相关文档、再交给模型回答的流程解释 RAG,并使用 Python 和 SQLite 构建最小实现。支持用模型替身离线测试,无需额外部署服务。
AI 深度解读
模型能解释退货流程,却未必知道你店里的退货期限。RAG 的做法是先找出相关文档,再把文档交给模型回答。原文用 Python 3 和 SQLite 搭了一个小型客服问答系统,想说明:起步时,全文搜索就能承担检索工作,不必先部署向量数据库。
值得读的是它安排工作的顺序:先验证能否找到答案,再检查模型是否引用正确,最后才接真实模型。不过,原文给出的主程序存在截断,检索实验也只有 12 道题,适合作为入门设计参考,不能直接当作完整可运行的工程交付。
文档不多时,先别急着搭 RAG
RAG 是检索增强生成(retrieval-augmented generation),分成三步:检索相关段落,把段落加入提示词,让模型依据这些材料生成答案并引用来源。
它没有修改模型的知识,也不同于微调。微调改变模型本身;RAG 在每次提问时提供参考资料,因此更适合经常变化的业务事实。
但如果全部资料只有几页,直接塞进每次提示词可能更省事。代价是资料增长后,每次请求都要携带更多文本,提示词缓存可以缓解部分成本。资料超过上下文容量、更新频繁,或者希望每次只支付相关材料的输入费用时,再考虑检索更合理。
作者使用五份 Markdown 帮助文档,覆盖退换货、配送、礼品卡、营业时间和保修。系统先切段、建索引,每次取前三段,再生成带编号来源的提示词。没有搜索结果,就直接返回未找到答案,不调用模型。
RAG 的第一道质量关,是相关资料有没有被找到。
这也是原文最有用的工作顺序:答案不对,先检查检索结果。正确段落根本没进入提示词,继续调整模型措辞通常解决不了问题。
SQLite 如何把相关段落找出来
实现使用 Python 标准库里的 sqlite3,检索依赖 SQLite 的 FTS5。作者指出近期 Python 构建通常包含它,但仍需检查自己的环境,不能把「无需安装额外依赖」理解为所有环境都默认可用。
切段函数 chunk_markdown() 按 Markdown 标题划分内容,保存完整标题路径,例如 Returns > Refunds。这样,「商品必须未使用」就能带上退货语境,标题中的词也参与搜索。代码默认 max_words=120,作者另建议从每段 100 到 200 个词开始调整。
这里有一个实现细节:它按段落累计长度,没有继续拆分单个超长段落,所以 120 不是严格上限。
build_index() 默认创建 help.db,使用 source UNINDEXED, heading, text 三列,以及 tokenize = 'porter unicode61'。porter 处理英文词尾,让 refunds 能匹配 refund。每次建索引都会先执行 DROP TABLE IF EXISTS chunks,再从文档全量重建。
下面这段负责把自然语言问题转换为 FTS5 查询,并返回排名靠前的段落:
def search(conn, question, k=3):
"""The k best passages for a question. Matches on any word, and counts a heading match double."""
words = [w for w in re.findall(r"[a-z0-9]+", expand(question).lower()) if w not in STOPWORDS]
if not words:
return []
query = " OR ".join(f'"{w}"' for w in dict.fromkeys(words)) # quoting every word stops punctuation from breaking the query
rows = conn.execute("SELECT source, heading, text FROM chunks WHERE chunks MATCH ? ORDER BY bm25(chunks, 0.0, 2.0, 1.0) LIMIT ?", (query, k)).fetchall()
return [{"source": source, "heading": heading, "text": text} for source, heading, text in rows]
重点看查询构造和排序两行:词项分别加引号,以 OR 连接,再通过参数绑定传入;bm25(chunks, 0.0, 2.0, 1.0) 给标题列设置了正文两倍的权重。作者称用 500 条随机及恶意字符串测试,没有触发查询崩溃。这个结果支持查询构造的健壮性,不能证明检索准确。
另一个前提对国内开发者很关键:[a-z0-9]+ 不会提取中文词项,停用词、词干处理和同义词也围绕英文设计。把它直接接到中文知识库,不能期待同样效果。
12 道题证明了什么,又没证明什么
作者为 12 个问题标注了答案所在文件,同时观察第一名命中和前三名覆盖情况:
| 检索方式 | 正确文件排第一 | 前三条包含正确文件 |
|---|---|---|
| 仅关键词 | 11/12 | 11/12 |
| 关键词加同义词 | 12/12 | 12/12 |
漏掉的问题是 How do I get my money back?,文档用的却是 refund。作者通过 SYNONYMS 加入三组扩展:money back → refund、parcel → item、weekend → saturday sunday。
这个例子解释了关键词检索的边界:用户和文档表达同一件事,词面却可能没有交集。原文给出的升级路线是先维护少量同义词,再考虑模型改写问题,或使用嵌入(embeddings),把文本转成可比较语义接近程度的数值表示,也可以组合关键词与语义检索。
但 12/12 只代表这组小样本里的文件命中。它没有测正确段落是否排在前面,也没有测答案是否完整,更不能推出其他知识库同样有效。
为了离线测试,作者还写了 extractive_model():从来源中挑出与问题共享词最多的句子,加上引用编号。这能验证周边流程,但测试不了真实模型的理解、改写和抗提示注入能力。
答案检查同样分层。check_answer() 设计上检查引用是否存在、答案数字是否出现在来源中。示例能发现把 14 天写成 30 天、漏写引用、引用不存在的 [3],允许诚实回答不知道。
然而,数字存在不代表用对了。来源同时包含退货期限和到账时间时,模型仍可能混淆两者;引用正确,也可能漏掉「未使用、保留原包装」的条件。
原文代码还有明确缺口:check_answer() 在一个孤立的 problems 表达式处结束,正文提到的 answer() 没有给出。因此,后续演示展示的是作者声称的行为,无法仅靠已提供代码直接复现完整流程。
我的判断
我认可「先测检索,再接模型」的路线。与一开始就引入向量数据库相比,SQLite 版本更容易看清分段、排序、提示词和引用之间的关系。但原文没有与向量检索或混合检索做对照,不能据此判断哪种方案更适合你的业务。
没有结果就拒答,确实省钱;可它只拦住零命中的情况。这里用 OR 匹配任意词,找到一段文字不等于找到答案。原文没有提供相关性阈值,也没有完整的「材料不足」判定实现。
真实模型接入也应单独验证。原文使用 pip install anthropic,从 ANTHROPIC_API_KEY 读取密钥,通过 client.messages.create() 调用 model="claude-sonnet-5-5",设置 max_tokens=300,最后读取 response.content[0].text。作者明确说只用替身服务器检查了 SDK 调用,没有请求真实模型;这个模型标识是否可用,输入材料没有给出验证。
安全边界比提示词措辞更重要。把材料包在 <sources> 中,并声明资料是数据而非指令,只是防护措施。索引外部网页、邮件或用户上传内容时,仍可能遇到提示注入。权限过滤应发生在材料进入提示词之前,密钥、密码和不该被检索的私密记录也不应入库。
能拿来做什么
- 为内部文档或客服帮助页做问答原型时,先拿五页文档和十个真实问题建立基线。记录答案文件及具体段落,分别检查检索命中与回答质量,别把两种错误混在一起。
- 给前端或 Node 项目接中文知识库前,先验证中文查询如何分词和进入索引。原文的英文正则与
porter配置需要重新评估,不能只替换文档内容。 - 把引用、数字检查和人工抽查一起保留。尤其涉及期限、金额和适用条件时,要检查整句话是否得到材料支持,不能只检查数字是否出现。
- 记录每次未找到答案的问题,并在文档更新时同步重建索引。先分清是文档缺内容、用户用词不同,还是旧页面互相矛盾,再决定补文档、加同义词或升级检索。