项目:让答疑助手读懂项目文档
把切分、混合检索、查询改写、带引用的回答装进 RepoBot,做出 v2。它在 v1 答错的问题上答对了,还解决了多轮对话里"那异步呢"这种追问的检索问题。
- 约 60 分钟
- 难度:进阶
- 实测:2026-09-14 deepseek-flash,multilingual-e5-small,bge-reranker-base
这一课把 04 模块的所有东西装进 RepoBot,做出第二版。你会看到一个在单次问答里没遇到过的问题:多轮对话中,用户的追问往往本身搜不到任何东西。
做完的标准
- 问"httpx 默认会自动跟随重定向吗?",回答是"不会",并列出来源
compatibility.md。 - 每个回答都用 [编号] 注明出处,结尾列出实际引用的文档。
- 问文档里没有的内容(比如 HTTP/3),回答"文档里没有找到相关说明",不编造。
- 连着问"怎么给 httpx 设置超时?"和"那异步客户端呢?",第二个问题能检索到超时相关的文档。
- 第二次启动时,不再重新计算文档的向量。
eval_retrieval.py在 20 道评估题上,前 5 名命中率达到 100%。
结构
代码在 projects/repobot/v2/,比 v1 多了两个文件:
llm.py 客户端、计费、带重试的请求(从 v1 的 repobot.py 里挪出来)
retrieval.py 切分、BM25、向量检索、RRF、重排、查询改写(04 模块第 2~4 课)
repobot.py 对话程序:在 v1 的基础上加上检索和引用
eval_retrieval.py 用 20 道题评估检索效果(04 模块第 3 课)
retrieval.py 里的代码,前几课都逐段讲过,这里只讲组装时新遇到的三个问题。
问题一:追问搜不到东西
在 04 模块的练习代码里,每个问题都是独立的。可在对话里,用户会这样问:
你:怎么给 httpx 设置 10 秒的超时?
你:那异步客户端呢?
第二个问题直接拿去检索,"那异步客户端呢"会找到讲异步的文档,却找不到讲超时的,因为问题里根本没有"超时"两个字。
v2 的解决办法是在查询改写这一步带上最近的对话,让模型先弄明白用户到底在问什么,再生成检索词:
REWRITE_PROMPT = """你要为一个 httpx 答疑助手生成文档检索词。httpx 的文档是英文的。
根据"最近的对话"理解用户"最新的问题"到底在问什么(比如"那异步呢"要结合上文补全),
然后输出一行英文检索词:包含问题的完整英文表述,以及文档里可能出现的参数名、类名、术语。只输出这一行。"""
class QueryRewriter:
def rewrite(self, question, history=()):
recent = "\n".join(f"{m['role']}: {m['content'][:300]}" for m in list(history)[-4:])
……
text, self.last_usage = llm.chat([
{"role": "system", "content": REWRITE_PROMPT},
{"role": "user", "content": f"最近的对话:\n{recent or '(无)'}\n\n最新的问题:{question}"},
])
只取最近 4 条消息,每条最多 300 个字,够理解指代,又不会让改写这一步变得太贵。下面的运行结果里能看到它的效果。
问题二:历史里存什么
每一轮都要把检索到的 5 段文档放进提示词,大约一千个词元。如果把它们也存进对话历史,十轮之后历史里就有上万个词元的旧文档,又贵又容易干扰模型。
v2 的做法是:文档只放进这一轮的 user 消息里,存进历史的只有用户的原始问题和模型的回答:
messages = ([{"role": "system", "content": SYSTEM}] + history +
[{"role": "user", "content": build_context(results) + f"\n\n问题:{question}"}])
……
history += [{"role": "user", "content": question}, {"role": "assistant", "content": text}]
模型的回答里已经包含了从文档里得到的要点,后面的对话要用到时,从回答里就能找到。
问题三:别每次启动都重算向量
196 个文档块,每次启动都要用嵌入模型计算一遍,要十几秒。文档不变的话,结果每次都一样,完全可以缓存:
key = hashlib.sha256((EMBED_MODEL + json.dumps(self.chunks)).encode()).hexdigest()[:16]
path = cache_dir / f"vectors-{key}.npy"
if path.exists():
self.matrix = np.load(path)
else:
self.matrix = self.embedder.encode(["passage: " + t for t in texts], normalize_embeddings=True, batch_size=32)
np.save(path, self.matrix)
缓存文件的名字来自"嵌入模型名 + 所有文档块的内容"的哈希值。只要文档改了一个字,或者换了嵌入模型,哈希就会变,就会重新计算,不会误用过期的向量。查询改写的结果也同样缓存在 .cache/rewrites.json 里。
检索效果
cd projects/repobot/v2
pip install -r requirements.txt
export HF_ENDPOINT=https://hf-mirror.com
python eval_retrieval.py
python eval_retrieval.py --rerank
== 不加重排
前 1 名命中:80%
前 3 名命中:100%
前 5 名命中:100%
MRR:0.892
== 加重排
前 1 名命中:85%
前 3 名命中:95%
前 5 名命中:100%
MRR:0.902
不加重排的 MRR 是 0.892,比第 4 课"改写 + 混合 RRF"的 0.772 高了不少。检索的算法完全一样,唯一的区别是改写的提示词换成了上面这个新版本:"输出一行英文检索词:包含问题的完整英文表述,以及……"。第 4 课的版本是"包含问题的英文翻译,以及……",而且没有对话上下文。
这再次说明,查询改写的提示词对检索效果影响很大。这也是为什么每改一处都要重新跑评估:你以为只是顺手改了一句提示词,指标可能就变了。
在这个版本上,重排只把 MRR 从 0.892 提到了 0.902,前 3 名命中率还从 100% 降到了 95%。多花一秒钱换来的提升很小,所以 v2 默认不开重排,需要时加 --rerank。
运行一次对话
printf '%s\n' "httpx 默认会自动跟随重定向吗?" "那默认最多跟随几次?" "怎么给 httpx 设置 10 秒的超时?" \
"那异步客户端呢?" "httpx 支持 HTTP/3 吗?" "今天北京天气怎么样?" | python repobot.py --show-query
--show-query 会显示每一轮的检索词和检索到的文档。我运行的结果(删掉了几段代码示例,其他没有改动):
你:httpx 默认会自动跟随重定向吗?
[检索词] Does httpx follow redirects by default? follow_redirects parameter, redirect, httpx.Client, AsyncClient, max_redirects, history, redirection behavior
[检索到] [1] compatibility.md [2] quickstart.md [3] compatibility.md [4] compatibility.md [5] http2.md
RepoBot:不会。与 `requests` 不同,HTTPX 默认**不跟随重定向**[1],对所有 HTTP 方法默认都不跟随重定向[2]。
(启用方法和示例代码省略)
来源:[1] compatibility.md [2] quickstart.md
[回答输入 1047(缓存命中 0),输出 180,本轮 0.00060 美元(含改写),累计 0.00060 美元]
你:那默认最多跟随几次?
[检索词] httpx follow_redirects max_redirects default maximum number of redirects
[检索到] [1] quickstart.md [2] compatibility.md [3] advanced/resource-limits.md [4] compatibility.md [5] advanced/transports.md
RepoBot:文档里没有找到相关说明。检索到的片段只说明了 HTTPX 默认不跟随重定向,以及如何通过 `follow_redirects=True` 启用[1][2],但没有提到最大跟随次数的默认值。
建议查阅 HTTPX 官方文档中关于重定向或多重定向(`max_redirects`)的页面确认。
来源:[1] quickstart.md [2] compatibility.md
你:怎么给 httpx 设置 10 秒的超时?
[检索词] httpx set timeout 10 seconds how to configure timeout Timeout timeout parameter httpx.Client(timeout=...) httpx.get(timeout=TimeOut(...)) DEFAULT_TIMEOUT_CONFIG connect read write pool timeout
[检索到] [1] advanced/extensions.md [2] advanced/timeouts.md [3] advanced/timeouts.md [4] advanced/timeouts.md [5] advanced/timeouts.md
RepoBot:可以给客户端实例设置默认超时,例如设置 10 秒对所有请求生效:
(代码示例省略)
来源:[2] advanced/timeouts.md [4] advanced/timeouts.md
你:那异步客户端呢?
[检索词] asynchronous client timeout configuration httpx.AsyncClient(timeout=10.0) default timeout per-request async
[检索到] [1] advanced/timeouts.md [2] advanced/timeouts.md [3] advanced/timeouts.md [4] compatibility.md [5] quickstart.md
RepoBot:异步客户端同样支持超时设置,用法与同步客户端一致。
(代码示例省略)
不过需要说明:文档片段中给出的示例都写作 `httpx.Client(...)`,没有出现 `httpx.AsyncClient(...)` 的写法,因此片段里没有专门针对异步客户端的示例,但相同参数适用于客户端实例的默认超时与单请求超时设置[1][3]。
来源:[1] advanced/timeouts.md [2] advanced/timeouts.md [3] advanced/timeouts.md
你:httpx 支持 HTTP/3 吗?
[检索词] httpx HTTP/3 support HTTP/3 QUIC protocol
[检索到] [1] index.md [2] advanced/proxies.md [3] index.md [4] index.md [5] http2.md
RepoBot:文档里没有找到相关说明。
检索到的片段只提到 HTTPX 支持 HTTP/1.1 和 HTTP/2[3][4],并且 HTTP/2 默认不启用、需要安装可选依赖并显式开启[5],但没有提到 HTTP/3。
建议查阅 HTTPX 官方文档确认是否支持 HTTP/3。
来源:[3] index.md [4] index.md [5] http2.md
你:今天北京天气怎么样?
[检索词] Beijing weather forecast today current temperature conditions
[检索到] [1] troubleshooting.md [2] logging.md [3] troubleshooting.md [4] exceptions.md [5] troubleshooting.md
RepoBot:抱歉,我只负责 httpx 相关的答疑,不回答天气这类无关问题。如果你有 httpx 的使用问题,欢迎继续问我。
[回答输入 2481(缓存命中 640),输出 29,本轮 0.00069 美元(含改写),累计 0.00388 美元]
逐条看
重定向,答对了。v1 在这里答错,还编了版本历史。v2 答"不会",并给出了两个来源。
追问被正确理解了。"那默认最多跟随几次?"被改写成了 httpx follow_redirects max_redirects default maximum number of redirects;"那异步客户端呢?"被改写成了 asynchronous client timeout configuration httpx.AsyncClient(timeout=10.0),检索到的都是 timeouts.md。没有带上对话上下文的改写,第二个问题只会搜到讲异步的文档。
"默认最多跟随几次",它说文档里没有。这个回答是对的:httpx 的文档确实没写这个默认值,它只存在于源码里(第 03 模块第 5 课我们在 httpx/_config.py 里查到是 20)。可有意思的是,v1 凭记忆答对了这一题。v2 因为严格要求"只根据资料回答",反而答不上来。这是第 5 课说过的代价:资料里没写的,就算模型知道,也不能说。
异步客户端,回答得很有分寸。文档的示例都是 httpx.Client,它如实说明了这一点,同时指出相同的参数也适用。这正是我们想要的:不假装文档里有它没有的东西。
HTTP/3,没有编造。
天气,拒绝了,但白花了钱。它仍然做了一次改写和检索,找了 5 段毫不相关的文档塞进提示词。更好的做法是在检索之前先判断问题和 httpx 有没有关系,无关就直接拒绝。06 模块第 5 课的"护栏"会做这件事。
费用。每轮大约 0.0006 到 0.0007 美元,包括改写的那次调用,比 v1 的每轮 0.0001 到 0.0005 美元贵了一些,主要是多了约一千个词元的文档。缓存命中数在增长,因为 system 提示词和对话历史是固定的开头,只有最后一条 user 消息里的文档每次不同。
v2 的问题
- 文档没写的,它就答不了。默认重定向次数、
Limits的内部逻辑、某个异常到底在什么情况下抛出,这些答案都在源码里。 - 只能检索一次。如果第一次检索没找对,它没有机会换个检索词再试。
- 不管什么问题,都要先检索。连天气这种问题也不例外。
前两个问题,需要让 RepoBot 能自己决定"去查文档"、"去翻源码",并根据查到的结果决定下一步做什么。这就是第 05 模块的智能体。
这个项目要回答的几个问题
- 为什么这样设计? 查询改写 + BM25 + 向量 RRF,是在 20 道评估题上验证过效果最好、又不需要额外算力的组合。重排提升很小,所以设为可选。
- 会在哪里失败? 答案只在源码里、不在文档里的问题;需要综合好几篇文档的问题;检索第一次没找对的问题。
- 怎么评估? 检索用
eval_retrieval.py,回答的质量用第 6 课的模型评委。 - 出问题时看什么? 打开
--show-query,先看检索词对不对,再看检索到的文档对不对,最后看模型有没有正确使用文档。大部分问题出在前两步。 - 能不能更便宜? 可以在检索前先判断问题是否和 httpx 相关,无关的直接拒绝,省掉改写、检索和一千多个词元的输入。
- 需要智能体吗? 到这一版为止还不需要,流程是固定的:改写、检索、回答。当它需要"检索没找到就换个方法"时,才需要。
练习
- 用 v1 第 5 课练习里你自己出的 5 道题测一测 v2。有没有哪道题 v1 答对了、v2 反而答不上来?
- 把
QueryRewriter.rewrite里的history参数去掉(永远传空的历史),再问一次"怎么设置超时"和"那异步客户端呢",看第二个问题检索到了什么。 - 在
repobot.py里,检索之前先调用一次模型判断"这个问题和 httpx 有没有关系",无关就直接回复拒绝语,不做检索。比较改动前后,问天气那一轮的花费。
自测
1. 用户问"那异步客户端呢?",直接拿这句话去检索会有什么问题?v2 是怎么解决的?
这句话里没有"超时"之类的关键信息,检索只会找到讲异步的通用文档,找不到讲异步客户端超时设置的内容。v2 在查询改写时带上最近几轮对话,让模型先理解用户实际在问"异步客户端怎么设置超时",再生成完整的检索词。
2. 为什么检索到的文档只放在当前这一轮的消息里,不存进对话历史?
每一轮的文档大约有一千个词元,都存进历史的话,对话越长历史越大,又贵又容易干扰模型。模型的回答里已经包含了从文档中得到的要点,存回答就够了。
3. v1 凭记忆答对了"默认最多跟随几次重定向",v2 却说文档里没有。这说明 v2 比 v1 差吗?
不能这么说。v2 只根据文档回答,文档里确实没有这个信息,所以它如实说"没有找到"。这是用"可能答不上来"换来"答出来的都有据可查"。v1 这次碰巧答对了,但它在别的问题上会很自信地答错。要解决 v2 的这个问题,应该让它能查到更多资料,比如源码,而不是放宽"只根据资料"的要求。