GEOZ

RAG优化优先级:先把文本切分做好,再谈高级策略

2026/8/10
RAG优化优先级:先把文本切分做好,再谈高级策略

AIAI Summary (BLUF)

本文系统梳理了17种经过验证的RAG优化方案,涵盖文档处理、检索增强和生成优化三个阶段,包括文本切分、语义拆分、上下文增强、二次排序、HyDE、Fusion等关键技术。每项策略均给出难度、效果、优先级和实施时间建议,并附有实现思路和代码示例,适合需要系统性提升RAG系统性能的开发者参考。

核心洞察

先说结论:这篇文章最值钱的是一张表,就是策略分类里的优先级表。17 种技术本身不算稀奇,真正拉开差距的是先做哪个后做哪个。见过太多团队一上来就啃知识图谱、CRAG,折腾两三周,检索准确率还是上不去,问题往往不在高级策略,在最基础的切分上。

核心结论

  1. RAG 检索质量的关键在于最基础的文本切分优化,而非高级策略:固定 1000 字无重叠切分的检索准确率约 60%,而按文档类型自适应切分可提升至约 85%。

  2. 优先级比技术本身更重要:文本切分优化、上下文增强等基础策略仅需 1 天实施且优先级为高,而知识图谱、CRAG 等高级策略需 2 周以上且优先级为低,团队应避免跳过基础手段直接上高级策略。

  3. 语义拆分可在智能切分基础上再将检索准确率提升 15-20 个百分点,但每个句子需单独计算 embedding,耗时显著,更适合离线处理或对延迟不敏感的场景。

  4. 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. HyDE 中等 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. 上下文增强,1天
  3. 查询转换重写,2天
  4. 二次排序,3天

阶段 2:进阶优化(2-3周)

推荐做:

  1. 上下文压缩,3天
  2. 场景细分,4天
  3. Fusion 融合,4天
  4. HyDE,2天

阶段 3:高级优化(按需)

可选:

  1. Self-RAG,5天
  2. CRAG,5天
  3. 知识图谱,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周以上),容易返工。

Roger深圳
本文由 Roger 审核,最后更新于 2026年8月12日
联系编辑 →
← 返回文章列表
分享到:微博

版权与免责声明:本文仅用于信息分享与交流,不构成任何形式的法律、投资、医疗或其他专业建议,也不构成对任何结果的承诺或保证。

文中提及的商标、品牌、Logo、产品名称及相关图片/素材,其权利归各自合法权利人所有。本站内容可能基于公开资料整理,亦可能使用 AI 辅助生成或润色;我们尽力确保准确与合规,但不保证完整性、时效性与适用性,请读者自行甄别并以官方信息为准。

若本文内容或素材涉嫌侵权、隐私不当或存在错误,请相关权利人/当事人联系本站,我们将及时核实并采取删除、修正或下架等处理措施。也请勿在评论或联系信息中提交身份证号、手机号、住址等个人敏感信息。