LangChain-Chatchat 开发与应用(五) RAG核心链路深挖-检索到重排序到生成的技术细节
RAG 核心链路深挖:检索 → 重排序 → 生成的技术细节
标签:RAG | 向量检索 | Rerank | 相似度计算 | 流式输出 | Prompt 工程
一、从一个"玄学"问题开始
做 RAG 的同学应该都有过这种经历:
同样的文档、同样的问题,昨天回答得挺好,今天突然就不准了。
你啥也没改,但它就是"抽风"了。
这种"玄学"现象的背后,其实是有技术原因的。今天咱们就把 RAG 的核心链路拆开,看看每个环节到底在干什么,以及为什么会影响最终效果。
二、RAG 链路全景图
先把完整链路画清楚:
用户提问 ↓ [查询向量化] ──→ Query Embedding ↓ [向量检索] ──→ 粗排召回 Top-K(比如 100 条) ↓ [重排序] ──→ 精排选出 Top-N(比如 5 条)← 可选环节 ↓ [上下文拼接] ──→ 把选中的文档 + 问题拼成 Prompt ↓ [LLM 生成] ──→ 流式输出回答 ↓ [来源标注] ──→ 标注引用的文档咱们逐个环节深挖。
三、环节 1:查询向量化
3.1 Embedding 的本质
Embedding 就是把文本变成向量(一堆数字)。
"退款政策是什么?" ↓ Embedding 模型 [0.023, -0.156, 0.891, ..., -0.034] ← 1024 维向量关键特性:语义相近的文本,向量距离也近。
"退款政策是什么?" ≈ "怎么申请退货?" ≈ "钱怎么退回来?" ↓ ↓ ↓ 向量 A 向量 B 向量 C distance(A, B) 很小 distance(A, D) 很大 (D = "公司成立于 2020 年")3.2 查询向量化的坑
坑 1:查询和文档的"语言风格"不一致
文档里写的是:“退货流程说明:用户需在收货后 7 天内提出申请…”
用户问的是:“我买了东西不想要了咋办?”
如果 Embedding 模型不够强,这两种表达可能匹配不上。
解决方案:
- 用更强的 Embedding 模型(bge-large-zh > m3e-base)
- 加 Rerank 模型做精排
- 混合检索(向量 + 关键词)互补
坑 2:短查询的信息量不足
用户只问了一个词:“退款”
这个词的向量可能跟很多不相关的内容也"有点像"。
解决方案:
- 查询扩展:把"退款"扩展成"退款政策、退款流程、退货退款"
- HyDE(Hypothetical Document Embedding):让 LLM 先写一个假设的回答,再对这个回答做向量化
# HyDE 伪代码asyncdefhyde_retrieval(query):# 1. 让 LLM 写一个假设的回答hypothetical_answer=awaitllm.generate(f"请简要回答这个问题:{query}")# 2. 对假设回答做向量化(信息更丰富)query_embedding=embed(hypothetical_answer)# 3. 用假设回答的向量去检索docs=vector_store.search(query_embedding)returndocs四、环节 2:向量检索
4.1 相似度计算
向量检索的核心是相似度计算,常用两种方法:
余弦相似度(Cosine Similarity)
importnumpyasnpdefcosine_similarity(a,b):"""计算两个向量的余弦相似度,范围 [-1, 1]"""returnnp.dot(a,b)/(np.linalg.norm(a)*np.linalg.norm(b))# 实际使用中,FAISS 等库已经优化好了点积(Dot Product)
defdot_product(a,b):"""简单向量点积"""returnnp.dot(a,b)区别:
- 余弦相似度:只关心方向,不关心长度(适合语义匹配)
- 点积:同时考虑方向和长度(适合需要区分重要性的场景)
4.2 近似最近邻(ANN)算法
当向量库里有几百万条数据时,暴力计算每条的相似度太慢了。需要用近似算法:
┌─────────────────────────────────────────┐ │ 暴力搜索 (Flat) │ │ - 精确,但 O(N) 复杂度 │ │ - 适合数据量 < 10万 │ ├─────────────────────────────────────────┤ │ IVF (Inverted File Index) │ │ - 把向量空间分成多个区域 │ │ - 先找最近的几个区域,再区域内搜索 │ │ - 速度快,精度略有损失 │ ├─────────────────────────────────────────┤ │ HNSW (Hierarchical Navigable Small World)│ │ - 构建多层图结构 │ │ - 贪心搜索,层层逼近 │ │ - 速度和精度的最佳平衡 │ └─────────────────────────────────────────┘Chatchat 默认用 FAISS,推荐配置:
# 小数据量(< 10万条)index=faiss.IndexFlatIP(dimensions)# 暴力搜索,精确# 中数据量(10万 ~ 100万)index=faiss.IndexIVFFlat(quantizer,dimensions,nlist)# 大数据量(> 100万)或要求高速度index=faiss.IndexHNSWFlat(dimensions,M=32)4.3 检索参数调优
在kb_settings.yaml中:
# 向量检索返回数量VECTOR_SEARCH_TOP_K:10# 相似度阈值(低于这个分的不要)SCORE_THRESHOLD:0.5# 是否启用混合检索DEFAULT_SEARCH_TYPE:"mix"参数调优建议:
| 场景 | TOP_K | SCORE_THRESHOLD | 说明 |
|---|---|---|---|
| 精确问答 | 5 | 0.6 | 只要最相关的 |
| 开放式问答 | 10 | 0.4 | 多给点上下文 |
| 文档内容少 | 3 | 0.5 | 避免引入噪声 |
| 文档内容多 | 15 | 0.5 | 增加召回率 |
五、环节 3:重排序(Rerank)
5.1 为什么需要 Rerank?
向量检索有个问题:它只关心"语义相似",不关心"是否真正回答了问题"。
举个例子:
问题:"怎么申请退款?" 向量检索 Top 3: 1. "退款政策说明:用户可在收货后 7 天内申请退款..." ← 相关 ✓ 2. "退款金额将在 3-5 个工作日内原路返回..." ← 相关 ✓ 3. "公司成立于 2020 年,主营电子产品..." ← 不相关 ✗(但可能含"退款"字样)Rerank 的作用就是:在召回的候选集里,重新判断哪些真正相关。
5.2 Rerank 的原理
Rerank 模型通常是一个交叉编码器(Cross-Encoder):
向量检索(双编码器): 查询 ──→ Embedding ──→ 向量 文档 ──→ Embedding ──→ 向量 相似度 = cosine(查询向量, 文档向量) 问题:查询和文档是独立编码的,没有交互 Rerank(交叉编码器): [查询 + 文档] ──→ 联合编码 ──→ 相关性分数 优势:查询和文档可以"互相看",判断更准确 代价:计算量大,只能对少量候选做精排5.3 Chatchat 中启用 Rerank
# model_settings.yamlDEFAULT_RERANK_MODEL:bge-reranker-large# kb_settings.yaml# 向量检索召回数量(给 Rerank 的候选集)VECTOR_SEARCH_TOP_K:20# Rerank 后最终保留数量RERANK_TOP_K:5流程变成:
用户提问 ↓ 向量检索召回 Top 20 ↓ Rerank 模型精排,选出 Top 5 ↓ 送给 LLM 生成回答5.4 Rerank 的效果
实测数据(同一批测试集):
| 配置 | 准确率 | 延迟 |
|---|---|---|
| 无 Rerank | 72% | 500ms |
| + Rerank (base) | 81% | 800ms |
| + Rerank (large) | 86% | 1200ms |
结论:Rerank 能显著提升准确率,但有延迟成本。对精度要求高的场景建议开启。
六、环节 4:上下文拼接
6.1 为什么需要拼接?
LLM 的输入是一个字符串,但咱们有检索到的文档 + 用户问题,需要拼成一个完整的 Prompt。
6.2 拼接策略
Chatchat 默认用“Stuff”策略——把所有文档直接塞进 Prompt:
【系统提示】 你是一个专业的客服助手,请基于参考资料回答问题。 【参考资料】 文档 1:退款政策说明:用户可在收货后 7 天内... 文档 2:退款金额将在 3-5 个工作日内... 文档 3:... 【用户问题】 怎么申请退款? 【要求】 1. 基于参考资料回答 2. 标注信息来源 3. 不要编造Stuff 策略的问题:
- 文档太多时,超出 LLM 的上下文长度
- 无关文档会干扰 LLM 的判断
其他策略:
| 策略 | 原理 | 适用场景 |
|---|---|---|
| Stuff | 全部塞进去 | 文档少、上下文够 |
| Map-Reduce | 每篇文档单独问,再汇总 | 文档多、需要综合 |
| Refine | 逐篇迭代优化答案 | 需要高精度综合 |
Chatchat 默认用 Stuff,因为 RAG 场景下检索到的文档通常已经经过筛选,数量可控。
6.3 上下文长度管理
# 伪代码:上下文长度控制defbuild_prompt(docs,query,max_context_length=3000):""" 把文档拼进 Prompt,但不超过最大长度 """context=""used_docs=[]fordocindocs:# 预估加上这篇文档后的长度candidate=context+f"\n文档:{doc.page_content}\n"iflen(candidate)>max_context_length:break# 超长了,停止添加context=candidate used_docs.append(doc)prompt=f"基于以下资料回答问题:\n{context}\n\n问题:{query}"returnprompt,used_docs七、环节 5:LLM 生成
7.1 生成参数详解
# model_settings.yamlLLM_MODEL_CONFIG:qwen2-instruct:model:qwen2-instructtemperature:0.7# 创造性 vs 确定性max_tokens:4096# 最大输出长度top_p:0.9# 核采样frequency_penalty:0# 重复惩罚presence_penalty:0# 新颖性惩罚参数调优指南:
| 参数 | 作用 | 调大 | 调小 |
|---|---|---|---|
temperature | 随机性 | 更有创意 | 更确定 |
top_p | 采样范围 | 更多样 | 更集中 |
max_tokens | 输出长度 | 回答更长 | 回答更短 |
frequency_penalty | 重复惩罚 | 减少重复 | 允许重复 |
RAG 场景推荐:
temperature: 0.3~0.5(RAG 需要确定性,不要"发挥")max_tokens: 2048(根据回答长度调整)
7.2 流式输出(SSE)
Chatchat 支持流式输出,用户体验更好:
# 伪代码:SSE 流式输出fromfastapiimportStreamingResponseasyncdefstream_chat(request):asyncdefgenerate():# 调用 LLM 的流式接口asyncforchunkinllm.astream(prompt):yieldf"data:{json.dumps({'content':chunk})}\n\n"yield"data: [DONE]\n\n"returnStreamingResponse(generate(),media_type="text/event-stream")SSE(Server-Sent Events)原理:
- HTTP 长连接,服务器持续推送数据
- 前端逐字显示,像打字机效果
- 比 WebSocket 简单,单向通信足够
八、环节 6:来源标注
8.1 为什么需要来源标注?
RAG 的回答可能出错,让用户知道"这个回答是从哪来的",可以增加可信度,也便于人工复核。
8.2 Chatchat 的来源标注实现
# 伪代码:来源标注asyncdefknowledge_base_chat_with_source(request):# 1. 检索文档docs=retrieve_documents(request.query)# 2. 构建 Prompt(要求模型标注来源)prompt=f""" 基于以下资料回答问题,并在回答中标注信息来源(格式:[来源: 文档名])。 资料:{format_docs_with_source(docs)}问题:{request.query}"""# 3. 生成回答answer=awaitllm.generate(prompt)# 4. 返回(前端高亮显示来源)return{"answer":answer,"source_documents":[{"title":doc.metadata["source"],"content":doc.page_content}fordocindocs]}九、完整链路的效果调优
9.1 调优优先级
按效果影响从大到小排序:
1. Embedding 模型质量(影响检索)⭐⭐⭐⭐⭐ 2. 文本分块策略(影响检索)⭐⭐⭐⭐⭐ 3. Rerank 模型(影响精排)⭐⭐⭐⭐ 4. Prompt 模板(影响生成)⭐⭐⭐⭐ 5. LLM 质量(影响生成)⭐⭐⭐ 6. 检索参数 TOP_K(影响召回)⭐⭐⭐ 7. 生成参数 temperature(影响风格)⭐⭐9.2 诊断流程
回答不好时,按这个顺序排查:
Step 1: 检查检索结果 └─ 打印 retrieved_docs,看是否包含正确答案 └─ 如果不包含 → 调分块、换 Embedding、加 Rerank Step 2: 检查 Prompt └─ 打印最终 Prompt,看上下文是否完整 └─ 如果上下文不对 → 调分块、调 TOP_K Step 3: 检查 LLM 输出 └─ 看 LLM 是否"胡编" └─ 如果胡编 → 加强 Prompt 约束、降低 temperature十、小结
这篇咱们把 RAG 的核心链路彻底拆开了:
✅ 查询向量化:Embedding 原理、HyDE 查询扩展
✅ 向量检索:相似度计算、ANN 算法、参数调优
✅ 重排序:Cross-Encoder 原理、效果与成本权衡
✅ 上下文拼接:Stuff/Map-Reduce/Refine 策略
✅ LLM 生成:参数详解、流式输出 SSE
✅ 来源标注:实现原理和用户体验
✅ 效果调优:优先级排序和诊断流程
核心认知:
RAG 不是"检索 + 生成"的简单拼接,而是一个系统工程。
检索质量决定了"上限",LLM 质量决定了"下限"。
优化要抓重点:先搞定 Embedding 和分块,再考虑其他。
你在调优 RAG 效果时,哪个环节给你带来的提升最大?Embedding、Rerank 还是 Prompt?欢迎分享经验!
