模块 04 · 第 5 课

带引用的回答

把检索到的文档块编号交给模型,要求它每句话注明出处、资料里没有就说没有。再用程序检查引用是否有效,并看看它在文档没有答案的问题上会不会编。

  • 约 35 分钟
  • 难度:进阶
  • 实测:2026-09-14 deepseek-flash,multilingual-e5-small

检索做好了,找到的资料要交给模型。怎么交、交了之后怎么要求它,决定了最后的回答是否可靠。

这一课要达到三个目标:模型只根据资料回答,每句话注明依据哪一段资料,资料里没有答案时老实说没有。然后写一段程序,自动检查它有没有做到。

给资料编号

检索用的是上一课效果最好的"改写 + 混合 RRF",取前 5 块。每一块用一个带编号和来源的标签包起来:

def build_context(results):
    parts = []
    for i, (_, file, text) in enumerate(results, 1):
        parts.append(f'<doc id="{i}" source="{file}">\n{text}\n</doc>')
    return "<docs>\n" + "\n".join(parts) + "\n</docs>"

放进提示词后是这个样子(内容省略):

<docs>
<doc id="1" source="compatibility.md">
## Redirects
Unlike `requests`, HTTPX does **not follow redirects by default**. ……
</doc>
<doc id="2" source="compatibility.md">
……
</doc>
……
</docs>

问题:httpx 默认会自动跟随重定向吗?怎么开启?

编号是给模型引用用的,source 是给模型参考、也方便程序最后展示给用户的。第 02 模块第 1 课讲过用标签把材料和指令分开,这里的 <doc> 标签还有一个作用:清楚地标出每一段资料的边界,模型不会把两段资料混成一段。

提示词

SYSTEM = """你是 httpx 的答疑助手。只根据 <docs> 里的文档片段回答用户的问题。

规则:
- 每句话后面用 [编号] 注明依据的是哪个片段,可以同时引用多个,如 [1][3]。
- 片段里没有的信息不要写,哪怕你自己知道。
- 如果这些片段不足以回答问题,就直接说"文档里没有找到相关说明",不要猜。
- 用中文回答,简洁,代码保持原样。"""

第二条"哪怕你自己知道"很重要。模型对 httpx 本来就有很多了解,但它的了解可能是错的,比如默认跟随重定向。要求它只用资料,就是用资料的正确性替代模型记忆的正确性。代价是:资料里恰好没写、而模型其实知道的东西,它也不能说了。第 7 课会遇到这样的例子。

第三条给了模型一个明确的"退路"。没有这条,资料不够时模型往往会用自己的知识补上,而这正是我们要避免的。

运行结果

我准备了 4 个问题:两个文档里有明确答案的;一个文档里完全没有提到的(HTTP/3,我搜过 httpx 的全部文档,没有出现过);一个答案需要仔细看资料的(httpx 有没有自带重试)。完整代码见 code/04-rag/citations.py,运行结果:

问:httpx 默认会自动跟随重定向吗?怎么开启?
检索到:[1] compatibility.md  [2] compatibility.md  [3] quickstart.md  [4] http2.md  [5] compatibility.md
答:httpx 默认**不会**自动跟随重定向,所有 HTTP 方法都是如此 [1][3]。

你可以在单个请求上显式开启 [1][3]:

```python
response = client.get(url, follow_redirects=True)
```

或者在创建 `Client` 时就默认开启 [1]:

```python
client = httpx.Client(follow_redirects=True)
```

开启后,可以通过 `response.history` 属性查看被跟随的重定向响应列表,按发生顺序排列 [3]。
检查:引用了 [1, 3],不存在的编号 无,没有引用的句子 0 句(输入 1002 词元)

问:httpx 支持 HTTP/3 吗?
检索到:[1] async.md  [2] http2.md  [3] advanced/transports.md  [4] http2.md  [5] advanced/transports.md
答:文档里没有找到相关说明。
检查:引用了 [],不存在的编号 无,没有引用的句子 1 句(输入 1128 词元)
    未引用:文档里没有找到相关说明。

问:httpx 自带请求失败自动重试的功能吗?
检索到:[1] third_party_packages.md  [2] advanced/transports.md  [3] compatibility.md  [4] async.md  [5] advanced/transports.md
答:httpx 自带连接层面的重试功能,通过直接实例化 transport 来使用:当发生 `httpx.ConnectError` 或 `httpx.ConnectTimeout` 时,请求会按给定的次数重试,从而在链路不稳定的网络下更平滑地运行 [2]。
(代码示例省略)
如果只需要专门的 HTTPX 重试层,可以看 httpx-retries,它是一个 for HTTPX 的 retry layer [1]。若需要其他形式的重试行为(例如处理读/写错误,或针对 `503 Service Unavailable` 做出反应),文档建议考虑 tenacity 这类通用工具 [2]。
检查:引用了 [1, 2, 4],不存在的编号 无,没有引用的句子 0 句(输入 908 词元)

(关闭 SSL 校验那道题的结果也正确,这里省略了。)

重定向:答对了,而且引用了两个来源,compatibility.mdquickstart.md 都讲到了这一点。

HTTP/3:文档里没有,模型就说没有,一个字都没多说。对比第 01 模块第 2 课,同一个模型在没有资料、没有规则约束时,会编出一个不存在的 CVE 编号。

重试:这是最能说明问题的一道题。"httpx 有没有自带重试",凭记忆回答很容易答成"有"或者"没有"。照着资料,它给出了准确的、有分寸的回答:自带的只是连接失败时的重试,读写错误和 503 要用别的工具。这正是 httpx 文档原文的意思,第 01 模块第 2 课里我去翻文档核实过同一件事。

用程序检查引用

模型说它引用了 [2],可 [2] 是不是真的存在?每句话是不是都有引用?这些可以用程序检查:

def check_citations(text, n_docs):
    """找出回答里所有的 [数字],检查它们是否都在 1..n_docs 范围内,以及有多少句话没有引用。"""
    cited = [int(x) for x in re.findall(r"\[(\d+)\]", text)]
    invalid = sorted({c for c in cited if not 1 <= c <= n_docs})
    prose = re.sub(r"```.*?```", "", text, flags=re.S)  # 代码块是照抄文档的,不要求逐行引用
    sentences = [s.strip() for s in re.split(r"(?<=[。!?])|\n+", prose) if len(s.strip()) > 8]
    # 以冒号结尾的句子是在引出下面的代码,引用通常写在代码块后面,也不算
    uncited = [s for s in sentences if not re.search(r"\[\d+\]", s) and not s.endswith((":", ":"))]
    return sorted(set(cited)), invalid, uncited

它做两件事:

  • 引用的编号是否存在。只给了 5 段资料,模型却引用了 [7],说明它在编造出处。这种情况在 4 道题里一次都没出现,但在资料很多、回答很长时是会发生的。
  • 哪些句子没有引用。没有引用的句子,可能是模型自己加的内容。

这个检查器我改过一次。第一版把代码块里的每一行代码都当成"没有引用的句子",于是每道带代码的题都报出一堆误报。代码是从文档里照抄的,不需要逐行引用,就先把代码块去掉再检查。现在唯一的"未引用"是"文档里没有找到相关说明"这一句,它本来就不需要引用。

程序检查有明显的局限:它只能检查格式,不能检查内容。模型引用了 [2],但 [2] 里是不是真的有这句话的依据,程序判断不了。这需要人来看,或者让另一个模型来判断,就是下一课要做的"忠实度"评估。

把引用展示给用户

对用户来说,[1]、[3] 这样的编号没有意义,要把它们换成真正的出处。RepoBot v2 的做法是:回答结束后,找出回答里实际引用了哪些编号,列出对应的文档:

来源:[1] compatibility.md  [2] quickstart.md

做成网页时,可以把编号做成链接,点击直接跳到文档的对应位置。用户能自己核对,出了错也能一眼看出是哪段资料误导了模型。

可能出现的问题

模型不引用,或者只在最后引用一次。在提示词里给一个例子,展示你期望的引用格式。

引用了,但引错了。回答里说的是 A 段的内容,却标了 [B]。这在资料之间内容相似时容易出现。可以要求模型在引用时顺便摘录原文的关键句,便于核对,代价是回答会变长。

资料之间互相矛盾。比如一段是旧版本的文档,一段是新版本的。模型可能随便挑一个,也可能把两者混在一起。解决办法在检索这一侧:给文档块加上版本信息,只检索当前版本的文档。

过于保守。资料里其实有答案,只是说得比较隐晦,模型却回答"文档里没有"。这是要求"只根据资料"的副作用。可以在提示词里说明"资料里间接提到的也可以回答,但要说明是推断"。

练习

  1. citations.py 里加一个问题:"httpx 和 requests 哪个性能更好?"文档里没有直接比较两者性能的内容,看看模型怎么回答。
  2. 删掉提示词里"片段里没有的信息不要写,哪怕你自己知道"这一条,再问 HTTP/3 的问题,看模型会不会用自己的知识回答,回答的内容是否正确。
  3. check_citations 加一个检查:如果回答里引用了某一段,那这一段里至少应该出现回答中的某个代码标识符(比如 follow_redirects)。这是一个粗略的"内容检查",想想它会在哪些情况下误判。

自测

1. 提示词里为什么要写"哪怕你自己知道,片段里没有的也不要写"?

模型自己的知识可能是错的,比如它记错了 httpx 的默认重定向行为。要求只根据资料回答,就是让资料的正确性代替模型记忆的正确性,并且让每句话都有据可查。代价是资料里恰好没写的信息,模型就不能说了。

2. 程序检查通过了(引用编号都存在、每句话都有引用),能说明回答是可靠的吗?

不能。程序只能检查格式:编号是否存在、有没有标引用。它判断不了引用的那段资料是否真的支持这句话。内容上是否忠实,需要人工检查,或者让另一个模型来判断。

3. 为什么要给模型一个"文档里没有找到相关说明"的明确说法?

没有明确的退路时,模型在资料不够的情况下倾向于用自己的知识补全,这恰恰可能引入错误。给它一个固定的说法,它就知道"不知道"也是一个可以接受的回答,程序也可以根据这句话识别出检索没有找到答案的情况。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…