Appearance
02 检索与带引用的回答
第一课把四条文档全部放入请求。资料变多后,我们先筛出可能回答问题的证据,再交给模型。本课实现一条完整而简单的路径:问题 → 检索 → 证据 → 生成 → 引用校验。
源码是 examples/doc_assistant/retrieval.py 和 documents.json。检索完全在本地执行;只有加 --live 且检索到了证据,才会调用模型。
1. 从四条可核对的资料开始
| ID | 主题 | 能回答的问题 |
|---|---|---|
preview | 本地预览 | 如何启动开发服务器 |
build | 构建文档 | 构建命令、产物位置、是否已部署 |
submodule | 初始化子模块 | 如何检出主仓库记录的内容版本 |
source | 文章维护位置 | AI Wiki 内容在哪个仓库修改 |
这些是随教程附带的教学资料,不是对当前线上系统的实时查询。ID 用于定位证据,标题用于阅读和检索,正文承载答案。生产资料还需要版本、来源、有效期与访问权限,本例暂不引入这些字段。
2. 检索基线如何打分
我们把连续英文与数字提取成词,把连续中文拆成相邻的两个字。例如:
text
问题:如何构建文档
中文片段:如何、何构、构建、建文、文档
文档标题:构建文档
中文片段:构建、建文、文档分数定义为:问题词集合与文档词集合的交集大小 ÷ 问题词集合大小。正文也参与匹配。程序保留分数不低于 0.2 的记录,再取前两条;同分时按 ID 排序,保证离线运行结果可重复。
这个分数既不是概率,也不是模型置信度。0.2 只是教学参数。真实系统要用独立评测样例选择阈值,而不能把它当成通用推荐值。
py
"""第二课:可解释的关键词检索基线。它不是向量检索,也不等同于生产 RAG。"""
import json
import re
from pathlib import Path
from common import options, payload, live_request, parse_answer, show, validate_question
DOCUMENTS = json.loads(Path(__file__).with_name("documents.json").read_text(encoding="utf-8"))
def tokens(text):
result = set(re.findall(r"[a-z0-9_]+", text.lower()))
for segment in re.findall(r"[\u4e00-\u9fff]+", text):
result.update(segment[index:index + 2] for index in range(len(segment) - 1))
return result
def search(question, top_k=2):
query = tokens(validate_question(question))
if not query:
return []
ranked = []
for doc in DOCUMENTS:
score = len(query & tokens(doc["title"] + " " + doc["text"])) / len(query)
if score >= 0.2:
ranked.append({**doc, "score": round(score, 3)})
return sorted(ranked, key=lambda doc: (-doc["score"], doc["id"]))[:top_k]
def answer_question(question, send=live_request):
evidence = search(question)
if not evidence:
return {"status": "insufficient_evidence", "answer": "没有检索到相关资料,请补充文档或改写问题。", "citations": []}
content = json.dumps({"question": question, "evidence": evidence}, ensure_ascii=False)
response = send(payload([{"role": "user", "content": content}]))
return parse_answer(response, {doc["id"] for doc in evidence})
if __name__ == "__main__":
args = options("检索本地样例文档,或基于证据生成答案")
if args.live:
show(answer_question(args.question))
else:
show({"mode": "offline-retrieval", "evidence": search(args.question)})3. 先看检索,再看答案
bash
python -X utf8 retrieval.py "如何构建文档"输出中应出现 mode: offline-retrieval,首条 evidence 的 ID 为 build。观察它的 score、标题与正文,再尝试:
bash
python -X utf8 retrieval.py "如何初始化子模块"
python -X utf8 retrieval.py "明天天气怎样"前者应优先命中 submodule,后者返回空 evidence。空结果不是程序崩溃,而是检索层没有足够证据的明确状态。
现在可选择真实生成:
bash
python -X utf8 retrieval.py "如何构建文档" --live与第一课对比,请求里的 evidence 只包含命中资料。answer_question() 在空 evidence 时直接返回 insufficient_evidence,不发送模型请求。因此,代码保证的是“检索结果为空时不调用模型”。
检索有结果不代表资料能够回答问题。例如运行 python -X utf8 retrieval.py "文档多少钱",build 会因为包含“文档”而获得 0.25 的分数,但资料没有价格信息。开启 --live 时,这个问题仍会进入生成阶段,需要模型依据证据报告不足,并由后续评测检查它是否猜测。这是关键词阈值可能误召回的具体反例。
4. 将一次错答拆成三个问题
假设用户问“构建文件保存在哪里”,最终答案却说“运行开发服务器”。不要立刻更换模型,按顺序检查:
- 检索错误:返回的资料是否包含
build?没有就查看词匹配、标题和阈值。 - 上下文错误:检索命中了,但真正发送的 evidence 是否漏了正文、混入了另一条记录或丢了 ID?
- 生成错误:证据正确进入模型,答案仍然错误;此时才评估任务说明、模型能力和答案约束。
引用校验只保证“引用过的文档实际提供过”。模型把 preview 中的命令归到 build 名下,仍可能通过存在性校验;第四课需要单独评估引用是否支持断言。
5. 为什么暂时不用向量数据库
教学任务只有四条短资料,线性扫描便于观察每一步。需要改善同义表达召回时,可以把 search() 换成向量或混合检索,并保持返回的 id/title/text 结构不变,这样生成和工具调用部分不必一起重写。
关键词基线有明显边界:
- “打包站点”和“构建文档”可能说的是同一件事,但字面重合少。
- 长文档和高频词可能得到偏高分数。
- 没有中文分词词典、词频归一化、重排或查询改写。
- 没有权限过滤,不能直接连接多租户私有资料。
当资料变成长文时,先按小节或语义边界切片,并保留来源与父文档 ID;不要机械地把每个固定长度片段都当成一份独立真相。进一步阅读 RAG 详解、长文本切片、混合检索。
6. 练习与完成标准
新增一条“预览构建产物”的资料,说明 npm run docs:preview 的用途,并为它写两个不同说法的问题。先记录基线命中情况,再只修改一个因素:标题、分词、分数或阈值。不要同时改三个参数后凭感觉判断提升。
完成标准:能复现命中、未命中和误召回三种情况;知道某条资料为什么排第一;能证明检索结果为空时不会发起模型请求;能指出至少一种关键词检索会失败的同义表达。
下一课:工具调用与执行循环。