RAG 实战:语义检索 + 大模型生成精准问答

RAG 实战:语义检索 + 大模型生成精准问答

语义检索 vs 关键词检索:本质区别

在深入代码之前,先理解一个核心区别:语义检索和传统关键词检索有什么不同?

对比维度 关键词检索(如 SQL LIKE) 语义检索(向量检索)
匹配方式 字面匹配 语义理解
对"手机"搜"智能机" ❌ 搜不到 ✅ 能搜到
对拼写错误"苹手机" ❌ 搜不到 ✅ 可能搜到"苹果手机"
同义词识别 ❌ 需要人工配置 ✅ 自动识别
实现原理 字符串匹配 向量相似度计算

前端类比

  • 关键词检索就像 array.filter(item => item.includes(keyword)) ------ 必须完全匹配
  • 语义检索就像用 AI 理解意思后去找 ------ 意合而非形合

这也是为什么 RAG 系统选择向量检索------用户问"怎么退款",文档里写的是"申请退货流程",字面上完全不同,但语义上是一回事。

语义检索核心逻辑

完整 RAG 问答流程

flowchart LR subgraph RAG问答完整流程 A[用户提问<br/>如何配置超时?] --> B[1. 问题向量化 ────▶ Embedding 模型 ] B --> C[2. 向量检索 ────▶ 向量数据库] C --> D[3. 结果优化 ────▶ 去重、排序、相关性筛选] D --> F[4. 构建Prompt ────▶ 大模型] F --> G[5. 生成回答 ────▶ 基于检索内容] end

相似度计算方式

向量检索的核心是比较两个向量的距离,LangChain 支持多种相似度计算方法:

方法 计算逻辑 特点 适用场景
余弦相似度 关注向量的方向而非长度 对向量长度不敏感 最常用,文本相似度
欧氏距离 计算两点间的直线距离 对数值敏感 图像/音频特征
点积 向量对应元素乘积之和 需要归一化向量 推荐系统
typescript 复制代码
// 余弦相似度计算公式
// similarity = (A·B) / (|A| × |B|)
// 值越接近 1,表示语义越相似

// 示例:
// 向量 A("手机"):[0.9, 0.1, 0.05, ...]
// 向量 B("智能手机"):[0.88, 0.12, 0.06, ...]
// 余弦相似度 ≈ 0.95(高度相似)

// 向量 C("汽车"):[0.1, 0.05, 0.85, ...]
// 与 A 的余弦相似度 ≈ 0.12(不相关)

检索结果优化处理

优化前后对比

处理环节 优化前 优化后 效果提升
去重 同一内容多次出现 语义去重,只保留一份 减少 30% 冗余
排序 只靠相似度分数 分数 + 来源权威性 相关性 +25%
长度控制 可能超过 LLM 窗口 智能截断或摘要 避免截断丢失信息
元数据筛选 按来源/时间过滤 结果更精准

检索结果优化实现

typescript 复制代码
// src/retrieval/result-optimizer.ts
import { Document } from "@langchain/core/documents";

interface OptimizerConfig {
  maxResults: number;           // 最大返回结果数
  minRelevanceScore: number;    // 最低相关度阈值
  enableDeduplication: boolean; // 是否启用去重
  maxTotalLength: number;       // 最大总字符数
}

export class RetrievalOptimizer {
  private config: OptimizerConfig;
  
  constructor(config: OptimizerConfig) {
    this.config = config;
  }
  
  /**
   * 1. 按相似度分数过滤
   */
  filterByScore<T extends { score: number }>(
    results: T[]
  ): T[] {
    return results.filter(r => r.score >= this.config.minRelevanceScore);
  }
  
  /**
   * 2. 语义去重(基于文本相似度)
   */
  deduplicate(docs: Document[]): Document[] {
    const unique: Document[] = [];
    
    for (const doc of docs) {
      let isDuplicate = false;
      
      for (const existing of unique) {
        // 简单去重:检查文本相似度
        const similarity = this.textSimilarity(
          doc.pageContent,
          existing.pageContent
        );
        if (similarity > 0.85) {
          isDuplicate = true;
          break;
        }
      }
      
      if (!isDuplicate) {
        unique.push(doc);
      }
    }
    
    console.log(`📊 去重: ${docs.length} → ${unique.length} 个文档块`);
    return unique;
  }
  
  /**
   * 3. 长度控制(确保不超过 LLM 上下文窗口)
   */
  truncateByLength(docs: Document[], maxLength: number): Document[] {
    const result: Document[] = [];
    let totalLength = 0;
    
    for (const doc of docs) {
      const docLength = doc.pageContent.length;
      
      if (totalLength + docLength > maxLength) {
        // 截断最后一个文档
        const remaining = maxLength - totalLength;
        if (remaining > 100) {
          const truncatedDoc = {
            ...doc,
            pageContent: doc.pageContent.slice(0, remaining) + "...",
          };
          result.push(truncatedDoc);
        }
        break;
      }
      
      result.push(doc);
      totalLength += docLength;
    }
    
    return result;
  }
  
  /**
   * 4. 综合优化
   */
  async optimize(
    resultsWithScore: [Document, number][]
  ): Promise<Document[]> {
    let docs = resultsWithScore;
    
    // 按分数排序(已在检索时完成)
    // 过滤低分结果
    docs = this.filterByScore(docs);
    
    let optimizedDocs = docs.map(([doc]) => doc);
    
    // 去重
    if (this.config.enableDeduplication) {
      optimizedDocs = this.deduplicate(optimizedDocs);
    }
    
    // 长度控制
    optimizedDocs = this.truncateByLength(
      optimizedDocs,
      this.config.maxTotalLength
    );
    
    return optimizedDocs.slice(0, this.config.maxResults);
  }
  
  /**
   * 简单文本相似度计算(Jaccard)
   */
  private textSimilarity(text1: string, text2: string): number {
    const words1 = new Set(text1.split(/\s+/));
    const words2 = new Set(text2.split(/\s+/));
    
    const intersection = new Set([...words1].filter(x => words2.has(x)));
    const union = new Set([...words1, ...words2]);
    
    return intersection.size / union.size;
  }
}

RAG 专属提示词设计

提示词的核心要素

一个好的 RAG 提示词需要包含以下要素:

要素 作用 示例
系统角色 定义 AI 的行为边界 "你是一个基于文档的问答助手"
检索内容 注入相关文档 【参考文档】\n${context}
回答约束 防止幻觉、规范行为 "如果文档中没有相关信息,请明确说不知道"
输出格式 规范答案结构 "请先给出结论,再引用来源"
来源引用 增强可信度 (来源:${metadata.source})

RAG 提示词模板

typescript 复制代码
// src/prompts/rag-prompt.ts

/**
 * 生成 RAG 问答的提示词
 */
export function buildRagPrompt(
  question: string,
  contexts: Document[],
  options?: { includeSources?: boolean; maxContextLength?: number }
): string {
  const includeSources = options?.includeSources !== false;
  
  // 1. 构建上下文部分
  const contextText = contexts
    .map((doc, idx) => {
      const source = includeSources 
        ? ` [来源: ${doc.metadata.source || "未知"}]` 
        : "";
      return `【参考文档 ${idx + 1}】${source}\n${doc.pageContent}`;
    })
    .join("\n\n");
  
  // 2. 返回完整提示词
  return `你是一个专业的知识问答助手。请基于以下参考文档回答用户问题。

## 重要规则
1. 只使用下面【参考文档】中的信息回答
2. 如果文档中没有相关信息,请明确说"根据现有文档,没有找到相关信息"
3. 不要使用你自己的知识补充答案
4. 回答要简洁、准确、有条理
5. 引用具体文档时请标注来源

${contextText}

## 用户问题
${question}

## 回答
`;
}

提示词优化对比

优化维度 优化前 优化后
角色定义 "专业的知识问答助手"
内容约束 "只使用参考文档中的信息"
拒绝策略 "没有找到相关信息时明确说不知道"
来源引用 标注具体来源
输出格式 自由发挥 先结论后展开

完整问答流程代码实现

完整 RAG 问答实现

typescript 复制代码
// src/rag-qa.ts
import { OpenAIEmbeddings } from "@langchain/openai";
import { ChatOpenAI } from "@langchain/openai";
import { Document } from "@langchain/core/documents";
import dotenv from "dotenv";

dotenv.config();


const CONFIG = {
  embedding: {
    model: "text-embedding-v2", // 通义官方正确嵌入模型
  },
  llm: {
    model: "qwen-turbo", // 通义官方模型
    temperature: 0.3,
  },
};

// 手写向量库
class SimpleVectorDB {
  private docs: Document[] = [];
  private embeddings: OpenAIEmbeddings;

  constructor(embeddings: OpenAIEmbeddings) {
    this.embeddings = embeddings;
  }

  async addDocuments(docs: Document[]) {
    this.docs = docs;
  }

  async similaritySearchWithScore(query: string, k: number) {
    const queryVec = await this.embeddings.embedQuery(query);
    const docVecs = await this.embeddings.embedDocuments(
      this.docs.map(d => d.pageContent)
    );

    const results = this.docs.map((doc, i) => {
      const score = this.cosineSimilarity(queryVec, docVecs[i]);
      return [doc, score] as [Document, number];
    });

    return results.sort((a, b) => b[1] - a[1]).slice(0, k);
  }

  private cosineSimilarity(a: number[], b: number[]): number {
    let dot = 0, ma = 0, mb = 0;
    for (let i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      ma += a[i] ** 2;
      mb += b[i] ** 2;
    }
    return dot / (Math.sqrt(ma) * Math.sqrt(mb) || 1);
  }
}

// RAG 核心类
class RAGQuestionAnswering {
  private embeddings: OpenAIEmbeddings;
  private llm: ChatOpenAI;
  private vectorStore: SimpleVectorDB;

  constructor() {
    this.embeddings = new OpenAIEmbeddings({
      apiKey: process.env.DASHSCOPE_API_KEY,
      model: CONFIG.embedding.model,
      configuration: {
        baseURL: process.env.DASHSCOPE_API_URL,
      },
    });

    this.llm = new ChatOpenAI({
      apiKey: process.env.DASHSCOPE_API_KEY,
      model: CONFIG.llm.model,
      temperature: CONFIG.llm.temperature,
      configuration: {
        baseURL: process.env.DASHSCOPE_API_URL,
      },
    });

    this.vectorStore = new SimpleVectorDB(this.embeddings);
  }

  async connect() {
    console.log("✅ 向量库已就绪");
    return this;
  }

  async addDocuments(docs: Document[]) {
    await this.vectorStore.addDocuments(docs);
    console.log(`✅ 已加入 ${docs.length} 个文档`);
  }

  async ask(question: string) {
    const rawResults = await this.vectorStore.similaritySearchWithScore(question, 10);
    const docs = rawResults.map(([doc]) => doc);

    // 临时提示词
    const prompt = `
你是智能助手,请根据文档回答。

文档:
${docs.map(d => d.pageContent).join("\n")}

问题:${question}

回答:`;

    const response = await this.llm.invoke(prompt);
    return { answer: response.content.toString() };
  }
}

// 测试
async function main() {
  const rag = new RAGQuestionAnswering();
  await rag.connect();

  await rag.addDocuments([
    new Document({
      pageContent: "RAG 是检索增强生成,用于解决大模型幻觉问题。",
      metadata: { source: "测试文档" },
    }),
  ]);

  const res = await rag.ask("什么是 RAG?");
  console.log("\n✅ 回答:", res.answer);
}

main().catch(console.error);

第五步:效果测试与分析

对比测试:纯大模型 vs RAG

测试问题 纯大模型回答 RAG 回答
"公司年假政策是什么?" "根据劳动法,年假一般为5-15天..."(通用回答) "根据公司员工手册第3章第2条,入职满1年享受5天年假,满3年享受10天..."(具体且可溯源)
"产品 X 的最新价格是多少?" 可能用训练数据中的旧价格 基于最新产品文档回答
"API 的超时参数怎么配置?" 可能给出通用示例 基于实际 API 文档给出准确参数名和用法

RAG 效果评估指标

指标 计算方法 目标值
检索召回率 相关文档被检索到的比例 >80%
回答准确率 基于文档的回答正确性 >90%
幻觉率 编造文档中没有的信息的比例 <5%
端到端延迟 从提问到回答的总时间 <3秒

第六步:检索精度优化技巧

优化方向概览

优化方法 实施方式 效果提升 实施难度
调整 Top-K 增加检索数量 +10-20% ⭐ 低
提高重叠度 增加分块重叠 +15-25% ⭐ 低
混合检索 向量 + 关键词 +20-30% ⭐⭐⭐ 高
重排序 二次排序优化 +10-15% ⭐⭐ 中
查询改写 优化用户问题 +15-20% ⭐⭐ 中

具体优化实现

1. 调整检索参数

typescript 复制代码
// 根据不同问题类型动态调整
function getAdaptiveTopK(question: string): number {
  if (question.includes("总结") || question.includes("概述")) {
    return 8;  // 总结类需要更多上下文
  }
  if (question.includes("具体") || question.includes("详细")) {
    return 5;  // 细节类需要精准
  }
  return 4;    // 默认
}

2. 查询改写(Query Rewriting)

typescript 复制代码
// 将用户问题改写成更适合检索的形式
async function rewriteQuery(original: string): Promise<string> {
  const rewritePrompt = `
将以下用户问题改写成适合向量检索的形式,保留核心信息,去除口语化表达。

原始问题: ${original}

改写后的问题:`;
  
  const response = await llm.invoke(rewritePrompt);
  return response.content.toString();
}

3. 混合检索策略

typescript 复制代码
// 向量检索 + 关键词检索的融合
async function hybridSearch(query: string, topK: number) {
  // 向量检索
  const vectorResults = await vectorStore.similaritySearch(query, topK);
  
  // 关键词检索(需要额外实现)
  const keywordResults = await keywordSearch(query, topK);
  
  // 结果融合(RRF 算法)
  return fusionResults(vectorResults, keywordResults);
}

结语

通过这篇教程,我们完整实现了基于 RAG 的语义检索与问答生成系统。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
沉尘5882 小时前
ACE-GCM加解密微信小程序
前端
秦jh_2 小时前
【LangChain核心组件】少样本提示(示例选择器)
人工智能·python·langchain
春风得意之时2 小时前
前端安装项目出现代理问题和ssl认证问题
前端·网络协议·ssl
VipSoft2 小时前
LangChain 入门 Memory 会话记忆
langchain
问心无愧05132 小时前
ctf show web入门109
android·前端·笔记
Java.熵减码农2 小时前
Hermes Agent 安装踩坑记录:DNS 解析失败 & Node.js 幽灵文件冲突
node.js·ai编程·hermes
粉末的沉淀2 小时前
vue:Vite项目中高效管理纯色SVG图标的方案
前端·javascript·vue.js
xyz_CDragon3 小时前
OpenClaw 局域网调用 Ollama 本地大模型:完整配置与踩坑指南
python·ai编程·集成学习·ollama·deepseek·openclaw