“The LLM is usually fine. The retrieval is the bottleneck.” 这句话放到调试 RAG 时尤其扎心。你以为是上下文窗口不够大,于是往对话里塞更多检索结果,结果回答更飘、延迟更高,账单也一路飞涨。真正的问题不是窗口,而是你塞进去的东西:标准 RAG 把向量召回 Top-200 候选全部拼进 prompt,其中 190 条都是噪音。为了把 LangChain + FAISS 这段检索链路调明白,我想用 Codex 复现并对比精排前后的差异,结果先被官方额度、多把 Key 换来换去卡住了。最后我在 TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end)创建了一把 Key,把 Codex 的 Base URL 指到 https://taotoken.net/api,请求统一走 TaoToken,才把注意力拉回到检索本身:先看那 190 条噪音是怎么进 prompt 的。
1. 先把 200 条候选摊开:Codex 复现噪音现场
1.1 向量召回快,但不知道“有用”是什么
典型的 RAG 流程是:用向量库做余弦相似度检索,召回 Top-200 候选,全部塞给大模型,期待它自己挑出关键信息。Bi-Encoder 的做法是把 query 和文档各自编码成一个向量,再算余弦相似度。速度快,但精度有限——它衡量的是语义接近程度,不是“这段话对当前问题有没有用”。举个具体例子:你问“怎么缓解 PostgreSQL 的锁等待”,召回结果里有大量讲解 MVCC 和行锁原理的百科式段落,真正给出排查步骤的可能只有十几段。这十几段淹没在 200 条候选里,模型很容易漏看。
1.2 让 Codex 写一段噪音检查脚本,你本地跑
要让问题可见,先写个脚本把召回结果摊开。让 Codex 生成下面这段代码,在你自己机器上跑,把输出贴回对话里做分析:
from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings() vectorstore = FAISS.load_local( "my_index", embeddings, allow_dangerous_deserialization=True ) query = "怎么缓解 PostgreSQL 的锁等待" candidates = vectorstore.similarity_search(query, k=200) for i, doc in enumerate(candidates[:10]): print(i, len(doc.page_content), doc.page_content[:40].replace("\n", " "))跑完你会发现,前 10 条里可能有一半和“锁等待”沾边,剩下的是锁机制背景、索引原理甚至其他数据库的文章。这就是“Top-200 全塞进 prompt”的现场。Codex 可以帮你统计噪音比例,但它只负责生成和分析,执行要在你本地完成。
2. config.toml 指向 TaoToken:Codex 跑精排前的准备
2.1 创建 Key,只认官网这一步
Codex 要想跑通上面的脚本并继续对比精排收益,得先有一把能稳定请求模型的 Key。创建 Key 的位置在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end,注册后进控制台的 API Keys 页面生成,复制出来的字符串就是你的 YOUR_API_KEY。这把 Key 只用于 API 请求,不要贴到前端页面里。
2.2 写 ~/.codex/config.toml,只改 Base URL
Codex 的配置存在用户目录下的 config.toml。在文件里声明一个自定义 provider,把 Base URL 指到 TaoToken 的接口即可:
# 模型 ID 以 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 模型广场当时列表为准 model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY"然后在 shell 里导出环境变量:
export TAOTOKEN_API_KEY="YOUR_API_KEY"这里有两个容易踩的细节:一是 Base URL 末尾不要加 /v1,TaoToken 的接口统一是 https://taotoken.net/api;二是模型 ID 不要在配置文件里写死猜测值,启动 Codex 后用 /model 打开列表,以模型广场当时显示的为准。配完之后发一条“print hello”让 Codex 跑一下,能正常返回就说明链路通了。若报 401,检查环境变量是否真的导出;报 404,多半是 Base URL 多写了后缀。
3. Attention 的 O(n²) 会让噪音变成一笔大账单
3.1 序列越长,计算量涨得越快
Transformer 的 Self-Attention 计算复杂度大致随序列长度平方增长。把候选从 10 段加到 200 段,不只是 20 倍的 token 费用,而是更慢的响应。下面这张相对计算量表是理论口径:
| 上下文 token 数 | 相对计算量 | 体感 |
|---|---|---|
| 1K | 1x | 即时 |
| 4K | 16x | 可接受 |
| 16K | 256x | 明显延迟 |
| 64K | 4096x | 基本不可用 |
200 个 chunk 平均每个 250 token,加起来就是 50,000 token。多数场景里真正有用的只有 10 段,约 2,500 token。这就是原文“Token 从 5 万降到 2500”说法的由来:不是模型变强了,而是你不再拿 50,000 token 去换一个本可以 2,500 token 完成的回答。
3.2 Lost in the Middle:关键段落被埋在中间
更麻烦的是,研究表明大模型对长上下文的注意力并不均匀,开头和结尾更容易被记住,中间部分容易被忽略。当 190 条噪音把关键段落挤到中间位置时,模型不是“没能力”找到答案,而是根本没看见。这也是我坚持先跑噪音检查脚本的原因:先把中间区域摊开,看看真正命中的段落被埋在哪。
4. 两阶段检索代码:向量召回 Top-50,精排取 Top-10
4.1 复现原来的 LangChain + FAISS 精排片段
原文的两阶段思路不需要改:第一阶段向量召回一批候选,第二阶段用 Cross-Encoder 精排取前 10。下面是补全后的可运行版本,比原片段多了 token 统计和反序列化参数,适合直接拿来对比精排收益:
from sentence_transformers import CrossEncoder from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings from transformers import AutoTokenizer cross_encoder = CrossEncoder("BAAI/bge-reranker-v2-m3") tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-v2-m3") embeddings = OpenAIEmbeddings() vectorstore = FAISS.load_local( "my_index", embeddings, allow_dangerous_deserialization=True ) query = "怎么缓解 PostgreSQL 的锁等待" candidates = vectorstore.similarity_search(query, k=50) pairs = [(query, doc.page_content) for doc in candidates] scores = cross_encoder.predict(pairs) ranked = sorted(zip(scores, candidates), key=lambda x: x[0], reverse=True) top_docs = [doc for _, doc in ranked[:10]] before = sum(len(tokenizer.encode(doc.page_content)) for doc in candidates) after = sum(len(tokenizer.encode(doc.page_content)) for doc in top_docs) print(f"精排前 {before} tokens -> 精排后 {after} tokens")这段代码在你本地执行。跑完后把输出贴给 Codex,让它对照精排前后的 token 数和排名变化,说清楚哪几段被挤出了 Top-10。
4.2 让 Codex 帮你盯住重复段落
第二阶段还有个隐蔽问题:精排后保留的 Top-10 可能来自同一篇文章的相邻分块,信息密度并不高。你可以让 Codex 对 top_docs 做一次相似度去重检查,比如两两计算重叠词比例,把重复段落合并或替换。这个检查同样只做分析,Codex 不直接改你的索引文件。改完再跑一次 token 统计,通常能看到第二次下降。
5. 主流精排方案对照:从 bge-reranker 起步
5.1 四种方案一表看明白
精排模型的选择直接影响延迟和精度,原文给出的对照可以简化为下面这张表:
| 方案 | 类型 | 优势 | 要注意的点 |
|---|---|---|---|
| BGE-Reranker-v2-M3 | 开源 Cross-Encoder | 多语言、中文效果好 | 需要自己部署 |
| Cohere Rerank 3.5 | 商业 API | 开箱即用 | 有网络延迟和费用 |
| ColBERT / RAGatouille | Late Interaction | 速度与精度折中 | 索引构建复杂 |
| FlashRank | 轻量 Cross-Encoder | CPU 上很快 | 精度略低 |
如果你刚把精排跑通,先别急着引入复杂方案。把 bge-reranker-v2-m3 的代码跑起来,拿到精排前后的 token 数,再决定要不要换。切换时只需要替换 CrossEncoder 初始化那一行,其余代码不用动。
5.2 模型 ID 别猜,去模型广场看
精排模型和生成模型是两个层面的东西,容易搞混。上面代码里的 BAAI/bge-reranker-v2-m3 是 Hugging Face 上的精排模型,跑在你自己机器上;Codex 请求的大语言模型 ID 则要看 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 模型广场当时的列表。两套 ID 不要互相套用,否则要么 404,要么模型直接不存在。
6. 精排之外:Codex 也答不了的那三个问题
6.1 个性化:新手和专家不该拿到同一份 Top-10
余弦相似度不知道“谁在问”。一个刚接触 PostgreSQL 的新手和一个 DBA 问同一个“锁等待”,需要的资料深度完全不同。向量检索给不了这种区分,精排模型同样给不了。这个问题需要用户上下文建模,短期内靠调整 query 改写可能更实际。
6.2 行为信号:用户点没点、赞没赞,检索一无所知
标准 RAG 链路里,用户点击、采纳、标记“这个回答没用”等信号完全没进检索。没有这些反馈,RAG 系统就无法学习“什么样的候选对真实用户有用”。对多数团队来说,先把两阶段精排做好,再考虑记录行为信号,优先级更合理。
6.3 多样性:Top-10 可能是同一段落的 10 个分块
精排只看相关性分数,不看重复度。排在最前面的可能都是同一篇长文的连续分段,信息密度很低。在把结果交给 LLM 之前,按段落来源做一次去重或分组,比继续堆候选更有效。这个动作也可以让 Codex 写脚本,在你本地对 top_docs 做合并。
7. 验证 5 万降到 2500:跑完脚本去控制台对账
7.1 先看本地脚本输出,再和原文口径对照
运行上一节的完整脚本后,你会得到两组数字:candidates 的 token 总和,以及 top_docs 的 token 总和。原文给出的社区统计口径是 50,000 降到 2,500,节省约 95%,延迟从秒级降到几百毫秒。你自己的语料不同,数字会有浮动,但趋势应该一致:精排后进入 LLM 的 token 明显变少,响应体感更快。如果 before 和 after 差距不大,多半是向量召回阶段本身召回了大量同质段落,先回去检查索引分块大小。
7.2 打开控制台看这次调用有没有记账
Codex 走 TaoToken 发出的请求,都会在控制台用量页留下记录。打开 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 的用量页,找到刚才调试精排代码那段时间的调用,看实际消耗的 token 数和请求次数。这样你能直观看到:一次完整的精排调试对话,到底消耗了多少 token,其中多少花在分析代码上,多少花在代码补全上。对账时如果发现某个环节 token 异常高,回到对话记录里找那轮上下文特别长的消息。
8. 先别换窗口,先看塞进去的东西
如果你的 Agent 回答质量不稳,先别急着换更大的上下文窗口。把检索结果摊开,数一数有多少段落和当前问题无关,比换模型更能解决问题。这次用 Codex 跑通精排链路后,我最大的感受是:上下文窗口变大不意味着可以往里面随便堆东西,10 条准确的结果,远胜 200 条模糊的噪音。正式进入长期调试前,可以用 TaoToken 模型对话 发一条测试消息,确认同一把 Key 和模型 ID 任选都能用;要写大量代码可以看 Coding Plan;新 Key 在 控制台 API Keys 创建。若你主力工具是 Claude Code,接入参数对照 接入文档 来填。