Skip to content

课 2 · 接入主线项目

本课目标

将 RAG 检索能力接入主线聊天应用,实现文档导入和引用来源展示。课后你会拿到一个支持知识库问答的完整聊天程序。

上一课我们跑通了 RAG 的完整流水线。这一课把它接入主线项目,让聊天应用具备知识库问答能力。

RAG 与聊天系统的结合方式

把 RAG 接入聊天系统,核心改动在一个地方:在生成回答之前,先用用户的问题做一次检索,把结果塞进 System Prompt。

先看对照图:

用户提问 → 检索相关文档 → 文档 + 问题一起传给模型 → 生成回答

原来的聊天流程是:

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 最常见的问题。

可能的原因:

  1. 切块太大:不相关的内容被包含在块中,干扰了 Embedding
  2. 切块太小:缺乏上下文,向量不能准确表达语义
  3. 问题和文档的表述差异大:用户说"怎么让页面变快",文档写的是"性能优化"

暂时的应对方式是调整切块大小。更系统的解决方案(混合检索、评测验收,以及可选的 Rerank)在下一个模块讲。

并入主线项目

本模块能力会在基础项目 basic/project/ 中收束,当前课内 Demo 见下方运行方式。

为什么这一步属于 basic/projectbasic/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
  1. 准备一个目录,放入自己的 .md.txt 文档,然后用 load <目录路径> 导入,再问一个只有该文档才能回答的问题
  2. 多轮追问同一个话题,观察检索上下文与对话历史如何共存
  3. 把代码里的 topK 从 3 改成 1 和 5,对比引用来源数量和答案完整性

面试追问

Q:RAG 应用和普通聊天应用的区别在哪?

核心区别是回答之前多了一步检索。普通聊天靠模型的训练知识回答,RAG 先从知识库检索相关文档,把文档片段作为上下文传给模型。这样模型的回答有据可查,能回答私有知识,也减少幻觉。

Q:怎么让用户知道答案是可信的?

引用来源展示。在回答中标注来源文档、引用的原文片段,让用户可以点击查看原文确认。更进一步可以显示置信度——如果检索到的文档相似度低,提示用户"回答可能不够准确"。

Q:文档更新了怎么办?

增量更新:记录每个文档的版本或修改时间,有变化时删除旧的向量、重新切块入库。实际项目中通常加一个 documentId 字段,更新时先删除该文档的所有 Chunk,再重新导入。

面向前端工程师和独立开发者的 AI 应用工程课程