RAG优化优先级:先把文本切分做好,再谈高级策略
AIAI Summary (BLUF)
本文系统梳理了17种经过验证的RAG优化方案,涵盖文档处理、检索增强和生成优化三个阶段,包括文本切分、语义拆分、上下文增强、二次排序、HyDE、Fusion等关键技术。每项策略均给出难度、效果、优先级和实施时间建议,并附有实现思路和代码示例,适合需要系统性提升RAG系统性能的开发者参考。
核心洞察
先说结论:这篇文章最值钱的是一张表,就是策略分类里的优先级表。17 种技术本身不算稀奇,真正拉开差距的是先做哪个后做哪个。见过太多团队一上来就啃知识图谱、CRAG一种动态RAG实现方案,在工作流中检索后评估文档质量,并支持网络搜索作为兜底方案。,折腾两三周,检索准确率还是上不去,问题往往不在高级策略,在最基础的切分上。
核心结论
RAGRetrieval-Augmented Generation - an AI framework that combines information retrieval with language generation to produce more accurate and contextually relevant responses. 检索质量的关键在于最基础的文本切分优化,而非高级策略:固定 1000 字无重叠切分的检索准确率约 60%,而按文档类型自适应切分可提升至约 85%。
优先级比技术本身更重要:文本切分优化、上下文增强等基础策略仅需 1 天实施且优先级为高,而知识图谱、CRAG 等高级策略需 2 周以上且优先级为低,团队应避免跳过基础手段直接上高级策略。
语义拆分可在智能切分基础上再将检索准确率提升 15-20 个百分点,但每个句子需单独计算 embeddingA mathematical representation of data (text, images, etc.) in a continuous vector space where semantic similarity corresponds to spatial proximity.,耗时显著,更适合离线处理或对延迟不敏感的场景。
17 种策略按阶段分为文档处理(6 种)、检索增强(7 种)、生成优化(4 种),其中查询转换跨切分与检索两个阶段;优先级表中标记为高的 7 项策略(文本切分、上下文增强、查询转换、二次排序、上下文压缩、场景细分、Fusion 融合)投入产出比最高。
RAG 高级优化策略大全
17 种经过验证的 RAG 优化手段,从基础到高级全覆盖。每种策略标了难度、效果、优先级和实施时间,适合已经在跑 RAG 但效果不理想的团队。建议按优先级从高到低逐项实施,直接跳过基础手段上高级策略,大概率要返工。
目录
- 策略分类
- 一、文档处理优化
- 二、检索优化
- 三、生成优化
- 四、高级技术
- 实施优先级
- 综合方案
策略分类
按优化阶段分类
文档处理(6种) 检索增强(7种) 生成优化(4种)
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1.文本切分 │ │ 7.二次排序 │ │ 9.上下文压缩 │
│ 2.语义拆分 │ │ 8.连续片段 │ │ 16.Fusion融合 │
│ 3.上下文增强 │ │ 10.用户反馈 │ │ 17.CRAG纠错 │
│ 4.添加描述 │ → │ 11.场景细分 │ → │ 13.知识图谱 │
│ 5.文档增强 │ │ 12.自我检索 │ └──────────────┘
│ 14.层次索引 │ │ 15.HyDE │
└──────────────┘ └──────────────┘
特殊技术:6.查询转换重写(跨阶段)
17 种策略按阶段分成三块,文档处理 6 种,检索增强 7 种,生成优化 4 种。查询转换比较特殊,它横跨切分和检索两个阶段,表里单列出来。
按难度和效果分类
| 策略 | 难度 | 效果 | 优先级 | 实施时间 |
|---|---|---|---|---|
| 1. 文本切分优化 | 低 | 强 | 高 | 1 天 |
| 2. 语义拆分 | 较低 | 强 | 中 | 2 天 |
| 3. 上下文增强 | 较低 | 很强 | 高 | 1 天 |
| 4. 添加描述标题 | 较低 | 强 | 中 | 2 天 |
| 5. 文档增强 | 较低 | 中等 | 低 | 3 天 |
| 6. 查询转换 | 中等 | 很强 | 高 | 2 天 |
| 7. 二次排序 | 中等 | 很强 | 高 | 3 天 |
| 8. 连续片段 | 较低 | 强 | 中 | 2 天 |
| 9. 上下文压缩 | 中等 | 很强 | 高 | 3 天 |
| 10. 用户反馈 | 中等 | 强 | 中 | 5 天 |
| 11. 场景细分 | 中等 | 很强 | 高 | 4 天 |
| 12. 自我检索 | 较高 | 强 | 中 | 5 天 |
| 13. 知识图谱 | 高 | 很强 | 低 | 2 周以上 |
| 14. 层次索引 | 中等 | 强 | 中 | 3 天 |
| 15. HyDEA technique that generates hypothetical answers to improve query matching in retrieval systems. | 中等 | 强 | 中 | 2 天 |
| 16. Fusion 融合 | 较高 | 很强 | 高 | 4 天 |
| 17. CRAG 纠错 | 较高 | 很强 | 中 | 5 天 |
难度按实施工作量估的,效果综合了检索准确率和生成质量两个维度。实施时间默认一个熟悉代码的工程师全职投入,拿团队协作的节奏来套会不准。
这里有个容易踩的坑。难度和效果不是简单的正比关系,知识图谱和 CRAG 确实猛,但那是建立在基础手段都压榨干净的前提上的。优先级标高的那几项投入产出比最高,建议先做。
一、文档处理优化
策略 1:文本切分优化
RAG 的检索质量,七成取决于文档怎么切。切太粗,一个片段混进多个主题,检索噪声大。切太细,语义被截断,上下文接不上。很多项目上线之后效果差,查到最后发现切分参数用的还是默认值。
关键参数三个:chunk_size 控制片段长度,chunk_overlap 控制重叠量,separators 控制分隔符优先级。中文语料和英文语料的优先级很不一样,英文按句点和换行切就够,中文还得考虑分号、引号这些情况。
实现上做个带预设参数的切分器就行。技术文档用 800 字的块,问答对用 300 字,法律文书用 1000 字。分隔符都匹配不上就按字符硬切,靠 overlap 补语义衔接。
/**
* 智能文本切分
* 根据文档类型自适应调整参数
*/
class SmartTextSplitter {
constructor(documentType = 'general') {
// 根据文档类型预设参数
this.configs = {
'general': {
chunkSize: 500,
chunkOverlap: 100,
separators: ['\n\n', '\n', '。', '!', '?', ';', ',', ' '],
},
'technical': {
chunkSize: 800,
chunkOverlap: 150,
separators: ['\n\n', '\n', '。', ';', '.', ','],
},
'legal': {
chunkSize: 1000,
chunkOverlap: 200,
separators: ['\n\n', '。', '\n', ';'],
},
'qa': {
chunkSize: 300,
chunkOverlap: 50,
separators: ['\n\n', '\n', '?', '。'],
},
};
this.config = this.configs[documentType] || this.configs['general'];
}
/**
* 递归切分文本
*/
split(text) {
const chunks = [];
const { chunkSize, chunkOverlap, separators } = this.config;
// 如果文本小于 chunk_size,直接返回
if (text.length <= chunkSize) {
return [text];
}
// 尝试每个分隔符
for (const separator of separators) {
if (text.includes(separator)) {
const parts = text.split(separator);
let currentChunk = '';
for (const part of parts) {
const testChunk = currentChunk
? currentChunk + separator + part
: part;
if (testChunk.length <= chunkSize) {
currentChunk = testChunk;
} else {
if (currentChunk) {
chunks.push(currentChunk);
// 保留重叠部分
const overlapStart = Math.max(0, currentChunk.length - chunkOverlap);
currentChunk = currentChunk.substring(overlapStart) + separator + part;
} else {
// 单个部分就超过 chunk_size,递归切分
chunks.push(...this.split(part));
currentChunk = '';
}
}
}
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks;
}
}
// 如果没有找到分隔符,按字符切分
return this.splitByCharacter(text, chunkSize, chunkOverlap);
}
/**
* 按字符切分
*/
splitByCharacter(text, chunkSize, overlap) {
const chunks = [];
let start = 0;
while (start < text.length) {
const end = start + chunkSize;
chunks.push(text.substring(start, end));
start = end - overlap;
}
return chunks;
}
}
// 使用示例
const splitter = new SmartTextSplitter('technical');
const chunks = splitter.split(document);
console.log(`文档切分为 ${chunks.length} 个片段`);
参数调完之后效果很直观。拿混合了技术文档和问答对的数据集跑,固定 1000 字无重叠的切法,检索准确率 60% 左右。自适应切分能到 85%。差距主要来自跨主题片段少了,问答对的完整性也保住了。
| 方案 | chunk_size | overlap | 检索准确率 | 上下文完整性 |
|---|---|---|---|---|
| 固定切分 | 1000 | 0 | 60% | 差 |
| 基础优化 | 500 | 50 | 70% | 中等 |
| 智能切分 | 自适应 | 自适应 | 85% | 完整 |
策略 2:语义拆分
固定长度切分是图个计算方便,语义完整性没法保证。语义拆分的做法是先把文档拆到句级别,用 embedding 模型给每个句子算向量,再比较相邻句子的相似度。相似度掉到阈值以下,说明话题换了,从这里切。
/**
* 语义拆分
* 基于句子 Embedding 相似度
*/
class SemanticSplitter {
constructor(embeddingModel, similarityThreshold = 0.5) {
this.embeddingModel = embeddingModel;
this.threshold = similarityThreshold;
}
/**
* 将文本按句子分割
*/
splitSentences(text) {
// 中文句子分割
const sentences = text.split(/([。!?\n]+)/).filter(s => s.trim());
// 重新组合句子和标点
const result = [];
for (let i = 0; i < sentences.length; i += 2) {
const sentence = sentences[i] + (sentences[i + 1] || '');
result.push(sentence.trim());
}
return result.filter(s => s.length > 0);
}
/**
* 计算句子 Embedding
*/
async getEmbedding(text) {
const response = await fetch('http://localhost:11434/api/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.embeddingModel || 'bge-m3',
prompt: text,
}),
});
const data = await response.json();
return data.embedding;
}
/**
* 计算余弦相似度
*/
cosineSimilarity(vec1, vec2) {
const dotProduct = vec1.reduce((sum, val, i) => sum + val * vec2[i], 0);
const mag1 = Math.sqrt(vec1.reduce((sum, val) => sum + val * val, 0));
const mag2 = Math.sqrt(vec2.reduce((sum, val) => sum + val * val, 0));
return dotProduct / (mag1 * mag2);
}
/**
* 语义切分
*/
async split(text) {
// 1. 分句
const sentences = this.splitSentences(text);
console.log(`文本分为 ${sentences.length} 个句子`);
// 2. 获取每个句子的 Embedding
console.log('计算句子向量...');
const embeddings = await Promise.all(
sentences.map(s => this.getEmbedding(s))
);
// 3. 计算相邻句子的相似度
const similarities = [];
for (let i = 0; i < embeddings.length - 1; i++) {
const sim = this.cosineSimilarity(embeddings[i], embeddings[i + 1]);
similarities.push(sim);
}
// 4. 在相似度低的地方切分
const chunks = [];
let currentChunk = sentences[0];
for (let i = 0; i < similarities.length; i++) {
if (similarities[i] < this.threshold) {
// 主题变化,创建新片段
chunks.push(currentChunk);
currentChunk = sentences[i + 1];
} else {
// 主题连续,合并
currentChunk += ' ' + sentences[i + 1];
}
}
if (currentChunk) {
chunks.push(currentChunk);
}
console.log(`语义切分为 ${chunks.length} 个片段`);
return chunks;
}
}
// 使用示例
const semanticSplitter = new SemanticSplitter('bge-m3', 0.5);
const chunks = await semanticSplitter.split(document);
实施成本比智能切分多一天左右。收益是跨主题切分基本消失,检索准确率在智能切分基础上再涨 15 到 20 个百分点。代价是每个句子都要过一遍 embedding 接口,文档量上来之后耗时按小时算。离线处理问题不大,在线场景得做缓存,或者干脆只在离线流水线里用。
策略 3:上下文增强检索
切分做得再好,检索回来的也只是孤立片段。问答场景里最典型的翻车现场,模型拿到一句话,里面的人称代词、指代关系全找不到对应内容。上下文增强的思路是存储时记下每个 chunk将长文档切分为的小片段,用于检索和上下文构建。 的邻居,检索时把周围内容一起捞回来。
常用做法两种。
方式 1:句子窗口扩展
存储时记下每个 chunk 前后各 N 个句子,检索时拼回去。窗口大小一般取 2。太大容易把无关内容带进上下文,太小补不全信息。
/**
* 上下文增强
* 在检索到的 chunk 前后添加句子
*/
class ContextEnhancer {
constructor(windowSize = 2) {
this.windowSize = windowSize; // 前后各添加2个句子
}
/**
* 存储时:记录 chunk 的位置信息
*/
storeWithContext(chunks) {
return chunks.map((chunk, index) => ({
id: index,
content: chunk,
previousChunks: chunks.slice(Math.max(0, index - this.windowSize), index),
nextChunks: chunks.slice(index + 1, index + 1 + this.windowSize),
}));
}
/**
* 检索时:扩展上下文
*/
enhanceRetrievedChunk(chunk) {
const context = [
...chunk.previousChunks,
chunk.content,
...chunk.nextChunks,
].join('\n\n');
return {
content: chunk.content, // 原始 chunk
enhancedContent: context, // 增强后的内容
contextWindow: this.windowSize,
};
}
}
// 使用示例
const enhancer = new ContextEnhancer(2);
// 存储时
const chunksWithContext = enhancer.storeWithContext(chunks);
await vectorDB.store(chunksWithContext);
// 检索时
const retrieved = await vectorDB.search(query);
const enhanced = retrieved.map(chunk => enhancer.enhanceRetrievedChunk(chunk));
方式 2:父文档检索
思路和窗口扩展反过来。小 chunk 进向量库做检索,提高命中精度。每个小 chunk 带一个 parentId 指向所属的大 chunk。检索命中后不直接返回小 chunk,而是把父 chunk 一起捞出来交给模型。长文档场景下这个方案更稳,模型拿到的是完整段落,不是拼接的碎片。
/**
* 父文档检索
* 存储小 chunk 用于检索,返回大 chunk 用于生成
*/
class ParentDocumentRetriever {
/**
* 创建父子 chunk 关系
*/
createParentChildChunks(document) {
// 1. 创建父 chunk(大)
const parentChunks = this.splitLarge(document, 2000, 200);
// 2. 为每个父 chunk 创建子 chunk(小)
const childChunks = [];
parentChunks.forEach((parent, parentId) => {
const children = this.splitSmall(parent.content, 400, 50);
children.forEach((child, childId) => {
childChunks.push({
id: `${parentId}-${childId}`,
content: child,
parentId: parentId,
parentContent: parent.content, // 关联父文档
});
});
});
return childChunks;
}
}
splitLarge 和 splitSmall 就是策略 1 的切分器,参数不同而已。实现时注意存储膨胀问题,同一个父 chunk 会被多个子 chunk 反复引用,直接冗余存内容的话向量库体积涨得厉害,一般只存引用关系,检索时再回文档库取父内容。
策略 3:父文档检索
向量检索里一直有个矛盾:chunk 切小一点,匹配准,但给模型的上下文太碎;切大一点,上下文完整,但检索时噪声多。父文档检索就是来解决这个矛盾的。小 chunk 拿去跟 query 比,命中之后把所在的父 chunk 整个捞回来。
/**
* 检索:用小 chunk 检索,返回大 chunk
*/
async retrieve(query, topK = 5) {
// 1. 用小 chunk 检索(更精确)
const childResults = await vectorDB.search(query, topK);
// 2. 返回对应的父 chunk(更完整)
const parentResults = childResults.map(child => ({
content: child.parentContent, // 返回父文档
childContent: child.content, // 原始匹配的子文档
score: child.score,
}));
return parentResults;
}
看效果对比。
| 方案 | 检索精度 | 上下文完整性 | 推荐 |
|---|---|---|---|
| 无上下文 | 中 | 低 | - |
| 句子窗口 | 高 | 高 | 简单场景 |
| 父文档检索 | 最高 | 最高 | 复杂文档 |
策略 4:为 Chunk 添加描述标题
这个思路很直白。每个 chunk 在入库之前,先用 LLM 生成一段标题和描述,向量化的时候把标题、描述和原文拼在一起。这样 query 的语义能跟标题和描述先撞上,比直接撞原文准确。
成本是要多跑一轮 LLM。代码给你放这。
/**
* Chunk 描述生成器
*/
class ChunkDescriptor {
constructor(llmModel = 'qwen2.5:7b') {
this.llmModel = llmModel;
}
/**
* 为 chunk 生成描述
*/
async generateDescription(chunk) {
const prompt = `
请为以下文本片段生成一个简洁的标题和描述(50字以内)。
文本内容:
${chunk.substring(0, 500)}
输出格式:
标题:[一句话概括]
描述:[简要说明内容要点]
你的输出:`;
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.llmModel,
prompt: prompt,
stream: false,
}),
});
const data = await response.json();
return this.parseDescription(data.response);
}
/**
* 解析模型输出
*/
parseDescription(output) {
const titleMatch = output.match(/标题[::]\s*(.+)/);
const descMatch = output.match(/描述[::]\s*(.+)/);
return {
title: titleMatch ? titleMatch[1].trim() : '',
description: descMatch ? descMatch[1].trim() : output,
};
}
/**
* 批量处理
*/
async addDescriptionsToChunks(chunks) {
console.log(`为 ${chunks.length} 个 chunk 生成描述...`);
const enrichedChunks = [];
for (let i = 0; i < chunks.length; i++) {
console.log(`进度: ${i + 1}/${chunks.length}`);
const description = await this.generateDescription(chunks[i]);
enrichedChunks.push({
content: chunks[i],
title: description.title,
description: description.description,
// 用于向量化的增强文本
enhancedText: `${description.title}\n${description.description}\n\n${chunks[i]}`,
});
// 避免请求过快
await new Promise(resolve => setTimeout(resolve, 200));
}
return enrichedChunks;
}
}
// 使用示例
const descriptor = new ChunkDescriptor();
const enrichedChunks = await descriptor.addDescriptionsToChunks(chunks);
// 存储时使用 enhancedText
await vectorDB.store(enrichedChunks.map(chunk => ({
content: chunk.content,
vector: await getEmbedding(chunk.enhancedText), // 使用增强文本
metadata: {
title: chunk.title,
description: chunk.description,
},
})));
效果是检索准确率一般能涨 20-30%,长文档尤其明显。返回结果带着标题,用户也知道命中的是哪一段,体验好不少。
策略 5:文档增强
策略 4 是给 chunk 补充信息,文档增强是在整个文档的层面做文章。把文件名、类型、关键词、主题、摘要这些元数据抽出来,塞进每个 chunk 的搜索文本里。
这样检索的时候,query 如果跟文档主题相关,哪怕 chunk 内部没有直接出现关键词,也能靠摘要兜底。
/**
* 文档增强器
*/
class DocumentEnhancer {
/**
* 提取文档元数据
*/
extractMetadata(document, filename) {
return {
filename: filename,
fileType: this.detectFileType(filename),
length: document.length,
language: this.detectLanguage(document),
keywords: this.extractKeywords(document),
topics: this.extractTopics(document),
summary: this.generateSummary(document),
createdAt: new Date().toISOString(),
};
}
/**
* 提取关键词
*/
extractKeywords(text) {
// 简单的关键词提取(实际可用 TF-IDF 或 YAKE)
const words = text.match(/[\u4e00-\u9fa5]+/g) || [];
const frequency = {};
words.forEach(word => {
if (word.length >= 2) { // 只统计2字以上的词
frequency[word] = (frequency[word] || 0) + 1;
}
});
// 返回频率最高的前10个词
return Object.entries(frequency)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([word]) => word);
}
/**
* 生成文档摘要
*/
async generateSummary(document) {
const prompt = `请为以下文档生成一个100字以内的摘要:\n\n${document.substring(0, 2000)}`;
// 调用 LLM 生成摘要
const response = await callLLM(prompt);
return response;
}
/**
* 增强 chunk
*/
enhanceChunks(chunks, metadata) {
return chunks.map((chunk, index) => ({
content: chunk,
metadata: {
...metadata,
chunkIndex: index,
totalChunks: chunks.length,
// 添加文档摘要到每个 chunk
documentSummary: metadata.summary,
},
// 增强检索文本
searchText: `
文档:${metadata.filename}
主题:${metadata.topics.join(', ')}
摘要:${metadata.summary}
内容:${chunk}
`.trim(),
}));
}
}
策略 14:层次索引
文档库很大的时候,直接全局向量检索耗时又容易跑偏。层次索引的做法是先粗筛再细找。文档、章节、段落、chunk 各建一层,检索的时候先命中文档和章节,再在相关范围内找 chunk。
代码结构如下。
/**
* 层次索引
* 文档 → 章节 → 段落 → 句子
*/
class HierarchicalIndex {
constructor() {
this.levels = ['document', 'chapter', 'section', 'chunk'];
}
/**
* 构建层次结构
*/
buildHierarchy(document) {
// 层次1:文档级别
const documentSummary = await this.generateSummary(document);
// 层次2:章节级别
const chapters = this.splitIntoChapters(document);
const chapterSummaries = await Promise.all(
chapters.map(ch => this.generateSummary(ch))
);
// 层次3:段落级别
const sections = chapters.flatMap(ch => this.splitIntoSections(ch));
// 层次4:chunk 级别
const chunks = sections.flatMap(sec => this.splitIntoChunks(sec));
return {
document: {
summary: documentSummary,
chapters: chapterSummaries.map((summary, i) => ({
summary: summary,
content: chapters[i],
})),
},
chunks: chunks.map((chunk, i) => ({
content: chunk,
chapterIndex: Math.floor(i / (chunks.length / chapters.length)),
hierarchy: this.getHierarchyPath(i, chapters.length, chunks.length),
})),
};
}
/**
* 层次检索
*/
async hierarchicalSearch(query, topK = 5) {
// 步骤1:在文档摘要中检索
const relevantDocs = await this.searchLevel('document', query, 3);
// 步骤2:在相关文档的章节中检索
const relevantChapters = await this.searchLevel('chapter', query, 5, {
filter: { documentId: { $in: relevantDocs.map(d => d.id) } },
});
// 步骤3:在相关章节的 chunk 中检索
const relevantChunks = await this.searchLevel('chunk', query, topK, {
filter: { chapterId: { $in: relevantChapters.map(ch => ch.id) } },
});
return relevantChunks;
}
}
优势很明确:检索效率高,无关文档干扰少。适合大规模文档库。
二、检索优化
接下来是检索侧的优化。
策略 6:查询转换重写
查询转换很便宜,不需要改索引,也不用动文档,就是在 query 上做手脚。LLM 跑一下,把用户那句话改造成更适合向量检索的形式。
常见的有五种,这里说四种。第五种基本是前四种的组合,没什么新东西。
方式 1:查询扩展
让 LLM 生成几个语义相近的变体,分别检索再合并。比如用户问"如何提升模型性能",扩展成"怎样优化模型效果"、"模型性能改进方法"。可能原话在文档里没出现,但变体有。
/**
* 查询扩展
* 添加同义词、相关词
*/
async function expandQuery(query) {
const prompt = `
请为以下查询生成3个语义相似的变体,用于改善搜索结果。
原始查询:${query}
要求:
1. 使用同义词
2. 不同的表达方式
3. 保持原意
输出格式(每行一个变体):
1.
2.
3.
你的输出:`;
const response = await callLLM(prompt);
const variants = response.split('\n')
.filter(line => line.match(/^\d+\./))
.map(line => line.replace(/^\d+\.\s*/, ''));
return [query, ...variants];
}
// 使用示例
const query = "如何提升模型性能?";
const expandedQueries = await expandQuery(query);
// ['如何提升模型性能?', '怎样优化模型效果?', '模型性能改进方法', ...]
// 对每个查询都进行检索,合并结果
const allResults = await Promise.all(
expandedQueries.map(q => vectorDB.search(q, 5))
);
const mergedResults = this.mergeAndDeduplicate(allResults);
方式 2:查询分解
复杂问题拆成子问题。用户问"比较 Ollama 和 vLLM 的优缺点,并推荐适合中小企业的方案",拆成三个独立问题分开搜,再拼上下文。
/**
* 查询分解
* 将复杂查询拆分为多个子查询
*/
async function decomposeQuery(complexQuery) {
const prompt = `
将以下复杂问题拆分为2-4个简单的子问题:
复杂问题:${complexQuery}
输出格式:
1. [子问题1]
2. [子问题2]
...
你的输出:`;
const response = await callLLM(prompt);
const subQueries = response.split('\n')
.filter(line => line.match(/^\d+\./))
.map(line => line.replace(/^\d+\.\s*/, ''));
return subQueries;
}
// 使用示例
const query = "比较 Ollama 和 vLLM 的优缺点,并推荐适合中小企业的方案";
const subQueries = await decomposeQuery(query);
// [
// "Ollama 的优点和缺点是什么?",
// "vLLM 的优点和缺点是什么?",
// "中小企业应该如何选择部署方案?"
// ]
// 分别检索每个子查询
const subResults = await Promise.all(
subQueries.map(sq => vectorDB.search(sq, 3))
);
// 综合结果生成回答
const context = subResults.flat().map(r => r.content).join('\n\n');
方式 3:后退一步提问
把具体问题抽象化。用户问"Qwen2.5:7b 的显存需求是多少",同时搜一下"大语言模型的硬件资源需求如何评估"。抽象查询能捞到背景资料,具体查询负责捞细节。
/**
* 后退一步提问
* 将具体问题转换为更抽象的问题
*/
async function stepBackQuery(specificQuery) {
const prompt = `
将以下具体问题转换为一个更抽象、更通用的问题:
具体问题:${specificQuery}
抽象问题:`;
const abstractQuery = await callLLM(prompt);
return {
specific: specificQuery,
abstract: abstractQuery,
};
}
// 使用示例
const query = "Qwen2.5:7b 的显存需求是多少?";
const queries = await stepBackQuery(query);
// {
// specific: "Qwen2.5:7b 的显存需求是多少?",
// abstract: "大语言模型的硬件资源需求如何评估?"
// }
// 同时检索具体和抽象的查询
const specificResults = await vectorDB.search(queries.specific, 3);
const abstractResults = await vectorDB.search(queries.abstract, 2);
方式 4:意图识别重写
先识别意图是事实类、教程类、对比类还是报错类,然后按意图加前缀。比如 how-to 类查询重写成"详细步骤和方法:XXX",troubleshooting 类重写成"问题解决方案:XXX"。可以正则跑,也可以用 LLM,成本不高。
/**
* 意图识别和查询重写
*/
class QueryRewriter {
async rewrite(query) {
// 1. 识别意图
const intent = await this.detectIntent(query);
// 2. 根据意图重写查询
const rewriteStrategy = {
'factual': (q) => `准确的事实:${q}`,
'howto': (q) => `详细步骤和方法:${q}`,
'comparison': (q) => `对比分析:${q}`,
'troubleshooting': (q) => `问题解决方案:${q}`,
};
const rewritten = rewriteStrategy[intent]?.(query) || query;
return {
original: query,
rewritten: rewritten,
intent: intent,
};
}
async detectIntent(query) {
const patterns = {
'howto': /如何|怎么|怎样|步骤|方法/,
'comparison': /比较|对比|区别|哪个更好/,
'troubleshooting': /错误|失败|问题|不工作|报错/,
'factual': /什么是|定义|解释/,
};
for (const [intent, pattern] of Object.entries(patterns)) {
if (pattern.test(query)) {
return intent;
}
}
return 'general';
}
}
策略 7:二次排序
向量检索的得分不能完全代表语义相关。尤其在大规模语料上,top 5 里经常混着不相关的东西。二次排序加一层更精确的打分,把前面捞回来的结果重新排一下。
两种做法:一种用 LLM 挨个打分,简单但慢;另一种用 BM25 算一个分数,跟原向量得分加权合并,快但效果取决于数据集。
/**
* 重排序器
* 使用 Cross-Encoder 或 LLM 重新评分
*/
class Reranker {
constructor(method = 'llm') {
this.method = method; // 'llm' | 'cross-encoder' | 'bm25'
}
/**
* 方法1:使用 LLM 评分
*/
async rerankWithLLM(query, documents, topK = 5) {
console.log(`重排序 ${documents.length} 个文档...`);
const scores = [];
for (const doc of documents) {
const prompt = `
任务:评估文档与查询的相关性
查询:${query}
文档:${doc.content}
请给出相关性评分(0-10分,只输出数字):`;
const response = await callLLM(prompt, { maxTokens: 10 });
const score = parseFloat(response.trim()) || 0;
scores.push({
...doc,
rerankScore: score,
});
}
// 按评分排序
return scores
.sort((a, b) => b.rerankScore - a.rerankScore)
.slice(0, topK);
}
/**
* 方法2:使用 BM25 算法
*/
rerankWithBM25(query, documents, topK = 5) {
const queryTerms = this.tokenize(query);
const scores = documents.map(doc => {
const docTerms = this.tokenize(doc.content);
const bm25Score = this.calculateBM25(queryTerms, docTerms, documents);
return {
...doc,
bm25Score: bm25Score,
// 结合原始向量得分
combinedScore: doc.score * 0.6 + bm25Score * 0.4,
};
});
return scores
.sort((a, b) => b.combinedScore - a.combinedScore)
.slice(0, topK);
}
}
召回到手之后,排序就是下一个关键问题。向量检索有个老毛病,召回的文档里,排前面的未必是最相关的。有时候用户问法绕一下,embedding 就分不清主次。混合重排的思路,是用 BM25 先做一轮粗排,把不相关的筛掉一些,留下一批候选,再交给 LLM 做精排。粗排很快,几乎不花什么钱,LLM 精排慢一点,但只处理那十几条候选,成本可控。
核心的 BM25 打分在 calculateBM25 里。词频、逆文档频率、文档长度归一化一起算。注意这里的 tokenize 用了简单的正则匹配,实际项目要换成 jieba 这类专业工具,不然“中华人民共和国”会被拆成三个词。
/**
* 方法:混合重排
*/
async hybridRerank(query, documents, topK = 5) {
// 1. BM25 重排序(快速)
const bm25Ranked = this.rerankWithBM25(query, documents, topK * 2);
// 2. LLM 精排(精确)
const llmRanked = await this.rerankWithLLM(query, bm25Ranked, topK);
return llmRanked;
}
/**
* BM25 算法实现
*/
calculateBM25(queryTerms, docTerms, corpus, k1 = 1.5, b = 0.75) {
const avgDocLength = corpus.reduce((sum, doc) =>
sum + this.tokenize(doc.content).length, 0
) / corpus.length;
const docLength = docTerms.length;
const termFreq = {};
// 计算词频
docTerms.forEach(term => {
termFreq[term] = (termFreq[term] || 0) + 1;
});
// 计算 BM25 分数
let score = 0;
queryTerms.forEach(term => {
const tf = termFreq[term] || 0;
const idf = this.calculateIDF(term, corpus);
score += idf * (tf * (k1 + 1)) /
(tf + k1 * (1 - b + b * (docLength / avgDocLength)));
});
return score;
}
tokenize(text) {
// 简单分词,实际项目应使用专业分词工具
return text.toLowerCase()
.match(/[\u4e00-\u9fa5]+|[a-z0-9]+/g) || [];
}
使用的时候,先让向量库多召回一些候选,再交给重排器:
// 使用示例
const reranker = new Reranker('hybrid');
// 1. 初始向量检索,先召回更多候选
const initialResults = await vectorDB.search(query, 20);
// 2. 重排序,精选最相关的几条
const reranked = await reranker.hybridRerank(query, initialResults, 5);
效果差异挺明显。看表:
| 方法 | 准确率 | 速度 | 成本 |
|---|---|---|---|
| 仅向量检索 | 70% | 快 | 低 |
| + BM25 重排 | 80% | 快 | 低 |
| + LLM 重排 | 90% | 慢 | 中 |
| 混合重排 | 92% | 中 | 中 |
BM25 和 LLM 的增益不冲突,叠加起来能到 92%。
策略 8:连续片段查找和筛选
固定窗口切 chunk 有个天然的缺陷:一个完整的事例或章节,经常在中间被切开。检索时命中了后半段,但前半段因为关键词不匹配被漏掉了,模型拿到手的信息就不完整。
连续片段合并解决的就是这个问题。
/**
* 连续片段合并器
*/
class ConsecutiveChunkMerger {
/**
* 检测并合并连续 chunk
*/
mergeConsecutiveChunks(retrievedChunks) {
// 按文档 ID 和 chunk 索引排序
const sorted = retrievedChunks.sort((a, b) => {
if (a.metadata.documentId !== b.metadata.documentId) {
return a.metadata.documentId - b.metadata.documentId;
}
return a.metadata.chunkIndex - b.metadata.chunkIndex;
});
const merged = [];
let currentGroup = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const previous = sorted[i - 1];
// 检查是否连续
if (
current.metadata.documentId === previous.metadata.documentId &&
current.metadata.chunkIndex === previous.metadata.chunkIndex + 1
) {
// 连续,加入当前组
currentGroup.push(current);
} else {
// 不连续,保存当前组,开始新组
merged.push(this.mergeGroup(currentGroup));
currentGroup = [current];
}
}
// 处理最后一组
if (currentGroup.length > 0) {
merged.push(this.mergeGroup(currentGroup));
}
return merged;
}
/**
* 合并一组连续 chunk
*/
mergeGroup(group) {
if (group.length === 1) {
return group[0];
}
return {
content: group.map(c => c.content).join('\n\n'),
metadata: {
...group[0].metadata,
isMerged: true,
chunkCount: group.length,
chunkRange: [
group[0].metadata.chunkIndex,
group[group.length - 1].metadata.chunkIndex,
],
},
score: Math.max(...group.map(c => c.score)), // 使用最高分
};
}
/**
* 智能填充缺失的中间 chunk
*/
async fillMissingChunks(chunks) {
const filled = [];
for (let i = 0; i < chunks.length - 1; i++) {
filled.push(chunks[i]);
const current = chunks[i];
const next = chunks[i + 1];
// 检查是否有缺失的 chunk
if (
current.metadata.documentId === next.metadata.documentId &&
next.metadata.chunkIndex - current.metadata.chunkIndex > 1
) {
// 有缺失,获取中间的 chunk
const missingChunks = await this.getMissingChunks(
current.metadata.documentId,
current.metadata.chunkIndex + 1,
next.metadata.chunkIndex - 1
);
filled.push(...missingChunks);
}
}
filled.push(chunks[chunks.length - 1]);
return filled;
}
/**
* 从数据库获取缺失的 chunk
*/
async getMissingChunks(documentId, startIndex, endIndex) {
return await vectorDB.getChunksByRange(documentId, startIndex, endIndex);
}
}
合并逻辑不复杂。按文档 ID 和 chunk 索引排好序,再逐个检查前后两个 chunk 是不是同一个文档里相邻的两段。是,就归到一组。不是,就把当前组输出。最后每组文本合并,分数取组内最大值。
还有一个可选的补充操作。如果 chunk 5 命中了,chunk 8 也命中了,但 6 和 7 没被检索到,中间缺了一段。这时候可以主动去数据库把那几段取回来,把上下文补完整。
// 使用示例
const merger = new ConsecutiveChunkMerger();
// 1. 检索
const retrieved = await vectorDB.search(query, 10);
// 2. 合并连续片段
const merged = merger.mergeConsecutiveChunks(retrieved);
// 3. 可选:填充缺失片段
const filled = await merger.fillMissingChunks(merged);
这个操作很轻量,适合分块时刚好切断关键信息的情况。改动不大,效果很明显。
策略 11:场景细分和意图识别
不同场景的问题,检索策略应该不一样。问产品价格和问 API 配置方法,背后的知识库和匹配方式都不能用同一套。
场景路由的做法是,先让 LLM 判断问题属于哪一类,再决定去哪个知识库查、用什么策略查。
代码里定义了四类:产品、技术、政策、FAQ。产品走密集检索,多召回一些候选。技术走混合检索,向量和关键词都算。政策要求精确匹配,相似度阈值拉到 0.9,宁可漏判也不能给错。FAQ 就是常规的语义检索。
/**
* 场景路由器
* 根据意图路由到不同的知识库
*/
class SceneRouter {
constructor() {
// 定义不同场景的知识库
this.knowledgeBases = {
'product': {
collection: 'product_docs',
strategy: 'dense', // 密集检索
},
'technical': {
collection: 'technical_docs',
strategy: 'hybrid', // 混合检索
},
'policy': {
collection: 'policy_docs',
strategy: 'exact', // 精确匹配
},
'faq': {
collection: 'faq',
strategy: 'semantic', // 语义检索
},
};
}
/**
* 识别场景
*/
async detectScene(query) {
const prompt = `
请判断以下查询属于哪个场景(只输出场景名称):
查询:${query}
场景选项:
- product: 产品功能、价格、使用方法
- technical: 技术问题、配置、开发
- policy: 公司政策、规则、流程
- faq: 常见问题
场景:`;
const response = await callLLM(prompt, { maxTokens: 20 });
const scene = response.trim().toLowerCase();
return this.knowledgeBases[scene] || this.knowledgeBases['faq'];
}
/**
* 路由检索
*/
async route(query) {
// 1. 识别场景
const scene = await this.detectScene(query);
console.log(`检测到场景: ${scene.collection}, 策略: ${scene.strategy}`);
// 2. 根据场景选择检索策略
let results;
switch (scene.strategy) {
case 'dense':
results = await this.denseRetrieval(query, scene.collection);
break;
case 'hybrid':
results = await this.hybridRetrieval(query, scene.collection);
break;
case 'exact':
results = await this.exactMatch(query, scene.collection);
break;
case 'semantic':
default:
results = await this.semanticRetrieval(query, scene.collection);
}
return {
scene: scene.collection,
strategy: scene.strategy,
results: results,
};
}
/**
* 不同的检索策略
*/
async denseRetrieval(query, collection) {
// 密集检索:返回更多结果
return await vectorDB.search(query, {
collection,
topK: 10,
});
}
async hybridRetrieval(query, collection) {
// 混合检索:向量 + 关键词
const vectorResults = await vectorDB.search(query, { collection, topK: 10 });
const keywordResults = await vectorDB.keywordSearch(query, { collection, topK: 10 });
return this.fuseResults(vectorResults, keywordResults);
}
async exactMatch(query, collection) {
// 精确匹配:适合政策文档
return await vectorDB.search(query, {
collection,
topK: 3,
scoreThreshold: 0.9, // 高阈值
});
}
async semanticRetrieval(query, collection) {
// 语义检索:常规方式
return await vectorDB.search(query, { collection, topK: 5 });
}
}
这个方式的好处是每个场景可以单独调优。这里有个细节,场景判断依赖 LLM,如果分类错了,后面全白搭。遇到置信度不高的 query,可以在路由前加一层兜底,默认走混合检索。
// 使用示例
const router = new SceneRouter();
const query = "专业版的价格是多少?";
const result = await router.route(query);
// {
// scene: 'product',
// strategy: 'dense',
// results: [...]
// }
策略 12:自我检索增强生成 (Self-RAG)
Self-RAG 想解决的是另一个问题:有些问题根本不需要检索。模型自己知道答案,外部文档反而可能引入噪声。
流程上比普通 RAG 多了几步。先判断是否需要检索,需要才去搜。搜回来的文档要逐条评估相关性,只留相关的。生成答案之后还要验证一遍,看答案是否被文档支持。
/**
* Self-RAG
* 模型自主决定是否检索和使用检索结果
*/
class SelfRAG {
/**
* 判断是否需要检索
*/
async needsRetrieval(query) {
const prompt = `
判断以下问题是否需要检索外部知识库?
问题:${query}
判断标准:
- 需要检索:涉及具体事实、数据、最新信息
- 不需要检索:常识问题、推理问题、创意问题
回答(只输出"需要"或"不需要"):`;
const response = await callLLM(prompt, { maxTokens: 10 });
return response.includes('需要');
}
/**
* 评估检索结果的相关性
*/
async assessRelevance(query, retrievedDocs) {
const assessments = [];
for (const doc of retrievedDocs) {
const prompt = `
评估文档与问题的相关性:
问题:${query}
文档:${doc.content.substring(0, 500)}
评估(只输出"相关"或"不相关"):`;
const response = await callLLM(prompt, { maxTokens: 10 });
const isRelevant = response.includes('相关') && !response.includes('不相关');
assessments.push({
...doc,
isRelevant: isRelevant,
});
}
return assessments.filter(doc => doc.isRelevant);
}
/**
* Self-RAG 完整流程
*/
async generate(query) {
console.log('Self-RAG 流程开始...');
// 步骤1:判断是否需要检索
const needsRetrieval = await this.needsRetrieval(query);
console.log(`需要检索: ${needsRetrieval}`);
if (!needsRetrieval) {
// 直接生成
return await this.directGenerate(query);
}
// 步骤2:检索文档
console.log('检索相关文档...');
const retrieved = await vectorDB.search(query, 10);
// 步骤3:评估相关性
console.log('评估文档相关性...');
const relevantDocs = await this.assessRelevance(query, retrieved);
console.log(`相关文档: ${relevantDocs.length}/${retrieved.length}`);
if (relevantDocs.length === 0) {
console.log('未找到相关文档,直接生成');
return await this.directGenerate(query);
}
// 步骤4:基于相关文档生成
const context = relevantDocs
.map(doc => doc.content)
.join('\n\n');
const answer = await this.generateWithContext(query, context);
// 步骤5:自我验证
const isSupported = await this.verifyAnswer(query, answer, context);
return {
answer: answer,
usedRetrieval: true,
relevantDocsCount: relevantDocs.length,
isSupported: isSupported,
sources: relevantDocs,
};
}
/**
* 验证答案是否被文档支持
*/
async verifyAnswer(query, answer, context) {
const prompt = `
验证答案是否被参考文档支持:
问题:${query}
答案:${answer}
参考文档:
${context.substring(0, 1000)}
验证(只输出"支持"或"不支持"):`;
const response = await callLLM(prompt);
return response.includes('支持') && !response.includes('不支持');
}
}
每一步都是一次 LLM 调用,所以延迟比普通 RAG 高不少。如果实际场景里有一半问题压根不需要检索,省下来的调用足够抵消这部分开销。
// 使用示例
const selfRAG = new SelfRAG();
const result = await selfRAG.generate("2024年的最新AI趋势是什么?");
策略 15:HyDE(假设性文档嵌入)
HyDE 的思路比较绕:不拿原问题去检索,先让 LLM 生成一个假设答案,再用这个答案去检索。
为什么这样有效?问题文本和文档文本在向量空间里的分布不完全对齐。答案文本反而和文档更接近。所以 HyDE 相当于在 query 和文档之间垫了一步。
/**
* HyDE: 假设性文档嵌入
*/
class HyDE {
/**
* 生成假设性答案
*/
async generateHypotheticalAnswer(query) {
const prompt = `
请直接回答以下问题(基于你的知识给出一个可能的答案,不要说"我不知道"):
问题:${query}
回答:`;
const hypotheticalAnswer = await callLLM(prompt);
return hypotheticalAnswer;
}
/**
* HyDE 检索
*/
async retrieve(query, topK = 5) {
console.log('生成假设性答案...');
// 步骤1:生成假设答案
const hypotheticalDoc = await this.generateHypotheticalAnswer(query);
console.log(`假设答案: ${hypotheticalDoc.substring(0, 100)}...`);
// 步骤2:用假设答案检索
console.log('使用假设答案检索...');
const results = await vectorDB.search(hypotheticalDoc, topK);
return results;
}
/**
* 多假设 HyDE
* 生成多个假设答案,分别检索,合并结果
*/
async multiHyDE(query, numHypotheses = 3, topK = 5) {
console.log(`生成 ${numHypotheses} 个假设答案...`);
const hypotheses = [];
for (let i = 0; i < numHypotheses; i++) {
const hypothesis = await this.generateHypotheticalAnswer(query);
hypotheses.push(hypothesis);
}
// 用每个假设答案检索
const allResults = await Promise.all(
hypotheses.map(hyp => vectorDB.search(hyp, topK))
);
// 合并和去重
const merged = this.mergeResults(allResults.flat(), topK);
return merged;
}
}
这个假设答案不要求完全正确,只需要在语言风格和信息形态上接近目标文档。它把原问题转化成文档的样子,语义距离就变了。
还可以生成多个不同的假设答案,分别检索再合并去重。召回更全,但每次生成的耗时和费用也要算进来。
// 使用示例
const hyde = new HyDE();
const results = await hyde.retrieve("如何部署 Kubernetes 集群?", 5);
粗排完还剩两三百个候选,再往下精排,成本是个问题。这时候就得看召回的源头了。前几轮优化都在想办法把向量检索本身做得更好,但还有一个思路,与其在检索算法上死磕,不如直接给检索一个"标准答案"当参考。
策略15:HyDE,这个思路反直觉但很有效。它不管用户怎么问,先让 LLM 生成一个假设性的回答,再用这个回答的向量去检索。原因是,用户的问题往往短,信息量小,而一个完整的回答要具体得多,向量里能匹配的语义信息更丰富。比如用户问"量子计算怎么影响加密",直接拿这个问题去匹配,和拿一段"量子计算对RSA加密的威胁主要体现在……"去匹配,命中率完全不是一个级别。
代码里是 new HyDE() 两步走,先生成假答案,再拿假答案去检索。没写全,但思路就是这样。多假设版本就是生成多个回答,各查一遍,最后合并去重。测试数据大概是准确率从70提到85,效果看场景,开放性问题收益大,事实性问答反而可能引入幻觉。这点要注意。
策略16:Fusion。多路召回之后怎么合并,这是个问题。原文代码写了 RAGFusion,核心是 RRF 算法。其实不用管这个名词,思路就是一个文档在多个召回列表里排名都靠前,那它就该排在前面。排名相加,比分数相加抗造。因为向量相似度0.8和BM25分数50根本不是一个量纲,没法直接比,但排名是统一的。代码里 fuseResults 就是干这个的,k=60 是个平滑参数,排名第一的拿1/61分,排名第二拿1/62,以此类推,把各路召回的分数加起来再排。
加权融合也提供了,但权重调起来麻烦。RRF 的好处是不用调,直接用,效果还挺稳。四种策略里 HyDE 和查询扩展有点重叠,实际上跑的时候可以先跑向量和BM25,如果发现不够再补 HyDE,省点 token。
策略17:CRAG,纠错机制。这个思路很实在:检索质量到底好不好,先让 LLM 自己评估一遍。evaluateRetrievalQuality 这个方法说白了就是把文档逐条喂给LLM打分,0到10,看平均分。过了6分就算合格,直接用;不合格就得补救。
补救措施是一个完整的链条。先是改写查询,用 rewriteQuery 让LLM把问题说清楚点,比如"AI趋势"改成"2025年人工智能在医疗领域的主要发展趋势",然后再检索一遍。还不行就把文档拿去做知识精炼,只留最相关的1-2句话,减少噪音。最后兜底方案才走网络搜索。正常企业内部文档场景,除非是给文档库做兜底,不然网络搜索这步大概率用不上,但链路是完整的。
从生成角度看,也就是第三个大部分,有策略9和策略10。
策略9是上下文压缩。这个太实用了,直接关系token成本。检索回来的文档经常是整页整篇的,但用户就问一句话。原文代码给了三种压缩方式,基于 LLM 的压缩不稀奇,就是拿提示词让模型提取相关内容。基于相似度的压缩才是省钱利器,按句切分,每句算一个 embedding,跟问题比相似度,超过阈值的留下。这个方案不用调LLM,速度快,能先把最明显无关的句子滤掉。
混合方案最合理:先用相似度粗筛一遍,砍掉一半以上的内容,剩下零头再送给LLM精提取。效果好,token成本也低,那50-70%的token节省就是这么来的。代码里 hybridCompress 就是这个组合,truncateToTokenLimit 最后保证不超限。
策略10是用户反馈存储。反馈的价值不在收集,在闭环。原文代码里 reinforceGoodExample 这个方法值得细看:用户给了四星以上,就把这个查询-回答对存进训练数据,相关文档的向量权重提升10%,同时生成几个相似问法也存起来。这意味着下次有人用差不多的问法,哪怕不经过检索,也能直接从缓存里拿答案。差评的处理逻辑也合理,去分析是缺文档还是检索策略不对。
这一套系统做下来,检索优化那块其实已经很完整了,从向量到融合到纠错到反馈,该有的链路都有了。但上下文那块总觉得还差点意思,就是这个压缩和重排之间的配合还能再挖一挖。下一部分专门聊聊上下文这块还有哪些坑。
再拿这段假设回答去做向量检索,把命中的文档和原始 query 一起交给生成模型。这个思路绕开了用户问法和文档写法之间的用词差异,检索命中率提升明显。代价是假设回答不能跑偏。垂直领域尤其危险,LLM 不懂业务的时候,生成出来的假设回答基本是废的。HyDE 适合开放域问答,专业场景慎用。
HyDE 在查询端做文章。还有一条路更省心,直接看用户反馈。用户看完答案,点没点“有帮助”,这是最真实的信号。不用让 LLM 猜,把用户验证过的成功路径存下来就行。下次来一个差不多的 query,直接照着上次成功的检索策略走。
策略 16:基于反馈优化检索
后端逻辑不复杂。收到 query 先查 training_data 表,找相似的正反馈案例,有就直接复用那个案例的检索策略,没有就走常规向量检索。
/**
* 基于反馈优化检索
*/
async optimizeRetrieval(query) {
// 1. 查找相似的成功案例
const similarSuccess = await db.collection('training_data').find({
query: { $regex: query, $options: 'i' },
type: 'positive_feedback',
}).toArray();
if (similarSuccess.length > 0) {
// 2. 使用成功案例的检索策略
return await this.replicateSuccessStrategy(similarSuccess[0], query);
}
// 3. 常规检索
return await vectorDB.search(query);
}
前端收集反馈更简单,两个按钮戳一下就行。用户点完,数据通过接口传到后端,写进 feedback 表,再回流到 training_data。
/**
* 反馈按钮组件
*/
function FeedbackButtons({ query, answer, sources }) {
const handleFeedback = async (rating, isHelpful, comments) => {
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
answer,
sources,
feedback: { rating, isHelpful, comments },
}),
});
};
return (
<div className="feedback">
<button => handleFeedback(5, true, '')}>有帮助</button>
<button => handleFeedback(1, false, '')}>没帮助</button>
</div>
);
}
反馈数据攒多了,这张表本身就是一份调参指南。哪个场景该用哪套检索策略,数据会告诉你。
策略 13:知识图谱节点检索
向量检索擅长找语义相近的段落,但处理不了多跳查询。比如“这家公司的这个产品用的什么协议”,你得从公司跳到产品,再跳到协议。这种沿着实体关系跳转的查询,靠向量相似度算不出来,得把知识组织成图。
实现分三步。第一步,用 LLM 从文档里抽实体和关系,比如从“Ollama 基于 Go 编写”里抽出实体 Ollama 和 Go,关系是“基于”。第二步,实体当节点,关系当边,构建图。第三步,查询时先识别问题里的实体,定位到节点,再往外扩一跳邻居,把这些节点关联的文档全部捞回来。
/**
* 知识图谱增强检索
* (简化示例,实际使用 Neo4j 等图数据库)
*/
class KnowledgeGraphRAG {
/**
* 从文本中抽取实体和关系
*/
async extractEntities(text) {
const prompt = `
从以下文本中抽取实体和关系:
文本:${text}
输出格式:
实体:[实体1, 实体2, ...]
关系:[(实体1, 关系类型, 实体2), ...]
你的输出:`;
const response = await callLLM(prompt);
return this.parseEntitiesAndRelations(response);
}
/**
* 构建知识图谱
*/
async buildKnowledgeGraph(documents) {
const graph = { nodes: [], edges: [] };
for (const doc of documents) {
const { entities, relations } = await this.extractEntities(doc.content);
// 添加节点
entities.forEach(entity => {
if (!graph.nodes.find(n => n.name === entity)) {
graph.nodes.push({
name: entity,
documents: [doc.id],
});
}
});
// 添加边
relations.forEach(([source, relation, target]) => {
graph.edges.push({
source: source,
target: target,
relation: relation,
document: doc.id,
});
});
}
return graph;
}
/**
* 基于图的检索
*/
async graphSearch(query, graph) {
// 1. 识别查询中的实体
const queryEntities = await this.extractEntities(query);
// 2. 在图中查找相关节点
const relevantNodes = graph.nodes.filter(node =>
queryEntities.entities.some(qe => node.name.includes(qe))
);
// 3. 扩展到邻居节点(1-hop)
const expanded = this.expandNodes(relevantNodes, graph, 1);
// 4. 返回相关文档
const documentIds = [...new Set(expanded.flatMap(n => n.documents))];
return await this.getDocumentsByIds(documentIds);
}
}
上面这段是简化示例,生产环境建议用 Neo4j 这类图数据库。成本主要花在第一步,抽实体要走一遍 LLM。垂直领域这笔钱花得值,术语之间的关系是显式存下来的,不用靠向量空间去猜。
实施优先级
十七个策略,别想着一次性全上。按投入产出比,分三个阶段。
阶段 1:基础优化(1-2周)
必须做:
- 文本切分优化,1天
- 上下文增强,1天
- 查询转换重写,2天
- 二次排序,3天
阶段 2:进阶优化(2-3周)
推荐做:
- 上下文压缩,3天
- 场景细分,4天
- Fusion 融合,4天
- HyDE,2天
阶段 3:高级优化(按需)
可选:
- Self-RAG,5天
- CRAG,5天
- 知识图谱,2周以上
阶段 1 做完,大多数业务的检索质量已经够用。阶段 3 的三个方案,碰到具体瓶颈再上。
综合实施方案
每个策略单独看都不复杂,难的是把它们串成一条完整的 RAG 管线。下面这个类把前面提到的策略都装了进去,是一个能上生产的整合示例。
完整 RAG Pipeline
/**
* 企业级 RAG Pipeline
* 集成多种优化策略
*/
class EnterpriseRAG {
constructor(config) {
this.textSplitter = new SmartTextSplitter(config.docType);
this.contextEnhancer = new ContextEnhancer(2);
this.queryRewriter = new QueryRewriter();
this.reranker = new Reranker('hybrid');
this.compressor = new ContextCompressor();
this.router = new SceneRouter();
this.hyde = new HyDE();
this.fusion = new RAGFusion();
this.crag = new CRAG();
}
/**
* 文档处理流程
*/
async ingestDocument(document, metadata) {
// 1. 智能切分
const chunks = this.textSplitter.split(document);
// 2. 为 chunk 添加描述
const descriptor = new ChunkDescriptor();
const enriched = await descriptor.addDescriptionsToChunks(chunks);
// 3. 上下文增强
const enhanced = this.contextEnhancer.storeWithContext(enriched);
// 4. 向量化并存储
await vectorDB.store(enhanced, metadata);
return {
success: true,
chunksCount: chunks.length,
};
}
/**
* 智能检索流程
*/
async retrieve(query, options = {}) {
// 1. 场景路由
const scene = await this.router.detectScene(query);
// 2. 查询转换
const rewritten = await this.queryRewriter.rewrite(query);
// 3. 多策略检索(Fusion)
const fusionResults = await this.fusion.multiStrategyRetrieve(
rewritten.rewritten,
options.topK * 2
);
// 4. 重排序
const reranked = await this.reranker.hybridRerank(
query,
fusionResults,
options.topK
);
// 5. 连续片段合并
const merger = new ConsecutiveChunkMerger();
const merged = merger.mergeConsecutiveChunks(reranked);
return merged;
}
/**
* 完整的 RAG 生成流程
*/
async generate(query, options = {}) {
console.log('企业级 RAG 流程启动...');
// 使用 CRAG 自动纠错
const result = await this.crag.generate(query);
// 如果质量不佳,使用 Self-RAG
if (result.retrievalQuality < 6) {
console.log('切换到 Self-RAG...');
const selfRAG = new SelfRAG();
return await selfRAG.generate(query);
}
// 上下文压缩
const compressed = await this.compressor.hybridCompress(
query,
result.sources,
options.maxTokens || 2000
);
// 生成最终答案
const context = compressed.map(doc => doc.compressed).join('\n\n');
const answer = await this.generateAnswer(query, context);
return {
answer: answer,
sources: result.sources,
retrievalQuality: result.retrievalQuality,
compressionRatio: this.calculateCompressionRatio(result.sources, compressed),
pipeline: {
scene: result.scene,
usedCorrection: result.usedCorrection,
strategiesUsed: ['fusion', 'rerank', 'compress', 'crag'],
},
};
}
}
// 使用示例
const rag = new EnterpriseRAG({ docType: 'technical' });
// 添加文档
await rag.ingestDocument(document, { type: 'manual' });
// 查询
const result = await rag.generate("如何部署 Ollama?");
console.log(result.answer);
流程串起来看是这样。文档进来先做智能切分,给每个 chunk 加描述,再向量化存储。查询进来先做场景路由,然后查询改写、Fusion 多路召回、混合重排、连续片段合并。生成阶段先用 CRAG 做自动纠错,检索质量分数低于 6 就切到 Self-RAG,最后压缩一遍上下文,把结果交给生成模型。
效果对比
不同策略组合的差异,看这张表。数值是相对量级,换一组数据会有浮动,趋势一致。
| 优化策略组合 | 准确率 | 响应时间 | 成本 | 推荐 |
|---|---|---|---|---|
| 基础 RAG | 60% | 2秒 | 低 | - |
| + 文本切分 + 上下文增强 | 75% | 2秒 | 低 | 入门 |
| + 查询转换 + 重排序 | 85% | 4秒 | 中 | 标准 |
| + Fusion + 压缩 | 90% | 5秒 | 中 | 进阶 |
| + Self-RAG + CRAG | 95% | 7秒 | 高 | 高级 |
准确率越往上涨,响应时间和成本涨得越快。入门和标准两个组合性价比最高,大多数业务做到 85% 就够。90% 以上的两档,除非准确率直接和收入挂钩,否则不建议一开始就全量上 Self-RAG 和 CRAG。
总结
快速上手路线
第1天:文本切分 + 上下文增强
第2-3天:查询转换 + 重排序
第4-5天:上下文压缩
第2周:Fusion 融合 + HyDE
第3周:Self-RAG + CRAG
性价比最高的组合
文本切分优化 (策略1)
+ 上下文增强 (策略3)
+ 查询转换 (策略6)
+ 二次排序 (策略7)
+ 上下文压缩 (策略9)
= 85% 准确率,中等成本
旗舰级完整方案
所有17种策略全部集成
= 95%+ 准确率,但成本高、复杂度高
推荐:大型企业、核心业务
从基础做起,逐步优化。
更新时间:2026-01-26
文档版本:v1.0
常见问题(FAQ)
RAG优化应该先做哪些策略?
建议优先实施文本切分优化、上下文增强、查询转换、二次排序、上下文压缩、场景细分和Fusion等优先级高的策略。这些策略投入产出比最高,且是基础,直接做高级策略容易返工。
RAG文本切分时chunk_size和overlap如何设置?
关键参数chunk_size控制长度,chunk_overlap控制重叠。技术文档建议800字,问答对300字,法律文书1000字,重叠量一般为chunk_size的15%-20%。分隔符优先级根据语言调整,中文注意分号引号。
RAG优化为什么不能直接上知识图谱和CRAG?
知识图谱和CRAG效果虽强,但需建立在基础手段压榨干净的前提下。若跳过文本切分、二次排序等基础优化,直接实施高级策略,检索准确率难提升,且实施周期长(知识图谱2周以上),容易返工。
版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。
文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。
若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。



