RAG 是什么
RAG(Retrieval-Augmented Generation)检索增强生成,是让 LLM 基于特定知识库回答问题的技术。
核心思路:先检索相关文档,再把文档作为上下文让 AI 生成回答。
为什么需要 RAG
LLM 的知识有截止日期,且无法知道你的私有数据。RAG 解决这两个问题:
| 问题 | RAG 方案 |
|---|---|
| 知识过时 | 实时检索最新文档 |
| 私有数据 | 索引企业内部知识库 |
| 幻觉问题 | 基于真实文档生成,可追溯 |
| 成本控制 | 只检索相关片段,减少 Token |
RAG 流程
┌─────────────────────────────────────────────────────────────────┐
│ 索引阶段 │
│ 文档 → 分块 → 向量化 → 存储到向量数据库 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 查询阶段 │
│ 问题 → 向量化 → 检索相似文档 → 组装提示 → LLM 生成 → 返回答案 │
└─────────────────────────────────────────────────────────────────┘
索引阶段实现
1. 文档加载
import { DirectoryLoader } from 'langchain/document_loaders/fs/directory'
import { TextLoader } from 'langchain/document_loaders/fs/text'
import { PDFLoader } from 'langchain/document_loaders/fs/pdf'
import { NotionLoader } from 'langchain/document_loaders/fs/notion'
// 加载不同类型的文档
const loader = new DirectoryLoader('./documents', {
'.txt': (path) => new TextLoader(path),
'.md': (path) => new TextLoader(path),
'.pdf': (path) => new PDFLoader(path)
})
const documents = await loader.load()
console.log(`加载了 ${documents.length} 个文档`)
2. 文档分块
分块是 RAG 的关键环节,直接影响检索质量。
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'
// 递归字符分割(最常用)
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000, // 每块最大字符数
chunkOverlap: 200, // 块之间重叠字符数
separators: ['\n\n', '\n', '。', '!', '?', '.', '!', '?', ' '] // 分割符优先级
})
const chunks = await splitter.splitDocuments(documents)
console.log(`分割成 ${chunks.length} 个块`)
分块策略对比
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定大小 | 简单 | 可能切断语义 | 通用文档 |
| 递归分割 | 保持语义完整 | 块大小不均 | 大多数场景 |
| 按标题分割 | 结构清晰 | 需要结构化文档 | Markdown/HTML |
| 语义分割 | 语义完整 | 计算成本高 | 高质量需求 |
3. 向量化存储
import { OpenAIEmbeddings } from '@langchain/openai'
import { PineconeStore } from '@langchain/pinecone'
import { Pinecone } from '@pinecone-database/pinecone'
// 初始化 Pinecone
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY })
const index = pinecone.Index('my-index')
// 向量化并存储
const embeddings = new OpenAIEmbeddings({
modelName: 'text-embedding-3-small'
})
const vectorStore = await PineconeStore.fromDocuments(chunks, embeddings, {
pineconeIndex: index,
namespace: 'knowledge-base'
})
console.log('索引完成')
向量数据库选型
| 数据库 | 特点 | 成本 | 适用场景 |
|---|---|---|---|
| Pinecone | 托管服务,易用 | 按量付费 | 快速上线 |
| Supabase pgvector | 与 Postgres 集成 | 便宜 | 已用 Supabase |
| Chroma | 开源,本地部署 | 免费 | 开发测试 |
| Weaviate | 功能丰富 | 自托管免费 | 复杂需求 |
| Milvus | 高性能 | 自托管 | 大规模数据 |
查询阶段实现
1. 基础检索
// 从现有索引创建向量存储
const vectorStore = await PineconeStore.fromExistingIndex(embeddings, {
pineconeIndex: index,
namespace: 'knowledge-base'
})
// 相似性检索
const results = await vectorStore.similaritySearch('Vue 的响应式原理', 4)
// 返回最相关的 4 个文档块
2. 检索器配置
const retriever = vectorStore.asRetriever({
k: 5, // 返回数量
searchType: 'similarity', // 或 'mmr'
filter: { // 元数据过滤
source: 'vue-docs'
}
})
// MMR (最大边际相关性) - 平衡相关性和多样性
const mmrRetriever = vectorStore.asRetriever({
searchType: 'mmr',
searchKwargs: {
fetchK: 20, // 初始检索数量
lambda: 0.5 // 相关性/多样性权重
}
})
3. 构建问答链
import { ChatOpenAI } from '@langchain/openai'
import { createStuffDocumentsChain } from 'langchain/chains/combine_documents'
import { createRetrievalChain } from 'langchain/chains/retrieval'
import { ChatPromptTemplate } from '@langchain/core/prompts'
const llm = new ChatOpenAI({ modelName: 'gpt-4-turbo-preview' })
const prompt = ChatPromptTemplate.fromMessages([
['system', `你是一个知识库问答助手。根据以下上下文回答用户问题。
规则:
1. 只基于上下文内容回答,不要编造
2. 如果上下文中没有相关信息,明确说明
3. 引用来源文档
上下文:
{context}`],
['human', '{input}']
])
// 创建链
const documentChain = await createStuffDocumentsChain({ llm, prompt })
const retrievalChain = await createRetrievalChain({
retriever,
combineDocsChain: documentChain
})
// 查询
const result = await retrievalChain.invoke({
input: 'Vue 3 的 Composition API 有什么优势?'
})
console.log('答案:', result.answer)
console.log('来源:', result.context.map(d => d.metadata.source))
高级优化
1. 混合检索
结合关键词和语义检索:
import { BM25Retriever } from 'langchain/retrievers/bm25'
// 关键词检索
const bm25Retriever = BM25Retriever.fromDocuments(documents, { k: 5 })
// 语义检索
const vectorRetriever = vectorStore.asRetriever({ k: 5 })
// 融合结果
async function hybridSearch(query: string) {
const [bm25Results, vectorResults] = await Promise.all([
bm25Retriever.invoke(query),
vectorRetriever.invoke(query)
])
// 去重并排序
const seen = new Set()
const merged = [...vectorResults, ...bm25Results].filter(doc => {
const key = doc.pageContent.slice(0, 100)
if (seen.has(key)) return false
seen.add(key)
return true
})
return merged.slice(0, 5)
}
2. 重排序
先召回多个,再用更精确的模型排序:
import { CohereRerank } from '@langchain/cohere'
const reranker = new CohereRerank({
apiKey: process.env.COHERE_API_KEY,
topN: 5
})
async function searchWithRerank(query: string) {
// 先粗召回 20 个
const candidates = await vectorStore.similaritySearch(query, 20)
// 重排序选 5 个
const reranked = await reranker.compressDocuments(candidates, query)
return reranked
}
3. 查询改写
优化用户查询:
async function rewriteQuery(query: string) {
const response = await llm.invoke(`
将以下用户问题改写成更适合搜索的查询,保持语义不变:
原问题:${query}
改写后的查询(直接输出,不要其他内容):
`)
return response.content as string
}
// 使用
const originalQuery = '怎么让页面加载更快'
const rewrittenQuery = await rewriteQuery(originalQuery)
// '网页性能优化 页面加载速度提升方法'
4. 多步检索
复杂问题分解:
async function multiStepRetrieval(query: string) {
// 1. 分解问题
const subQueries = await llm.invoke(`
将以下复杂问题分解成 2-3 个简单的子问题:
问题:${query}
子问题(每行一个):
`)
const queries = (subQueries.content as string).split('\n').filter(Boolean)
// 2. 分别检索
const allDocs = []
for (const q of queries) {
const docs = await retriever.invoke(q)
allDocs.push(...docs)
}
// 3. 去重合并
return deduplicateDocs(allDocs)
}
前端集成
Nuxt 3 完整示例
// server/api/rag/query.post.ts
import { createRAGChain } from '../utils/rag'
export default defineEventHandler(async (event) => {
const { question, namespace = 'default' } = await readBody(event)
const chain = await createRAGChain(namespace)
// 流式响应
setHeader(event, 'Content-Type', 'text/event-stream')
const stream = await chain.stream({ input: question })
return new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
for await (const chunk of stream) {
if (chunk.answer) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ content: chunk.answer })}\n\n`)
)
}
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
}
})
})
前端组件
<script setup lang="ts">
const question = ref('')
const answer = ref('')
const sources = ref<Source[]>([])
const isLoading = ref(false)
async function askQuestion() {
if (!question.value.trim()) return
isLoading.value = true
answer.value = ''
sources.value = []
try {
const response = await fetch('/api/rag/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: question.value })
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
answer.value += parsed.content || ''
} catch {}
}
}
}
} finally {
isLoading.value = false
}
}
</script>
评估与优化
检索质量指标
// 计算召回率
function calculateRecall(retrieved: string[], relevant: string[]) {
const found = retrieved.filter(r => relevant.includes(r))
return found.length / relevant.length
}
// 计算精确率
function calculatePrecision(retrieved: string[], relevant: string[]) {
const found = retrieved.filter(r => relevant.includes(r))
return found.length / retrieved.length
}
// 评估示例
const testCases = [
{
query: 'Vue 响应式原理',
expectedDocs: ['vue-reactivity.md', 'proxy-reflect.md']
}
]
for (const testCase of testCases) {
const results = await retriever.invoke(testCase.query)
const retrievedSources = results.map(r => r.metadata.source)
const recall = calculateRecall(retrievedSources, testCase.expectedDocs)
const precision = calculatePrecision(retrievedSources, testCase.expectedDocs)
console.log(`Query: ${testCase.query}`)
console.log(`Recall: ${recall}, Precision: ${precision}`)
}
常见问题
Q: 分块大小怎么选?
A: 一般 500-1500 字符。太小会丢失上下文,太大会引入噪音。建议实验对比。
Q: 检索返回几个文档合适?
A: 通常 3-5 个。太少可能遗漏信息,太多会超出 context 长度并增加成本。
Q: 如何处理长文档?
A: 分层索引。先按章节粗分,再细分。检索时先找章节,再找具体段落。
总结
RAG 实施要点:
| 阶段 | 关键点 |
|---|---|
| 分块 | 保持语义完整,适当重叠 |
| 向量化 | 选择合适的 Embedding 模型 |
| 检索 | 混合策略 + 重排序 |
| 生成 | 明确提示 + 引用来源 |
| 评估 | 持续测试 + 迭代优化 |
相关文章推荐:


