【从0开发一个 Agent】第九章:实现 RAG (检索增强生成)
在上一章中,我们为 AI 构建了长期记忆,让它能够跨越会话记住用户的偏好和事实。但这只解决了“认识用户”的问题。在企业级应用中,AI 还需要“认识业务”——当用户询问“公司最新的报销流程是什么”或“这个 API 接口的错误码是什么意思”时,AI 必须给出基于企业内部文档的准确回答,而不是凭空捏造(幻觉)。
本章,我们将实现 RAG(Retrieval-Augmented Generation,检索增强生成),让 AI 拥有外挂的“企业大脑”。
1. 为什么模型不知道企业知识?
大语言模型的知识来源于其训练数据,存在两个致命缺陷:
- 知识截止(Knowledge Cutoff):模型只知道训练时的数据,无法获取企业昨天刚发布的政策。
- 私有知识盲区:企业的内部文档、代码库、客户数据从未在公网上公开过,模型自然无从知晓。
RAG 的核心思想:在模型生成答案之前,先去外部知识库中“查资料”,然后把查到的资料作为上下文(Context)一起喂给模型,让它“开卷考试”。
2. RAG 核心概念与架构
要构建 RAG,我们需要理解四个核心概念:
- Chunk(文本块):将长文档切分成适合检索的小片段。
- Embedding(向量嵌入):将文本转换为高维数字向量,使语义相近的文本在向量空间中距离更近。
- Vector(向量存储):存储这些向量,并提供高效的相似度搜索能力。
- Retriever(检索器):根据用户问题,从向量库中找出最相关的文本块。

3. 数据库设计
在上一章的基础上,我们需要新增知识库相关的表结构:
// prisma/schema.prisma
model Document {
id String @id @default(uuid())
userId String // 知识库隔离:确保用户只能检索自己的文档
filename String
content String @db.Text
chunks Chunk[]
createdAt DateTime @default(now())
}
model Chunk {
id String @id @default(uuid())
documentId String
content String @db.Text
embedding Float32Array @db.Vector(1536) // 1536 维向量 (OpenAI text-embedding-3-small)
metadata Json? // 存储页码、章节等元数据
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
}
4. 核心代码实现
4.1 文档解析与切分 (Chunking)
首先,我们需要处理用户上传的 PDF。这里使用 pdf-parse 提取文本,并进行智能切分。
npm install pdf-parse
// src/lib/rag/chunker.ts
import pdfParse from 'pdf-parse';
export async function parseAndChunk(fileBuffer: Buffer, chunkSize = 500, overlap = 50) {
const pdfData = await pdfParse(fileBuffer);
const text = pdfData.text;
const chunks: string[] = [];
let startIndex = 0;
while (startIndex < text.length) {
const endIndex = Math.min(startIndex + chunkSize, text.length);
chunks.push(text.slice(startIndex, endIndex));
startIndex += chunkSize - overlap; // 滑动窗口,保留重叠部分以维持上下文
}
return chunks;
}
4.2 文档上传与向量化入库
创建一个 API 路由来处理文件上传、解析、向量化并存入数据库:
// src/app/api/documents/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { parseAndChunk } from '@/lib/rag/chunker';
import { openai } from '@/lib/ai/config';
import { prisma } from '@/lib/db';
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File;
const userId = 'user_123'; // 实际项目中从 Session 获取
const buffer = Buffer.from(await file.arrayBuffer());
const chunks = await parseAndChunk(buffer);
// 批量生成 Embedding
const embeddings = await Promise.all(
chunks.map(chunk => openai.embedding('text-embedding-3-small').doEmbed(chunk))
);
// 存入数据库
const document = await prisma.document.create({
data: {
userId,
filename: file.name,
content: chunks.join('\n'),
chunks: {
create: chunks.map((content, index) => ({
content,
embedding: embeddings[index],
metadata: { pageIndex: Math.floor(index / 10) }, // 简单的页码估算
})),
},
},
});
return NextResponse.json({ success: true, documentId: document.id });
}
4.3 检索与注入 Prompt
在对话接口中,我们将检索到的知识块动态注入到 System Prompt 中:
// src/app/api/chat/route.ts
async function retrieveContext(userId: string, query: string) {
const queryEmbedding = await openai.embedding('text-embedding-3-small').doEmbed(query);
// 使用 pgvector 进行余弦相似度检索
const relevantChunks = await prisma.$queryRaw`
SELECT c.content, d.filename
FROM "Chunk" c
JOIN "Document" d ON c."documentId" = d.id
WHERE d."userId" = ${userId}
ORDER BY c.embedding <=> ${queryEmbedding}::vector
LIMIT 3
`;
if (!relevantChunks.length) return null;
const contextText = relevantChunks
.map((c: any) => `[来源: ${c.filename}]\n${c.content}`)
.join('\n\n');
return contextText;
}
export async function POST(req: Request) {
const { messages, userId } = await req.json();
const lastMessage = messages[messages.length - 1].content;
// 1. 检索知识库
const context = await retrieveContext(userId, lastMessage);
// 2. 动态组装 System Prompt
const systemPrompt = context
? `你是一个专业的企业知识助手。请严格基于以下参考资料回答用户问题。如果资料中没有答案,请明确告知用户。\n\n参考资料:\n${context}`
: `你是一个专业的企业知识助手。`;
const result = streamText({
model: openai('gpt-4o'),
system: systemPrompt,
messages,
});
return result.toDataStreamResponse();
}
5. 测试验证
验证清单:
- 通过前端上传一份包含特定术语的 PDF 文档(如公司内部 API 文档)。
- 等待上传成功提示。
- 在聊天框提问:“文档中提到的认证 Token 有效期是多久?”
- 验证 AI 是否准确回答了文档中的内容,并附带了来源文件名。
- 提问一个文档中不存在的问题,验证 AI 是否会回复“资料中未找到相关信息”而不是胡编乱造。
6. 常见问题与踩坑分析
问题 1:检索到的内容不相关,导致 AI 幻觉
原因:单纯的向量相似度搜索(Dense Retrieval)在处理精确匹配(如专有名词、错误码)时表现不佳。
解决:在生产环境中,强烈建议使用混合检索(Hybrid Search)。结合 pgvector 的向量检索与 PostgreSQL 的全文检索(tsvector),并使用 RRF(Reciprocal Rank Fusion)算法融合两路结果,能大幅提升召回准确率。
问题 2:PDF 解析丢失表格或图片信息
原因:pdf-parse 只能提取纯文本,对于复杂排版的 PDF 效果很差。
解决:对于企业级复杂文档,建议使用专业的解析服务(如 LlamaParse、Unstructured)或结合多模态大模型(如 GPT-4o Vision)将表格和图片转化为 Markdown 格式后再进行切分。
本章总结
- 我们剖析了大模型在企业私有知识面前的局限性,并引入了 RAG 架构。
- 掌握了文档解析、滑动窗口切分(Chunking)以及 Embedding 向量化的完整链路。
- 利用 PostgreSQL + pgvector 实现了高效的语义检索,并完成了知识库的数据隔离。
- 通过动态注入 System Prompt,让 AI 实现了基于外部证据的“开卷考试”,有效抑制了幻觉。
至此,你的 AI Agent 已经拥有了“企业大脑”,能够基于私有文档提供精准、可追溯的回答。
但企业级的复杂任务往往不是单靠“检索+回答”就能解决的。这就涉及到了上下文被稀释,意图漂移等问题。从下一章开始,我们将引入 Prompt Engineering,让 Prompt 正确的被管理。
更多推荐


所有评论(0)