RAG 知识库功能实现
RAG(Retrieval-Augmented Generation,检索增强生成)是让 AI 基于你自己的数据回答问题的核心技术。纯 LLM 只有训练数据中的知识,无法回答"我们公司的退款政策是什么"这类私有问题。RAG 通过在生成前先检索相关文档片段,把它们注入到 Prompt 中,让模型基于真实数据回答。本章从文档处理管道到检索策略,完整实现一个生产级 RAG 系统。
1. RAG 核心原理
1.1 RAG 的工作流程
用户提问 "退款政策是什么?"
│
▼
① Embedding:将问题转为向量 [0.12, -0.34, ...]
│
▼
② 检索:在向量数据库中查找最相似的文档块(Top-K)
│
▼
③ 拼接:把检索到的文档块放入 Prompt
│
▼
④ 生成:LLM 基于文档内容生成回答
│
▼
⑤ 引用:标注回答来源的文档和页码
这个流程看起来简单,但每一步都有工程细节。RAG 的质量瓶颈通常不在模型本身,而在检索质量——如果检索到的文档片段不相关或不完整,再强的模型也给不出好答案。
1.2 影响 RAG 质量的关键因素
| 因素 | 影响 | 优化方向 |
|---|---|---|
| 分块策略 | 块太大→检索不精确;块太小→缺失上下文 | 512 tokens + 50 overlap |
| Embedding 模型 | 模型质量直接决定语义匹配准确度 | text-embedding-3-small 性价比最高 |
| 检索数量(Top-K) | K 太小→可能遗漏;K 太大→引入噪声 | K=5 作为起点,按场景调整 |
| 相似度阈值 | 阈值太低→引入不相关内容 | 0.7 以上才纳入上下文 |
| 文档解析质量 | 格式丢失、表格损坏→内容失真 | 针对不同文件类型用专门的解析器 |
| 查询改写 | 用户问题模糊时检索效果差 | HyDE 或 Query Rewriting |
1.3 朴素 RAG vs 高级 RAG
| 维度 | 朴素 RAG | 高级 RAG |
|---|---|---|
| 检索方式 | 纯向量相似度 | 混合检索(向量 + 关键词 + 重排序) |
| 查询处理 | 直接用原始问题检索 | 查询改写 / 多查询扩展 |
| 分块 | 固定大小切分 | 语义分块 / 递归分块 |
| 上下文 | Top-K 文档块 | 带父子关系的上下文窗口 |
| 适用场景 | 文档少于 100 篇 | 大型知识库、高精度需求 |
本章先实现朴素 RAG(覆盖 80% 场景),然后逐步添加高级优化。
2. 文档处理管道
2.1 文档上传流程
用户上传 PDF/Word/TXT
│
▼
① 上传到 S3(复用第 73 章文件存储)
│
▼
② 写入 documents 表,状态 = 'pending'
│
▼
③ 触发异步任务(Inngest)
│
▼
④ 解析文档 → 提取纯文本
│
▼
⑤ 分块(Chunking)
│
▼
⑥ Embedding → 写入 document_chunks(含向量)
│
▼
⑦ 更新 documents 状态 = 'ready'
异步处理是关键设计——文档解析和向量化可能需要几秒到几分钟(取决于文档大小),不能阻塞用户操作。
2.2 文档上传 Action
// lib/actions/knowledge.ts
'use server'
export async function uploadDocument(knowledgeBaseId: string, formData: FormData) {
const { session, membership } = await requirePermission('ai:knowledge:write')
const file = formData.get('file') as File
if (!file) throw new Error('No file provided')
// 验证文件类型
const allowedTypes = ['application/pdf', 'text/plain', 'text/markdown',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document']
if (!allowedTypes.includes(file.type)) {
throw new Error('Unsupported file type')
}
// 上传到 S3
const key = `${membership.tenantId}/knowledge/${knowledgeBaseId}/${nanoid()}/${file.name}`
const uploadUrl = await createUploadUrl(key, file.type)
// 客户端直传(或在这里转发——小文件可以服务端中转)
// 写入数据库
const [doc] = await db.insert(documents).values({
knowledgeBaseId,
name: file.name,
fileKey: key,
fileSize: file.size,
mimeType: file.type,
status: 'pending',
}).returning()
// 触发异步处理
await inngest.send({
name: 'knowledge/document.process',
data: { documentId: doc.id, knowledgeBaseId },
})
revalidatePath(`/knowledge/${knowledgeBaseId}`)
return doc
}2.3 文档解析
不同格式的文件需要不同的解析器:
// lib/ai/rag/parse.ts
export async function parseDocument(fileKey: string, mimeType: string): Promise<string> {
const fileBuffer = await downloadFromS3(fileKey)
switch (mimeType) {
case 'text/plain':
case 'text/markdown':
return fileBuffer.toString('utf-8')
case 'application/pdf':
return parsePDF(fileBuffer)
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return parseDocx(fileBuffer)
default:
throw new Error(`Unsupported mime type: ${mimeType}`)
}
}
// PDF 解析
async function parsePDF(buffer: Buffer): Promise<string> {
const { default: pdfParse } = await import('pdf-parse')
const data = await pdfParse(buffer)
return data.text
}
// Word 解析
async function parseDocx(buffer: Buffer): Promise<string> {
const mammoth = await import('mammoth')
const result = await mammoth.extractRawText({ buffer })
return result.value
}2.4 文档分块(Chunking)
分块是 RAG 质量的关键环节。分块过大,检索时会引入大量无关内容;分块过小,又会丢失上下文。推荐的策略是 递归字符分块 + 重叠:
// lib/ai/rag/chunk.ts
interface ChunkOptions {
chunkSize: number // 每块目标大小(字符数)
chunkOverlap: number // 重叠区域
separators?: string[] // 分割优先级
}
export function splitIntoChunks(
text: string,
options: ChunkOptions = { chunkSize: 1000, chunkOverlap: 100 }
): string[] {
const { chunkSize, chunkOverlap } = options
const separators = options.separators || ['\n\n', '\n', '. ', ' ', '']
return recursiveSplit(text, separators, chunkSize, chunkOverlap)
}
function recursiveSplit(
text: string,
separators: string[],
chunkSize: number,
overlap: number
): string[] {
if (text.length <= chunkSize) return [text]
// 找到能分割文本的分隔符
let separator = ''
for (const sep of separators) {
if (text.includes(sep)) {
separator = sep
break
}
}
// 按分隔符拆分
const splits = separator ? text.split(separator) : [text]
const chunks: string[] = []
let currentChunk = ''
for (const split of splits) {
const piece = separator ? split + separator : split
if ((currentChunk + piece).length > chunkSize && currentChunk.length > 0) {
chunks.push(currentChunk.trim())
// 重叠:保留上一块末尾的内容
currentChunk = currentChunk.slice(-overlap) + piece
} else {
currentChunk += piece
}
}
if (currentChunk.trim()) chunks.push(currentChunk.trim())
return chunks
}分块策略的选择依据:
- 段落优先:先按
\n\n(段落)分割,保持语义完整性 - 句子次之:段落太长时按
\n或.分割 - 重叠保证连续性:100 字符的重叠确保分块边界处的信息不丢失
- 1000 字符 ≈ 250 tokens:每块约 250 tokens,5 块 Top-K 占 ~1250 tokens,留足空间给对话历史和回答
2.5 Embedding 与入库
// lib/ai/rag/embed.ts
import { embed, embedMany } from 'ai'
import { openai } from '@ai-sdk/openai'
const embeddingModel = openai.embedding('text-embedding-3-small')
// 单条文本嵌入
export async function embedText(text: string): Promise<number[]> {
const { embedding } = await embed({
model: embeddingModel,
value: text,
})
return embedding
}
// 批量嵌入(更高效)
export async function embedTexts(texts: string[]): Promise<number[][]> {
const { embeddings } = await embedMany({
model: embeddingModel,
values: texts,
})
return embeddings
}2.6 异步处理函数
// lib/inngest/functions/process-document.ts
import { inngest } from '../client'
export const processDocument = inngest.createFunction(
{ id: 'process-document', retries: 3 },
{ event: 'knowledge/document.process' },
async ({ event, step }) => {
const { documentId, knowledgeBaseId } = event.data
// 1. 获取文档信息
const doc = await step.run('get-document', async () => {
const [d] = await db.select().from(documents).where(eq(documents.id, documentId))
return d
})
// 2. 更新状态为处理中
await step.run('update-status-processing', async () => {
await db.update(documents)
.set({ status: 'processing' })
.where(eq(documents.id, documentId))
})
// 3. 解析文档
const text = await step.run('parse-document', async () => {
return parseDocument(doc.fileKey!, doc.mimeType!)
})
// 4. 获取知识库配置
const kb = await step.run('get-kb-config', async () => {
const [k] = await db.select().from(knowledgeBases)
.where(eq(knowledgeBases.id, knowledgeBaseId))
return k
})
// 5. 分块
const chunks = await step.run('chunk-text', async () => {
return splitIntoChunks(text, {
chunkSize: (kb.chunkSize || 512) * 4, // tokens → 字符近似
chunkOverlap: (kb.chunkOverlap || 50) * 4,
})
})
// 6. 批量 Embedding(每批 100 条)
const allEmbeddings: number[][] = []
for (let i = 0; i < chunks.length; i += 100) {
const batch = chunks.slice(i, i + 100)
const embeddings = await step.run(`embed-batch-${i}`, async () => {
return embedTexts(batch)
})
allEmbeddings.push(...embeddings)
}
// 7. 写入 document_chunks
await step.run('insert-chunks', async () => {
const values = chunks.map((content, index) => ({
documentId,
content,
embedding: allEmbeddings[index],
chunkIndex: index,
metadata: { documentName: doc.name },
}))
await db.insert(documentChunks).values(values)
})
// 8. 更新文档状态
await step.run('update-status-ready', async () => {
await db.update(documents)
.set({ status: 'ready', chunkCount: chunks.length })
.where(eq(documents.id, documentId))
await db.update(knowledgeBases)
.set({
documentCount: sql`${knowledgeBases.documentCount} + 1`,
})
.where(eq(knowledgeBases.id, knowledgeBaseId))
})
return { chunkCount: chunks.length }
}
)每一步用 step.run() 包裹,Inngest 会自动处理重试和幂等性——如果 Embedding 步骤失败,不会重复解析文档。
3. 向量检索
3.1 基础向量检索
// lib/ai/rag/retrieve.ts
import { sql } from 'drizzle-orm'
interface RetrievalResult {
content: string
similarity: number
documentName: string
chunkIndex: number
documentId: string
}
export async function retrieveRelevantChunks(
query: string,
knowledgeBaseId: string,
options: { topK?: number; threshold?: number } = {}
): Promise<RetrievalResult[]> {
const { topK = 5, threshold = 0.7 } = options
// 1. 将查询转为向量
const queryEmbedding = await embedText(query)
// 2. pgvector 余弦相似度搜索
const results = await db.execute(sql`
SELECT
dc.content,
dc.chunk_index,
dc.document_id,
dc.metadata->>'documentName' as document_name,
1 - (dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector) as similarity
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE d.knowledge_base_id = ${knowledgeBaseId}
AND d.status = 'ready'
AND 1 - (dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector) > ${threshold}
ORDER BY dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector
LIMIT ${topK}
`)
return results.rows as RetrievalResult[]
}<=> 是 pgvector 的余弦距离运算符,1 - distance 得到相似度(0-1,越高越相似)。
3.2 混合检索(Hybrid Search)
纯向量检索在精确匹配(如产品名称、错误代码)上表现不佳。混合检索结合向量语义搜索和传统关键词搜索,两者互补:
export async function hybridRetrieve(
query: string,
knowledgeBaseId: string,
options: { topK?: number } = {}
): Promise<RetrievalResult[]> {
const { topK = 5 } = options
const queryEmbedding = await embedText(query)
// 同时执行向量搜索和全文搜索
const results = await db.execute(sql`
WITH vector_results AS (
SELECT dc.id, dc.content, dc.document_id, dc.chunk_index, dc.metadata,
1 - (dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector) as vector_score
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE d.knowledge_base_id = ${knowledgeBaseId} AND d.status = 'ready'
ORDER BY dc.embedding <=> ${JSON.stringify(queryEmbedding)}::vector
LIMIT ${topK * 2}
),
keyword_results AS (
SELECT dc.id, dc.content, dc.document_id, dc.chunk_index, dc.metadata,
ts_rank(to_tsvector('english', dc.content), plainto_tsquery('english', ${query})) as keyword_score
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE d.knowledge_base_id = ${knowledgeBaseId} AND d.status = 'ready'
AND to_tsvector('english', dc.content) @@ plainto_tsquery('english', ${query})
LIMIT ${topK * 2}
)
SELECT DISTINCT ON (id)
content, document_id, chunk_index,
metadata->>'documentName' as document_name,
COALESCE(v.vector_score, 0) * 0.7 + COALESCE(k.keyword_score, 0) * 0.3 as combined_score
FROM vector_results v
FULL OUTER JOIN keyword_results k USING (id, content, document_id, chunk_index, metadata)
ORDER BY id, combined_score DESC
LIMIT ${topK}
`)
return results.rows as RetrievalResult[]
}权重分配 0.7 向量 + 0.3 关键词 是常见起点,可以根据实际效果调整。
3.3 查询改写(Query Rewriting)
用户的问题经常不适合直接检索——太口语化、太简短、或者包含代词。查询改写让 LLM 先优化问题,再拿优化后的问题去检索:
// lib/ai/rag/query-rewrite.ts
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function rewriteQuery(
query: string,
chatHistory?: string
): Promise<string> {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
system: `You are a search query optimizer. Given a user question (and optional chat history),
rewrite it into a clear, self-contained search query.
Rules:
- Resolve pronouns (it, this, that) using chat history
- Expand abbreviations
- Keep it concise (under 50 words)
- Return ONLY the rewritten query`,
prompt: chatHistory
? `Chat history:\n${chatHistory}\n\nLatest question: ${query}`
: query,
maxTokens: 100,
})
return text.trim()
}例如,对话中用户先问"退款政策是什么",然后问"超过 30 天呢"——查询改写会把第二个问题转化为"超过 30 天的退款政策是什么"。
4. RAG 对话集成
4.1 RAG 增强的对话 API
// app/api/chat/route.ts(RAG 增强版)
export async function POST(req: Request) {
const session = await getSession()
if (!session) return new Response('Unauthorized', { status: 401 })
const tenantId = await getCurrentTenantId()
const { messages: clientMessages, conversationId, knowledgeBaseId } = await req.json()
const lastUserMsg = clientMessages[clientMessages.length - 1]
let ragContext = ''
let sources: RetrievalResult[] = []
// 如果指定了知识库,执行 RAG 检索
if (knowledgeBaseId && lastUserMsg.role === 'user') {
const rewrittenQuery = await rewriteQuery(lastUserMsg.content)
sources = await retrieveRelevantChunks(rewrittenQuery, knowledgeBaseId)
if (sources.length > 0) {
ragContext = sources
.map((s, i) => `[Source ${i + 1}: ${s.documentName}]\n${s.content}`)
.join('\n\n---\n\n')
}
}
const systemPrompt = buildRAGSystemPrompt(ragContext)
const result = streamText({
model: openai('gpt-4o-mini'),
system: systemPrompt,
messages: clientMessages,
onFinish: async ({ text, usage }) => {
await saveAssistantMessage(conversationId, text, usage, {
sources: sources.map(s => ({
documentName: s.documentName,
chunkIndex: s.chunkIndex,
similarity: s.similarity,
})),
})
},
})
// 将 sources 作为流数据附带返回
return result.toDataStreamResponse({
getErrorMessage: (error) => String(error),
})
}
function buildRAGSystemPrompt(context: string): string {
if (!context) {
return 'You are a helpful assistant. If you cannot find relevant information, say so honestly.'
}
return `You are a helpful assistant that answers questions based on the provided documents.
IMPORTANT RULES:
- Base your answers ONLY on the provided context. Do not use outside knowledge.
- If the context doesn't contain relevant information, say "I couldn't find relevant information in the documents."
- When citing information, reference the source (e.g., "According to [Source 1]...").
- Be precise and factual. Do not speculate or infer beyond what the documents state.
CONTEXT:
${context}`
}4.2 引用溯源
让用户知道 AI 的回答来自哪个文档的哪个部分,是建立信任的关键。引用信息可以通过 AI SDK 的 Data 通道传递:
// 在流开始前,通过 data 附加 sources
import { createDataStream } from 'ai'
const dataStream = createDataStream({
execute: async (writer) => {
// 先发送 sources 数据
writer.writeData({
type: 'sources',
sources: sources.map(s => ({
documentName: s.documentName,
similarity: Math.round(s.similarity * 100),
preview: s.content.slice(0, 150) + '...',
})),
})
// 然后开始流式生成
const result = streamText({
model: openai('gpt-4o-mini'),
system: systemPrompt,
messages: clientMessages,
})
result.mergeIntoDataStream(writer)
},
})
return new Response(dataStream.stream, {
headers: { 'Content-Type': 'text/event-stream' },
})客户端通过 useChat 的 data 字段获取引用信息:
const { messages, data } = useChat({ api: '/api/chat' })
// data 中包含 sources 信息
const sources = data?.find(d => d.type === 'sources')?.sources5. 知识库管理
5.1 知识库 CRUD
// lib/actions/knowledge.ts
export async function createKnowledgeBase(data: { name: string; description?: string }) {
const { membership } = await requirePermission('ai:knowledge:write')
const [kb] = await db.insert(knowledgeBases).values({
tenantId: membership.tenantId,
name: data.name,
description: data.description,
}).returning()
return kb
}
export async function deleteKnowledgeBase(knowledgeBaseId: string) {
const { membership } = await requirePermission('ai:knowledge:write')
// 验证归属
const [kb] = await db.select().from(knowledgeBases)
.where(and(
eq(knowledgeBases.id, knowledgeBaseId),
eq(knowledgeBases.tenantId, membership.tenantId),
))
if (!kb) throw new Error('Knowledge base not found')
// 级联删除:documents → document_chunks
await db.delete(knowledgeBases).where(eq(knowledgeBases.id, knowledgeBaseId))
// 清理 S3 文件(异步)
await inngest.send({
name: 'knowledge/cleanup-files',
data: { tenantId: membership.tenantId, knowledgeBaseId },
})
}5.2 文档状态管理
文档从上传到可用有明确的状态流转:
pending → processing → ready
↘ failed
在知识库详情页面显示每个文档的处理状态:
// components/document-status.tsx
const STATUS_MAP = {
pending: { label: 'Queued', color: 'bg-yellow-100 text-yellow-700' },
processing: { label: 'Processing', color: 'bg-blue-100 text-blue-700' },
ready: { label: 'Ready', color: 'bg-green-100 text-green-700' },
failed: { label: 'Failed', color: 'bg-red-100 text-red-700' },
}
export function DocumentStatus({ status }: { status: string }) {
const config = STATUS_MAP[status as keyof typeof STATUS_MAP]
return (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}5.3 重新处理失败文档
export async function retryDocumentProcessing(documentId: string) {
const { membership } = await requirePermission('ai:knowledge:write')
// 清除旧的 chunks
await db.delete(documentChunks).where(eq(documentChunks.documentId, documentId))
// 重置状态
await db.update(documents)
.set({ status: 'pending', errorMessage: null, chunkCount: 0 })
.where(eq(documents.id, documentId))
// 重新触发处理
const [doc] = await db.select().from(documents).where(eq(documents.id, documentId))
await inngest.send({
name: 'knowledge/document.process',
data: { documentId, knowledgeBaseId: doc.knowledgeBaseId },
})
}6. RAG 质量优化
6.1 分块质量评估
分块质量可以通过一个简单的测试衡量:给定一组已知问答对,检查检索到的文档块是否包含正确答案。
// scripts/eval-rag.ts
interface TestCase {
question: string
expectedDocumentName: string
expectedKeywords: string[]
}
async function evaluateRAG(knowledgeBaseId: string, testCases: TestCase[]) {
let hits = 0
for (const tc of testCases) {
const results = await retrieveRelevantChunks(tc.question, knowledgeBaseId)
const hasCorrectDoc = results.some(r => r.documentName === tc.expectedDocumentName)
const hasKeywords = tc.expectedKeywords.some(kw =>
results.some(r => r.content.toLowerCase().includes(kw.toLowerCase()))
)
if (hasCorrectDoc && hasKeywords) hits++
console.log(`${hasCorrectDoc && hasKeywords ? '✅' : '❌'} ${tc.question}`)
}
console.log(`\nAccuracy: ${hits}/${testCases.length} (${(hits/testCases.length*100).toFixed(1)}%)`)
}6.2 上下文窗口扩展
检索到的块可能缺少上下文。一个有效的策略是 Parent-Child 检索——检索到精确的小块后,把它的前后相邻块也纳入上下文:
export async function retrieveWithContext(
query: string,
knowledgeBaseId: string
): Promise<RetrievalResult[]> {
const results = await retrieveRelevantChunks(query, knowledgeBaseId, { topK: 3 })
// 对每个结果,获取前后各一个块
const expanded: RetrievalResult[] = []
for (const result of results) {
const neighbors = await db.select()
.from(documentChunks)
.where(and(
eq(documentChunks.documentId, result.documentId),
sql`${documentChunks.chunkIndex} BETWEEN ${result.chunkIndex - 1} AND ${result.chunkIndex + 1}`,
))
.orderBy(asc(documentChunks.chunkIndex))
const combined = neighbors.map(n => n.content).join('\n')
expanded.push({ ...result, content: combined })
}
return expanded
}本章小结
- RAG 原理:检索相关文档块 → 注入 Prompt → 基于文档内容生成回答
- 质量关键:分块策略 > Embedding 模型 > 检索数量,检索质量是 RAG 效果的天花板
- 文档管道:上传 → S3 存储 → 异步解析(PDF/Word/TXT) → 分块 → Embedding → 入库
- 递归分块:段落优先分割,1000 字符 + 100 字符重叠,保持语义完整
- Inngest 异步:文档处理用 step.run() 步骤化,失败自动重试,不重复处理已完成步骤
- 向量检索:pgvector 余弦距离,相似度阈值 0.7 过滤噪声
- 混合检索:0.7 向量 + 0.3 关键词,解决精确匹配不足的问题
- 查询改写:用 LLM 优化用户问题,解决代词和模糊表述
- 引用溯源:通过 Data Stream 将 sources 附带给客户端,标注文档名和相似度
- 质量优化:评估脚本量化检索准确率,Parent-Child 检索扩展上下文