Skip to content

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. 将一次错答拆成三个问题 ​

假设用户问“构建文件保存在哪里”,最终答案却说“运行开发服务器”。不要立刻更换模型,按顺序检查:

  1. 检索错误:返回的资料是否包含 build?没有就查看词匹配、标题和阈值。
  2. 上下文错误:检索命中了,但真正发送的 evidence 是否漏了正文、混入了另一条记录或丢了 ID?
  3. 生成错误:证据正确进入模型,答案仍然错误;此时才评估任务说明、模型能力和答案约束。

引用校验只保证“引用过的文档实际提供过”。模型把 preview 中的命令归到 build 名下,仍可能通过存在性校验;第四课需要单独评估引用是否支持断言。

5. 为什么暂时不用向量数据库 ​

教学任务只有四条短资料,线性扫描便于观察每一步。需要改善同义表达召回时,可以把 search() 换成向量或混合检索,并保持返回的 id/title/text 结构不变,这样生成和工具调用部分不必一起重写。

关键词基线有明显边界:

  • “打包站点”和“构建文档”可能说的是同一件事,但字面重合少。
  • 长文档和高频词可能得到偏高分数。
  • 没有中文分词词典、词频归一化、重排或查询改写。
  • 没有权限过滤,不能直接连接多租户私有资料。

当资料变成长文时,先按小节或语义边界切片,并保留来源与父文档 ID;不要机械地把每个固定长度片段都当成一份独立真相。进一步阅读 RAG 详解、长文本切片、混合检索。

6. 练习与完成标准 ​

新增一条“预览构建产物”的资料,说明 npm run docs:preview 的用途,并为它写两个不同说法的问题。先记录基线命中情况,再只修改一个因素:标题、分词、分数或阈值。不要同时改三个参数后凭感觉判断提升。

完成标准:能复现命中、未命中和误召回三种情况;知道某条资料为什么排第一;能证明检索结果为空时不会发起模型请求;能指出至少一种关键词检索会失败的同义表达。

下一课:工具调用与执行循环。