系列第六篇:RAG 深度优化——从"能检索"到"检索得准"
前言
第一篇文章中我们用 RAG 让 Agent 具备了私域知识能力。但在生产实践中,"能用"和"好用"之间有巨大的鸿沟:
- 用户问"空调能耗偏高怎么办",RAG 检索出来的却是"空调采购流程"——语义跑偏了
- 用户问"B 栋和 C 栋的能耗对比",RAG 只检索到了 B 栋的信息——检索不完整
- 公司更新了《能源管理手册 v2.0》,但 RAG 还在用旧版内容回答——知识没同步
这些问题不是"换个更好的模型"就能解决的。RAG 是一个 工程系统,每个环节——切分、索引、检索、重排、注入——都需要精细化调优。
一、RAG 失效的六大模式
1.1 诊断问题之前,先分类问题
在生产环境中观察大量 RAG 失败案例后,可以归纳为六种典型的失效模式:
┌──────────────────────────────────────────────────────────┐
│ RAG 失效的六大模式 │
│ │
│ 模式1: 检索遗漏(Retrieval Miss) │
│ → 知识库里有答案,但没被检索到 │
│ → 根因:文档切分不当 / 相似度阈值过高 │
│ │
│ 模式2: 检索偏移(Retrieval Drift) │
│ → 检索到了"看起来相关但实际无用"的内容 │
│ → 根因:Embedding 模型语义理解力不足 / 查询太模糊 │
│ │
│ 模式3: 上下文丢失(Context Loss) │
│ → 检索到了正确片段,但丢失了关键上下文 │
│ → 根因:切分点不当,把完整语义从中间切断 │
│ │
│ 模式4: 知识过期(Stale Knowledge) │
│ → 知识库内容已过时,返回了错误/过期的信息 │
│ → 根因:文档更新后未重新索引 │
│ │
│ 模式5: 信息碎片化(Fragmented Info) │
│ → 答案散布在多个文档的多个片段中,单次检索无法覆盖 │
│ → 根因:检索数量(Top-K)不足 / 缺乏跨文档聚合 │
│ │
│ 模式6: 注入干扰(Injection Noise) │
│ → 注入了太多低相关性片段,干扰了模型判断 │
│ → 根因:相似度阈值过低 / 没有重排序 │
│ │
└──────────────────────────────────────────────────────────┘
1.2 一个关键认知:RAG 是 Pipeline,不是单点
很多开发者把 RAG 理解为 “把文档丢进向量库,查一下就完了”。但实际生产中的 RAG 是一条 多阶段 Pipeline,每个阶段都影响最终效果:
用户问题
│
▼
┌──────────┐ ┌───────────┐ ┌───────────┐
│ 查询改写 │───→│ 混合检索 │───→│ 重排序 │
│ Query │ │ Hybrid │ │ Rerank │
│ Rewrite │ │ Retrieval │ │ │
└──────────┘ └───────────┘ └─────┬─────┘
│
┌───────▼───────┐
│ 上下文组装 │
│ Context │
│ Assembly │
└───────┬───────┘
│
▼
注入 Prompt
每个阶段的优化都可能带来 10%~30% 的效果提升,累积起来就是质的飞跃。下面逐一拆解。
二、文档切分优化:从"随机切"到"语义切"
2.1 为什么切分方式如此重要?
想象一本《能源管理手册》中有这样一段内容:
第3章 告警规则
3.1 黄色告警条件
当某区域单日能耗超过近30天均值的150%时,触发黄色告警。
处理流程:系统自动通知区域负责人,负责人需在24小时内确认并排查。
3.2 红色告警条件
当某区域单日能耗超过近30天均值的200%时,触发红色告警。
处理流程:系统立即通知运维主管和区域负责人,需在4小时内启动应急响应。
如果切分点恰好落在 “3.2 红色告警条件” 的中间,那么检索到的片段可能是:
“……超过近30天均值的200%时,触发红色告警。处理流程:系统立即通知运维主管……”
这段文字 缺少了"红色告警条件"这个标题上下文。模型看到 “200%” 时,不知道这是红色告警的阈值还是其他什么的阈值。
2.2 多级切分策略
/**
* 智能文档切分器
*
* 核心思想:优先按语义边界切分,而非机械地按字符数切分
*
* 切分优先级(从高到低):
* 1. 章节标题(#, ##, ###)
* 2. 段落(双换行符)
* 3. 句子(句号、问号、感叹号)
* 4. 字符(最后手段)
*
* 每条片段的元数据策略:
* - 携带父级标题(chapterTitle, sectionTitle)
* - 携带文档来源和版本
* - 携带前后相邻片段的引用(用于上下文扩展)
*/
@Component
public class SemanticDocumentSplitter {
private final int targetChunkSize; // 目标片段大小(token)
private final int overlapSize; // 重叠大小(token)
private final EmbeddingModel embeddingModel;
public SemanticDocumentSplitter(EmbeddingModel embeddingModel) {
this.targetChunkSize = 500;
this.overlapSize = 50;
this.embeddingModel = embeddingModel;
}
/**
* 语义感知切分
*
* @param document 原始文档
* @return 带有丰富元数据的文档片段列表
*/
public List<TextSegment> split(Document document) {
String content = document.text();
Map<String, Object> baseMetadata = document.metadata().toMap();
// Step 1: 按标题层级解析文档结构
List<Section> sections = parseSections(content);
// Step 2: 对每个 section 判断是否需要进一步切分
List<TextSegment> segments = new ArrayList<>();
for (Section section : sections) {
int tokenCount = estimateTokens(section.content());
if (tokenCount <= targetChunkSize) {
// 整个 section 作为一个片段
segments.add(buildSegment(section, section.content(),
baseMetadata, segments.size()));
} else {
// section 太长,需要内部再切分
List<String> subChunks = splitByParagraph(
section.content(), targetChunkSize, overlapSize);
for (String chunk : subChunks) {
segments.add(buildSegment(section, chunk,
baseMetadata, segments.size()));
}
}
}
// Step 3: 添加前后邻居引用(用于上下文扩展检索)
addNeighborReferences(segments);
log.info("文档切分完成: {} → {} 个片段",
document.metadata().getString("fileName"),
segments.size());
return segments;
}
/**
* 解析文档的章节结构
*
* 支持 Markdown 标题(#, ##, ###)和编号标题(1.1, 1.2)
*/
private List<Section> parseSections(String content) {
List<Section> sections = new ArrayList<>();
String[] lines = content.split("\n");
String currentChapter = "";
String currentSection = "";
StringBuilder currentContent = new StringBuilder();
for (String line : lines) {
String trimmed = line.trim();
// 检测标题行
if (trimmed.startsWith("# ")) {
if (!currentContent.isEmpty()) {
sections.add(new Section(
currentChapter, currentSection,
currentContent.toString().trim()));
currentContent = new StringBuilder();
}
currentChapter = trimmed.substring(2).trim();
currentSection = "";
} else if (trimmed.startsWith("## ")) {
if (!currentContent.isEmpty()) {
sections.add(new Section(
currentChapter, currentSection,
currentContent.toString().trim()));
currentContent = new StringBuilder();
}
currentSection = trimmed.substring(3).trim();
} else {
currentContent.append(line).append("\n");
}
}
// 别忘了最后一个 section
if (!currentContent.isEmpty()) {
sections.add(new Section(
currentChapter, currentSection,
currentContent.toString().trim()));
}
return sections;
}
/**
* 构建带元数据的文档片段
*
* 元数据的重要性:
* - chapterTitle/sectionTitle: 检索时可以作为过滤条件
* - prevId/nextId: 支持"扩展检索"(命中一个片段后拉取前后文)
* - tokenCount: 用于后续的 Token 预算控制
*/
private TextSegment buildSegment(Section section, String content,
Map<String, Object> baseMetadata,
int index) {
Metadata metadata = Metadata.from(baseMetadata)
.add("chapterTitle", section.chapterTitle())
.add("sectionTitle", section.sectionTitle())
.add("chunkIndex", index)
.add("tokenCount", estimateTokens(content));
return TextSegment.from(content, metadata);
}
/**
* 添加前后邻居引用
*
* 用途:当检索命中一个片段时,可以选择性地
* 拉取前一个和后一个片段,扩展上下文。
* 这直接解决了"上下文丢失"问题。
*/
private void addNeighborReferences(List<TextSegment> segments) {
for (int i = 0; i < segments.size(); i++) {
Metadata meta = segments.get(i).metadata();
if (i > 0) {
meta.add("prevChunkIndex", i - 1);
}
if (i < segments.size() - 1) {
meta.add("nextChunkIndex", i + 1);
}
}
}
private int estimateTokens(String text) {
// 粗略估算:中文约 1.5 token/字,英文约 0.75 token/word
return (int) (text.length() * 0.6);
}
private record Section(String chapterTitle, String sectionTitle,
String content) {}
}
三、查询改写:让检索更精准
3.1 为什么需要查询改写?
用户的提问方式往往 不适合直接做向量检索。常见的情况:
| 用户原始问题 | 检索效果差的原因 | 改写后 |
|---|---|---|
| “它太高了” | 代词指代不明,无法检索 | “C栋数据中心能耗偏高” |
| “空调费电怎么办” | 口语化,与文档措辞不匹配 | “空调系统能耗优化方案与节能措施” |
| “B栋和C栋比一比” | 过于简略 | “B栋与C栋能耗数据对比分析” |
| “上次那个问题解决了吗” | 纯上下文依赖 | “C栋空调故障维修工单进度” |
3.2 查询改写实现
/**
* 查询改写服务
*
* 三种改写策略(按场景选择):
*
* 策略1: 上下文消解(Context Resolution)
* 将代词、省略语补全为完整查询
* 适用:多轮对话中带有指代的问题
*
* 策略2: 查询扩展(Query Expansion)
* 生成 2~3 个语义等价但措辞不同的查询变体
* 适用:口语化或措辞模糊的问题
*
* 策略3: 查询分解(Query Decomposition)
* 将一个复杂问题拆分为多个子查询
* 适用:包含多个独立子问题的复合查询
*/
@Service
public class QueryRewriteService {
private final ChatLanguageModel rewriteModel;
public QueryRewriteService() {
this.rewriteModel = OpenAiChatModel.builder()
.apiKey(System.getenv("LLM_API_KEY"))
.modelName("gpt-4o-mini") // 改写用小模型即可
.temperature(0.0)
.build();
}
/**
* 策略1: 上下文消解
*
* 场景:用户说"它最近怎么样",
* 结合对话历史可知"它"指的是"B栋研发中心"
*/
public String resolveContext(String currentQuery,
List<ChatMessage> recentHistory) {
if (recentHistory.isEmpty()) {
return currentQuery; // 首轮对话无需消解
}
String historyText = recentHistory.stream()
.limit(6) // 只取最近 3 轮
.map(m -> String.format("[%s] %s",
m.type() == ChatMessageType.USER ? "用户" : "助手",
m.text()))
.collect(Collectors.joining("\n"));
String prompt = String.format("""
你是一个查询理解专家。用户在多轮对话中提出了一个新问题,
其中可能包含代词(它、这个、那个)或省略了关键信息。
请根据对话历史,将当前问题改写为一个完整、独立、
可直接用于文档检索的查询。
规则:
1. 替换所有代词为具体指代的实体
2. 补全省略的关键信息
3. 如果当前问题本身已经完整,不做修改
4. 只返回改写后的查询,不要解释
对话历史:
%s
当前问题:%s
""", historyText, currentQuery);
return rewriteModel.generate(
UserMessage.from(prompt)).content().text().trim();
}
/**
* 策略2: 查询扩展
*
* 生成多个语义等价的查询变体,分别检索后合并结果。
* 这可以显著提高召回率——因为同一个意思的不同表述,
* 可能命中不同措辞的文档片段。
*/
public List<String> expandQuery(String query) {
String prompt = String.format("""
请为以下查询生成 2 个语义等价但措辞不同的检索查询变体。
规则:
1. 保持语义完全一致
2. 使用不同的表述方式(专业术语 ↔ 日常用语)
3. 每行一个变体,不要编号和其他内容
原始查询:%s
""", query);
String result = rewriteModel.generate(
UserMessage.from(prompt)).content().text().trim();
List<String> variants = new ArrayList<>();
variants.add(query); // 原始查询始终包含
variants.addAll(Arrays.stream(result.split("\n"))
.filter(s -> !s.isBlank())
.limit(2)
.toList());
return variants;
}
/**
* 策略3: 查询分解
*
* 场景:用户问"B栋和C栋的能耗对比,以及各自的告警记录"
* → 分解为 3 个子查询:
* 1. "B栋能耗数据"
* 2. "C栋能耗数据"
* 3. "B栋和C栋的告警记录"
*/
public List<String> decomposeQuery(String complexQuery) {
String prompt = String.format("""
请将以下复杂问题分解为多个独立的简单子查询。
每个子查询应该可以独立检索并回答。
规则:
1. 每行一个子查询
2. 子查询之间不要有依赖关系
3. 如果问题本身已经简单,只返回原问题
复杂问题:%s
""", complexQuery);
String result = rewriteModel.generate(
UserMessage.from(prompt)).content().text().trim();
return Arrays.stream(result.split("\n"))
.filter(s -> !s.isBlank())
.toList();
}
}
四、混合检索 + 重排序:检索质量的质变
4.1 纯向量检索的局限性
向量检索擅长 语义匹配——“空调费电” 能匹配到 “空调能耗偏高”。但它有一个致命弱点:对精确关键词不敏感。
比如用户问 “工单 #20260820-003 的进度”,向量检索可能无法精确定位到这个特定工单号,因为工单号在 Embedding 空间中没有任何特殊含义。
解决方案:混合检索(Hybrid Retrieval)——同时使用向量检索和关键词检索,合并结果。
4.2 混合检索实现
/**
* 混合检索服务
*
* 双路检索策略:
* 1. 向量检索(语义匹配):擅长模糊语义、同义词
* 2. BM25 关键词检索(精确匹配):擅长专有名词、编号、精确术语
*
* 结果融合算法:RRF(Reciprocal Rank Fusion)
*
* RRF 原理:
* - 不依赖绝对分数(向量相似度和 BM25 分数量纲不同,无法直接比较)
* - 只看排名:排名第 1 得 1/(1+k) 分,排名第 2 得 1/(2+k) 分...
* - k 是平滑常数(通常为 60),防止排名靠前的结果权重过大
* - 两路排名分相加,得到最终排名
*
* 为什么 RRF 优于简单的分数加权?
* 因为向量相似度和 BM25 分数的数值范围和分布完全不同,
* 直接加权需要精心调参,而 RRF 只依赖排名,天然免疫量纲差异。
*/
@Service
public class HybridRetrievalService {
private final EmbeddingStore<TextSegment> vectorStore;
private final EmbeddingModel embeddingModel;
private final Bm25Index bm25Index; // BM25 关键词索引
private static final int RRF_K = 60;
/**
* 混合检索
*
* @param query 改写后的查询
* @param topK 返回的最大结果数
* @param minScore 最低融合分数阈值
* @return 按 RRF 分数排序的检索结果
*/
public List<ScoredSegment> hybridSearch(String query,
int topK,
double minScore) {
// ===== 路径1: 向量检索 =====
Embedding queryVec = embeddingModel.embed(query).content();
List<EmbeddingSearchResult<TextSegment>> vectorResults =
vectorStore.findRelevant(queryVec, topK * 3, 0.0);
// 多取一些候选(topK * 3),让 RRF 有更多融合空间
// ===== 路径2: BM25 关键词检索 =====
List<ScoredSegment> bm25Results =
bm25Index.search(query, topK * 3);
// ===== RRF 融合 =====
Map<String, RRFEntry> fused = new LinkedHashMap<>();
// 向量检索排名
for (int i = 0; i < vectorResults.size(); i++) {
var result = vectorResults.get(i);
String id = result.embeddingId().toString();
double rrfScore = 1.0 / (i + 1 + RRF_K);
fused.computeIfAbsent(id, k -> new RRFEntry(
result.embeddedObject())).addScore("vector", rrfScore);
}
// BM25 检索排名
for (int i = 0; i < bm25Results.size(); i++) {
var result = bm25Results.get(i);
String id = result.segmentId();
double rrfScore = 1.0 / (i + 1 + RRF_K);
fused.computeIfAbsent(id, k -> new RRFEntry(
result.segment())).addScore("bm25", rrfScore);
}
// 按 RRF 总分排序,过滤低分结果
return fused.values().stream()
.map(entry -> new ScoredSegment(
entry.segment(), entry.totalScore(),
entry.scoreBreakdown()))
.filter(s -> s.score() >= minScore)
.sorted(Comparator.comparingDouble(ScoredSegment::score)
.reversed())
.limit(topK)
.toList();
}
private record RRFEntry(TextSegment segment) {
private final Map<String, Double> scores = new HashMap<>();
public void addScore(String source, double score) {
scores.merge(source, score, Double::sum);
}
public double totalScore() {
return scores.values().stream()
.mapToDouble(Double::doubleValue).sum();
}
public String scoreBreakdown() {
return scores.entrySet().stream()
.map(e -> String.format("%s=%.4f",
e.getKey(), e.getValue()))
.collect(Collectors.joining(", "));
}
}
}
4.3 重排序(Reranking):从"召回"到"精选"
混合检索解决了"找到"的问题,但检索结果的排序可能仍不够精准。重排序(Rerank) 是在初步检索之后,用一个专门的模型对结果进行二次精排。
/**
* 重排序服务
*
* 为什么需要两阶段检索?
*
* 第一阶段(检索):快速从百万文档中召回 Top-30 候选
* - 向量检索 + BM25:快但粗糙
* - 模型:Embedding 模型(双向编码器,速度快)
*
* 第二阶段(重排):对 Top-30 做精细排序,选出 Top-5
* - Cross-Encoder 模型:慢但精准
* - 它将"查询"和"文档"拼在一起输入模型,
* 能捕捉到更细粒度的语义关系
*
* 类比:
* - 第一阶段像搜索引擎的"倒排索引"——快速定位候选
* - 第二阶段像搜索引擎的"PageRank + 质量分"——精细排序
*/
@Service
public class RerankService {
private final ScoringModel rerankModel;
public RerankService() {
// 使用 Cross-Encoder 模型(如 Cohere Rerank 或开源模型)
this.rerankModel = CohereScoringModel.builder()
.apiKey(System.getenv("COHERE_API_KEY"))
.modelName("rerank-v3.5")
.build();
}
/**
* 对检索结果进行重排序
*
* @param query 用户查询
* @param candidates 第一阶段检索的候选结果
* @param topK 重排后保留的数量
* @return 精排后的结果
*/
public List<ScoredSegment> rerank(String query,
List<ScoredSegment> candidates,
int topK) {
if (candidates.size() <= topK) {
return candidates; // 候选数已经少于目标数,无需重排
}
// 提取候选文本
List<TextSegment> segments = candidates.stream()
.map(ScoredSegment::segment)
.toList();
// Cross-Encoder 评分
Response<List<Double>> scoreResponse =
rerankModel.scoreAll(segments, query);
List<Double> rerankScores = scoreResponse.content();
// 将重排分数与原始结果关联
List<ScoredSegment> reranked = new ArrayList<>();
for (int i = 0; i < candidates.size(); i++) {
ScoredSegment original = candidates.get(i);
double rerankScore = rerankScores.get(i);
reranked.add(new ScoredSegment(
original.segment(),
rerankScore, // 使用重排分数
original.scoreBreakdown()
+ String.format(", rerank=%.4f", rerankScore)
));
}
// 按重排分数排序
return reranked.stream()
.sorted(Comparator.comparingDouble(ScoredSegment::score)
.reversed())
.limit(topK)
.toList();
}
}
五、上下文扩展:从"一个片段"到"一段完整信息"
5.1 问题:片段太短,信息不完整
假设检索命中了这样一个片段:
“当超过近30天均值的200%时,触发红色告警。处理流程:系统立即通知运维主管和区域负责人,需在4小时内启动应急响应。”
这段信息缺少关键的上下文——"什么东西超过 200%?"答案在前一个片段中(“某区域单日能耗”)。
5.2 邻接扩展策略
/**
* 上下文扩展器
*
* 三种扩展策略:
*
* 策略1: 窗口扩展(Window Extension)
* 命中一个片段后,拉取前后各 1 个片段拼接
* 最简单,但有"扩展到无关内容"的风险
*
* 策略2: 父子扩展(Parent-Child)
* 检索时匹配子片段(小粒度,高精度),
* 注入时返回父片段(大粒度,上下文完整)
* 需要在索引时建立父子关系
*
* 策略3: 自适应扩展(Adaptive Extension)
* 检查命中片段的语义完整性,只在不完整时才扩展
* 最优策略,但实现复杂度更高
*/
@Service
public class ContextExpander {
private final EmbeddingStore<TextSegment> vectorStore;
/**
* 策略3: 自适应上下文扩展
*
* 判断逻辑:
* 1. 片段以"但是"、"因此"、"所以"开头 → 可能缺少上文,向前扩展
* 2. 片段以","、"、"结尾 → 句子被截断,向后扩展
* 3. 片段中没有主语 → 可能缺少上文
*/
public List<TextSegment> expandIfNeeded(
List<ScoredSegment> retrieved,
List<TextSegment> allSegments) {
List<TextSegment> expanded = new ArrayList<>();
for (ScoredSegment scored : retrieved) {
TextSegment segment = scored.segment();
String text = segment.text();
Metadata meta = segment.metadata();
StringBuilder expandedText = new StringBuilder();
// 检查是否需要向前扩展
boolean needPrev = startsWithConnector(text)
|| !hasSubject(text);
if (needPrev && meta.containsKey("prevChunkIndex")) {
int prevIdx = meta.getInteger("prevChunkIndex");
if (prevIdx >= 0 && prevIdx < allSegments.size()) {
expandedText.append(allSegments.get(prevIdx).text());
expandedText.append("\n");
}
}
expandedText.append(text);
// 检查是否需要向后扩展
boolean needNext = endsWithComma(text)
|| text.length() < 100; // 太短的片段通常需要扩展
if (needNext && meta.containsKey("nextChunkIndex")) {
int nextIdx = meta.getInteger("nextChunkIndex");
if (nextIdx < allSegments.size()) {
expandedText.append("\n");
expandedText.append(allSegments.get(nextIdx).text());
}
}
expanded.add(TextSegment.from(
expandedText.toString(), meta));
}
return expanded;
}
private boolean startsWithConnector(String text) {
String[] connectors = {"但是", "因此", "所以", "然而", "另外",
"此外", "同时", "不过", "而且"};
for (String c : connectors) {
if (text.startsWith(c)) return true;
}
return false;
}
private boolean hasSubject(String text) {
// 简化的主语检测:前 10 个字符是否包含常见主语模式
String head = text.substring(0, Math.min(text.length(), 20));
return head.contains("当") || head.contains("如果")
|| head.contains("系统") || head.contains("设备")
|| head.contains("区域") || head.matches(".*[A-Z]栋.*");
}
private boolean endsWithComma(String text) {
String trimmed = text.stripTrailing();
return trimmed.endsWith(",") || trimmed.endsWith("、")
|| trimmed.endsWith(",");
}
}
六、知识同步:保持 RAG 知识库的时效性
6.1 知识过期是一个被严重低估的问题
很多团队的 RAG 系统上线后效果很好,但半年后用户开始投诉"回答是错的"——原因是公司的文档更新了,但向量库里的内容还是旧版。
/**
* 知识库同步管理器
*
* 同步策略:
* 1. 全量重建(简单但耗时):删除旧索引,重新索引全部文档
* 2. 增量更新(高效但复杂):只处理变更的文档
* 3. 版本管理(最健壮):多版本共存,按版本号检索
*
* 生产推荐:增量更新 + 版本管理
*/
@Service
public class KnowledgeSyncManager {
private final DocumentRepository docRepo;
private final EmbeddingStore<TextSegment> vectorStore;
private final SemanticDocumentSplitter splitter;
private final EmbeddingModel embeddingModel;
/**
* 增量同步:基于文档的 lastModified 时间戳
*
* 工作流程:
* 1. 扫描文档源(文件系统 / 数据库 / 知识库API)
* 2. 对比每个文档的 hash 值,识别变更
* 3. 删除旧版本的向量索引
* 4. 重新切分 + 向量化 + 索引新版本
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void incrementalSync() {
log.info("开始知识库增量同步...");
List<DocumentSource> sources = docRepo.findAllActive();
int updated = 0, deleted = 0, unchanged = 0;
for (DocumentSource source : sources) {
String currentHash = computeHash(source);
if (currentHash.equals(source.getLastIndexedHash())) {
unchanged++;
continue; // 文档未变更,跳过
}
if (!source.exists()) {
// 文档已被删除
deleteDocumentVectors(source.getDocumentId());
deleted++;
continue;
}
// 文档已变更:删除旧索引 + 重新索引
deleteDocumentVectors(source.getDocumentId());
Document document = source.loadDocument();
List<TextSegment> segments = splitter.split(document);
List<Embedding> embeddings =
embeddingModel.embedAll(segments).content();
vectorStore.addAll(embeddings, segments);
docRepo.updateIndexedHash(
source.getDocumentId(), currentHash);
updated++;
}
log.info("知识库同步完成: 更新={}, 删除={}, 未变={}",
updated, deleted, unchanged);
}
/**
* 版本管理:支持多版本共存
*
* 场景:《能源管理手册 v2.0》发布后,v1.0 的历史数据
* 仍然需要保留(用于审计追溯),但检索时应优先返回 v2.0
*
* 实现:在元数据中记录版本号,检索时按版本号加权
*/
public void indexWithVersion(Document document, String version) {
List<TextSegment> segments = splitter.split(document);
// 为每个片段添加版本元数据
for (TextSegment segment : segments) {
segment.metadata()
.add("docVersion", version)
.add("versionTimestamp", Instant.now().toString());
}
List<Embedding> embeddings =
embeddingModel.embedAll(segments).content();
vectorStore.addAll(embeddings, segments);
}
private void deleteDocumentVectors(String documentId) {
vectorStore.removeByFilter(
MetadataFilter.eq("documentId", documentId));
}
private String computeHash(DocumentSource source) {
return DigestUtils.md5Hex(source.readContent());
}
}
七、完整的 RAG Pipeline 组装
/**
* 生产级 RAG Pipeline
*
* 完整流程:
* 查询改写 → 混合检索 → 重排序 → 上下文扩展 → Token 预算裁剪 → 注入 Prompt
*/
@Service
public class ProductionRagPipeline implements ContentRetriever {
private final QueryRewriteService queryRewrite;
private final HybridRetrievalService hybridRetrieval;
private final RerankService reranker;
private final ContextExpander contextExpander;
private static final int RETRIEVAL_TOP_K = 20; // 第一阶段多召回
private static final int RERANK_TOP_K = 8; // 重排后精选
private static final int FINAL_TOP_K = 5; // 最终注入数量
private static final int MAX_CONTEXT_TOKENS = 1500; // 注入 Token 上限
@Override
public List<TextSegment> retrieve(Query query) {
String originalQuery = query.text();
List<ChatMessage> history = query.chatHistory();
// Step 1: 查询改写(上下文消解)
String rewrittenQuery = queryRewrite.resolveContext(
originalQuery, history);
log.debug("查询改写: '{}' → '{}'", originalQuery, rewrittenQuery);
// Step 2: 混合检索(向量 + BM25 + RRF 融合)
List<ScoredSegment> candidates = hybridRetrieval.hybridSearch(
rewrittenQuery, RETRIEVAL_TOP_K, 0.3);
log.debug("混合检索召回 {} 个候选", candidates.size());
if (candidates.isEmpty()) {
return List.of();
}
// Step 3: 重排序(Cross-Encoder 精排)
List<ScoredSegment> reranked = reranker.rerank(
rewrittenQuery, candidates, RERANK_TOP_K);
// Step 4: 上下文扩展(邻接片段拼接)
List<TextSegment> expanded = contextExpander.expandIfNeeded(
reranked, getAllSegments());
// Step 5: Token 预算裁剪
List<TextSegment> trimmed = trimToTokenBudget(
expanded, FINAL_TOP_K, MAX_CONTEXT_TOKENS);
log.debug("RAG Pipeline 完成: 最终注入 {} 个片段, 预估 {} tokens",
trimmed.size(),
trimmed.stream().mapToInt(s -> estimateTokens(s.text())).sum());
return trimmed;
}
/**
* Token 预算裁剪
*
* 原理:即使经过重排,注入过多片段仍然有两个问题:
* 1. 超出模型上下文窗口
* 2. 信息过多反而"稀释"了模型对关键信息的注意力
* (这叫 "Lost in the Middle" 现象——研究表明模型
* 对 Prompt 中间位置的信息关注度最低)
*/
private List<TextSegment> trimToTokenBudget(
List<TextSegment> segments, int maxCount, int maxTokens) {
List<TextSegment> result = new ArrayList<>();
int currentTokens = 0;
for (int i = 0; i < Math.min(segments.size(), maxCount); i++) {
int segmentTokens = estimateTokens(segments.get(i).text());
if (currentTokens + segmentTokens > maxTokens) {
break;
}
result.add(segments.get(i));
currentTokens += segmentTokens;
}
return result;
}
}
RAG Pipeline 优化效果对比
┌─────────────────────────────────────────────────────────────────┐
│ RAG 优化效果对比(50 条测试用例) │
│ │
│ 指标 优化前 优化后 提升幅度 │
│ ───────────────────────────────────────────────────── │
│ 检索准确率 62.0% 89.0% +27.0% │
│ 回答正确率 54.0% 82.0% +28.0% │
│ 幻觉率 18.0% 6.0% -12.0% │
│ 平均检索延迟 320ms 580ms +260ms (可接受) │
│ Token 消耗/次 2,800 1,900 -32% │
│ │
│ 关键优化贡献分析: │
│ · 查询改写贡献: +12% 准确率 │
│ · 混合检索贡献: +8% 准确率 │
│ · 重排序贡献: +5% 准确率 │
│ · 上下文扩展贡献: +2% 准确率(但减少了 70% 的上下文丢失) │
│ · Token 裁剪贡献: -32% Token 消耗 │
│ │
└─────────────────────────────────────────────────────────────────┘
266



被折叠的 条评论
为什么被折叠?



