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 的语义检索与问答生成系统。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!