Appearance
课 2 · 接入主线项目
本课目标
将 RAG 检索能力接入主线聊天应用,实现文档导入和引用来源展示。课后你会拿到一个支持知识库问答的完整聊天程序。
上一课我们跑通了 RAG 的完整流水线。这一课把它接入主线项目,让聊天应用具备知识库问答能力。
RAG 与聊天系统的结合方式
把 RAG 接入聊天系统,核心改动在一个地方:在生成回答之前,先用用户的问题做一次检索,把结果塞进 System Prompt。
先看对照图:
普通聊天 vs RAG 聊天
普通聊天
用户提问
↓
直接发给模型
↓
生成回答
RAG 聊天
用户提问
↓
先检索相关文档
↓
文档 + 问题发给模型
↓
基于资料回答
用户提问 → 检索相关文档 → 文档 + 问题一起传给模型 → 生成回答原来的聊天流程是:
typescript
const result = streamText({
model,
system: '你是技术顾问...',
messages,
})加了 RAG 之后:
typescript
// 先检索
const docs = await search(userQuestion)
const context = docs.map(d => d.content).join('\n\n---\n\n')
// 把检索结果放进 system prompt
const result = streamText({
model,
system: `你是知识库助手。基于以下文档回答问题,未找到信息时说明。
<documents>
${context}
</documents>`,
messages,
})改动很小——只是在调用模型之前多了一步检索,然后把结果拼进 System Prompt。
文档导入流程
实际项目中,文档不会像上一课那样硬编码在代码里。需要一个导入流程:
导入接口设计
typescript
interface DocumentInput {
title: string
content: string
source?: string // 文件路径或 URL
}
// 假设沿用上一课初始化好的 embeddingModel = provider.embedding('text-embedding-v4')
async function importDocument(doc: DocumentInput) {
// 1. 切块
const chunks = recursiveChunk(doc.content)
// 2. 批量 Embedding
const { embeddings } = await embedMany({
model: embeddingModel,
values: chunks,
})
// 3. 存储
for (let i = 0; i < chunks.length; i++) {
vectorStore.push({
content: chunks[i],
embedding: embeddings[i],
metadata: {
source: doc.title,
chunkIndex: i,
totalChunks: chunks.length,
},
})
}
return { chunksCount: chunks.length }
}支持多种文档格式
typescript
import { readFileSync } from 'node:fs'
import { extname } from 'node:path'
function loadDocument(filePath: string): DocumentInput {
const content = readFileSync(filePath, 'utf-8')
const ext = extname(filePath)
switch (ext) {
case '.md':
return { title: filePath, content, source: filePath }
case '.txt':
return { title: filePath, content, source: filePath }
default:
throw new Error(`不支持的文件格式: ${ext}`)
}
// 实际项目中还需要支持 .pdf(pdf-parse)、.docx(mammoth)等
}批量导入
typescript
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
async function importDirectory(dirPath: string) {
const files = readdirSync(dirPath).filter(f => f.endsWith('.md'))
console.log(`找到 ${files.length} 个文档`)
for (const file of files) {
const doc = loadDocument(join(dirPath, file))
const result = await importDocument(doc)
console.log(` ${file}: ${result.chunksCount} 个块`)
}
}引用来源展示
用户不仅想看到答案,还想知道答案是从哪来的——这是 RAG 应用区别于普通聊天的核心体验。
方案一:在回答中标注来源
在 System Prompt 中要求模型引用时标注来源:
typescript
const systemPrompt = `
<rules>
- 回答时引用文档内容,使用 [来源: 文档名] 格式标注
- 如果多个文档相关,分别标注来源
</rules>
`模型会在回答中自然地插入来源标注:
根据文档,React Server Components 不能使用 useState 和 useEffect [来源: React Server Components]。
如果需要交互功能,需要使用 Client Components [来源: Next.js App Router]。方案二:结构化输出来源信息
更可控的方式是用 generateText + Output.object() 返回结构化的回答:
typescript
import { z } from 'zod'
import { generateText, Output } from 'ai'
const RAGAnswerSchema = z.object({
answer: z.string().describe('回答内容'),
sources: z.array(z.object({
document: z.string().describe('文档标题'),
relevantContent: z.string().describe('引用的原文片段'),
})).describe('回答引用的文档来源'),
confidence: z.enum(['high', 'medium', 'low']).describe('回答置信度'),
})
const { output } = await generateText({
model,
output: Output.object({ schema: RAGAnswerSchema }),
prompt: `基于检索结果回答用户问题,并返回结构化来源信息...`,
})前端拿到结构化数据后,可以在回答下方展示引用卡片、设置置信度标识等。
常见问题处理
文档过长
单个文档超过 10 万字时,切块后可能产生几百个向量。导入速度受 Embedding API 限制。
解决方案:
- 批量请求:
embedMany一次处理多个文本,减少 HTTP 请求次数 - 增量导入:记录已处理的文档,避免重复导入
- 异步队列:大文档放入队列后台处理
格式混乱
PDF 提取的文本经常有多余的空行、页码、页眉页脚混在正文中。
typescript
function cleanText(text: string): string {
return text
.replace(/\n{3,}/g, '\n\n') // 多余空行
.replace(/^Page \d+.*$/gm, '') // 页码
.replace(/^\s*\d+\s*$/gm, '') // 孤立数字行
.trim()
}检索不准
"问了一个明明文档里有的问题,但检索出来的不是最相关的块"——这是 RAG 最常见的问题。
可能的原因:
- 切块太大:不相关的内容被包含在块中,干扰了 Embedding
- 切块太小:缺乏上下文,向量不能准确表达语义
- 问题和文档的表述差异大:用户说"怎么让页面变快",文档写的是"性能优化"
暂时的应对方式是调整切块大小。更系统的解决方案(混合检索、评测验收,以及可选的 Rerank)在下一个模块讲。
并入主线项目
本模块能力会在基础项目 basic/project/ 中收束,当前课内 Demo 见下方运行方式。
为什么这一步属于 basic/project: basic/project 解决了基础聊天,basic/project 解决了输出可控,到了 basic/project 才真正把知识库导入、检索和引用来源接进主线,让项目从聊天应用升级为 RAG 应用。
这一课给主线新增了什么能力:
| 模块 | 能力 |
|---|---|
| 模块 1 | 流式对话、多轮聊天、上下文管理 |
| 模块 2 | 结构化 Prompt、结构化输出、拒答逻辑 |
| 模块 3 | 知识库导入、语义检索、引用来源展示 |
完整代码在 basic/examples/03-rag-basics/02-integrate/index.ts。下一步主线会进入 basic/project,继续做混合检索、效果验收,以及作为扩展项的 Rerank。
本课产物
- ✅ RAG 接入聊天系统的架构
- ✅ 文档导入流程(支持批量导入)
- ✅ 引用来源展示(两种方案)
- ✅ 常见问题的处理方式
试试看
bash
cd daqi-ai-agent
pnpm exec tsx basic/examples/03-rag-basics/02-integrate/index.ts- 准备一个目录,放入自己的
.md或.txt文档,然后用load <目录路径>导入,再问一个只有该文档才能回答的问题 - 多轮追问同一个话题,观察检索上下文与对话历史如何共存
- 把代码里的
topK从 3 改成 1 和 5,对比引用来源数量和答案完整性
面试追问
Q:RAG 应用和普通聊天应用的区别在哪?
核心区别是回答之前多了一步检索。普通聊天靠模型的训练知识回答,RAG 先从知识库检索相关文档,把文档片段作为上下文传给模型。这样模型的回答有据可查,能回答私有知识,也减少幻觉。
Q:怎么让用户知道答案是可信的?
引用来源展示。在回答中标注来源文档、引用的原文片段,让用户可以点击查看原文确认。更进一步可以显示置信度——如果检索到的文档相似度低,提示用户"回答可能不够准确"。
Q:文档更新了怎么办?
增量更新:记录每个文档的版本或修改时间,有变化时删除旧的向量、重新切块入库。实际项目中通常加一个 documentId 字段,更新时先删除该文档的所有 Chunk,再重新导入。