返回笔记首页

第二章:知识库问答(RAG)

主题配置

源码

workmind-02-rag.zip


2.1 本章目标

学完本章,你能实现:

  • 上传 PDF / TXT / Markdown 文档到知识库
  • 用语义搜索找到最相关的文档片段
  • 生成带来源标注的回答(告诉用户答案来自哪里)
  • 按分类管理文档(技术文档、HR 制度、产品手册...)

本章对应项目功能:模块二 — 知识库问答


2.2 RAG 是什么,解决什么问题

问题

直接问大模型"我们公司差旅报销标准是多少",它不知道。 因为这是公司内部规定,不在模型的训练数据里。

方案一:把所有文档塞进 Prompt

plain
system: 你是助手,以下是公司所有制度文档:[1000页文档]
user: 差旅报销标准是多少?

问题:上下文窗口撑不住,而且 token 费用极高。

方案二:RAG(Retrieval-Augmented Generation)
plain
用户问题 → 向量化问题 → 在文档库里语义搜索
→ 找到最相关的 3-5 段 → 连同问题一起发给模型
→ 模型根据这几段内容回答

只把相关内容给模型,既省 token,质量还更高。

RAG 的两个阶段

plain
【离线阶段:文档入库】
文档文件
  → 读取文本
  → 按 500 字分片(Chunking)
  → 每片向量化(Embedding,变成数字数组)
  → 存入向量数据库(Chroma)

【在线阶段:用户提问】
用户问题
  → 问题向量化
  → 在向量库里找相似度最高的片段
  → 把片段 + 问题发给模型
  → 模型生成回答

2.3 核心概念:Embedding(向量化)

Embedding 是把文字转成数字数组(向量)的技术。

plain
"Vue3 的响应式系统"  →  [-0.012, 0.034, 0.089, ..., -0.045]  (1536 个数字)
"React 的状态管理"   →  [-0.008, 0.041, 0.076, ..., -0.031]  (1536 个数字)
"今天天气不错"       →  [0.091, -0.023, 0.112, ..., 0.067]    (1536 个数字)

语义相近的文字,它们的向量在空间中距离更近。 这就是"语义搜索"的原理:找向量距离最近的文档,就是找语义最相关的文档。

plain
// 用余弦相似度衡量两段文字有多像
function cosineSimilarity(a, b) {
  let dot = 0, na = 0, nb = 0
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i]
    na  += a[i] ** 2
    nb  += b[i] ** 2
  }
  return dot / (Math.sqrt(na) * Math.sqrt(nb))
  // 结果 0~1,越接近 1 越相似
}

关键点

  • 我们用 OpenAI 的 text-embedding-3-small 模型做 Embedding
  • DeepSeek 目前没有 Embedding 模型,所以这里需要 OpenAI Key
  • 如果不想用 OpenAI,可以换成本地的 Ollama nomic-embed-text 模型(免费)

2.4 文档分片(Chunking)

不能把整篇文档存成一个向量,太长了:

  1. Embedding 模型有长度限制(通常 512-8000 tokens)
  2. 太长的文档,向量不够精确,搜索质量差
  3. 只需要最相关的几段,不需要整篇

分片策略

plain
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize:    500,   // 每片最多 500 字符
  chunkOverlap: 50,    // 相邻两片重叠 50 字(防止语义在边界断裂)
  // 分割优先级:先按段落,再按句子,实在不行按字符
  separators: ['\n\n', '\n', '。', ';', ',', ' ', ''],
})

为什么要 overlap(重叠)?

plain
原文:...前三名员工可获得奖金。奖金标准为:
      第一名 5000 元,第二名 3000 元...

不重叠分片:
  片段1:...前三名员工可获得奖金。
  片段2:奖金标准为:第一名 5000 元...

问题:用户问"奖金怎么发",片段2 里没有"获得奖金"这几个字,
      向量搜索可能找不到片段2。

重叠分片(overlap=50):
  片段1:...前三名员工可获得奖金。奖金标准为:
  片段2:奖金标准为:第一名 5000 元,第二名...

效果:每个片段都包含前后的上下文,搜索准确率大幅提升。

2.5 向量数据库:Chroma

Chroma 是专门为 AI 应用设计的开源向量数据库。

启动 Chroma

bash
docker run -d -p 8000:8000 chromadb/chroma

基本操作

plain
import { Chroma } from '@langchain/community/vectorstores/chroma'
import { OpenAIEmbeddings } from '@langchain/openai'

const embeddings = new OpenAIEmbeddings({ model: 'text-embedding-3-small' })

// 创建集合并写入文档(自动向量化)
const vectorStore = await Chroma.fromDocuments(
  documents,   // Document[] 数组,每个有 pageContent 和 metadata
  embeddings,
  {
    collectionName: 'workmind-knowledge',
    url: 'http://localhost:8000',
  }
)

// 语义搜索(返回最相似的 4 条)
const results = await vectorStore.similaritySearchWithScore(
  '差旅费报销标准',
  4,              // 返回 k 条
  { category: 'HR制度' }  // 可选:按 metadata 过滤
)

// results 是 [Document, score][] 数组
// score 是相似度分数(0-1,越高越相关)
results.forEach(([doc, score]) => {
  console.log(score, doc.pageContent)
})

2.6 服务端实现

2.6.1 文档入库流程

server/src/services/rag/ingest.js 实现了完整的入库流程:

plain
export async function ingestDocument({ filePath, fileName, title, category, mimeType }) {
  const docId = `doc_${Date.now()}`

  // 1. 读取文件文本(根据扩展名选择解析方式)
  const rawText = await extractText(filePath, mimeType)

  // 2. 分片
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 500, chunkOverlap: 50,
  })
  const chunks = await splitter.createDocuments(
    [rawText],
    [{ docId, title, category, uploadedAt: new Date().toISOString() }]
  )
  // 每个 chunk 都携带 metadata,检索到时知道来自哪个文档

  // 3. 向量化并存入 Chroma
  const vs = await getVectorStore()
  await vs.addDocuments(chunks)

  // 4. 注册元数据(前端展示文档列表用)
  docRegistry.set(docId, { id: docId, title, category, chunks: chunks.length, ... })

  return docMeta
}

2.6.2 RAG 查询流程

server/src/services/rag/query.js 实现了检索 + 生成:

plain
export async function ragQueryStream(question, options = {}) {
  // 第一步:语义检索
  const docs = await retrieveDocs(question, options)

  return {
    sources: docs,   // 先把来源返回给前端
    async *streamAnswer() {
      if (!docs.length) {
        yield '知识库中未找到相关内容...'
        return
      }

      // 格式化参考资料
      const context = docs
        .map((doc, i) => `[参考${i+1}] 来源:${doc.title}\n${doc.content}`)
        .join('\n\n---\n\n')

      // 调用模型生成回答
      const prompt = ChatPromptTemplate.fromMessages([
        ['system', '只根据参考文档回答,文档外的不回答,结尾标注来源'],
        ['human', '参考文档:\n{context}\n\n问题:{question}'],
      ])

      const stream = await prompt.pipe(chatModel).stream({ context, question })

      for await (const chunk of stream) {
        if (chunk.content) yield chunk.content   // 逐 token 推送
      }
    },
  }
}

2.6.3 路由接口

server/src/routes/knowledge.js 提供了 4 个接口:

plain
POST   /api/knowledge/documents          上传文档入库
GET    /api/knowledge/documents          获取文档列表(可按分类过滤)
DELETE /api/knowledge/documents/:docId   删除文档
POST   /api/knowledge/query/stream       RAG 问答(SSE 流式)

流式接口的 SSE 事件顺序

plain
event: status       → { message: "正在检索相关文档..." }
event: sources      → { sources: [...] }           先推送来源(让用户看到在参考哪些文档)
event: status       → { message: "正在生成回答..." }
event: token        → { token: "根" }
event: token        → { token: "据" }
event: token        → { token: "规" }
...
event: done         → {}

2.7 前端实现

2.7.1 文件上传进度

DocumentUploader.vue 用原生 XMLHttpRequest 监听上传进度(axios 的 onUploadProgress 也可以):

plain
const result = await new Promise((resolve, reject) => {
  const xhr = new XMLHttpRequest()
  xhr.open('POST', '/api/knowledge/documents')

  // 监听上传进度
  xhr.upload.addEventListener('progress', (e) => {
    if (e.lengthComputable) {
      // 上传占 0-80%,向量化处理占 80-100%
      uploadProgress.value = Math.round((e.loaded / e.total) * 80)
    }
  })

  xhr.addEventListener('load', () => {
    uploadProgress.value = 100
    resolve(JSON.parse(xhr.responseText))
  })

  xhr.send(formData)
})

2.7.2 来源展开/折叠

每条 AI 回答上方展示参考的文档片段,可以展开查看原文:

vue
<div class="source-item" v-for="(src, i) in msg.sources">
  <span>[{{ i+1 }}]</span>
  <span>{{ src.title }}</span>
  <span>相似度 {{ (src.score * 100).toFixed(0) }}%</span>

  <!-- 展开/折叠按钮 -->
  <button @click="toggleSource(msg.id, i)">
    {{ expandedSources[`${msg.id}_${i}`] ? '▲' : '▼' }}
  </button>

  <!-- 展开后显示原文片段 -->
  <div v-if="expandedSources[`${msg.id}_${i}`]" class="source-content">
    {{ src.content }}
  </div>
</div>

reactive({}) 存展开状态,key 是 msgId_sourceIndex

plain
const expandedSources = reactive({})

function toggleSource(msgId, idx) {
  const key = `${msgId}_${idx}`
  expandedSources[key] = !expandedSources[key]
}

2.7.3 knowledge store 核心逻辑

plain
// stores/knowledge.js
async function query(question) {
  // 1. 添加用户消息
  messages.value.push({ role: 'user', content: question, ... })

  // 2. 添加 AI 消息占位
  const aiMsg = { role: 'assistant', content: '', sources: [], status: '...', streaming: true }
  messages.value.push(aiMsg)

  // 3. 调用流式接口
  await fetchStream('/api/knowledge/query/stream', { question }, {
    onToken: (token) => { aiMsg.content += token },    // 追加内容
    onEvent: (event, data) => {
      if (event === 'sources') aiMsg.sources = data.sources  // 更新来源
      if (event === 'status')  aiMsg.status = data.message   // 更新状态
    },
    onDone: () => { aiMsg.streaming = false },
  })
}

2.8 相似度阈值的选择

plain
// query.js
const SIMILARITY_THRESHOLD = 0.3   // 低于 0.3 的结果过滤掉

这个值怎么选?用实际数据测试:

阈值 效果
0.1 引入大量无关文档,回答质量差,容易"胡说"
0.3 推荐起点,平衡召回率和精确率
0.5 只返回非常相关的,但可能漏掉有用的
0.7 很严格,很多问题会返回"未找到相关内容"

实际业务中根据文档类型和用户反馈调整。


2.9 System Prompt 的重要性

RAG 的效果很大程度取决于 System Prompt:

plain
// 好的 RAG System Prompt
const RAG_SYSTEM = `你是知识库助手。

规则:
1. 只根据下方参考文档回答,不使用文档之外的知识
2. 如果文档中没有相关内容,明确说"知识库中未找到相关内容",不要编造
3. 回答要准确、简洁
4. 在回答末尾用【来源:文档名】标注使用了哪些文档`

// 不好的 System Prompt(没有约束)
const BAD_SYSTEM = '你是一个助手,根据参考文档回答。'
// 模型可能会在文档内容之外加入自己的"知识",导致回答不可靠

关键约束

  • 明确说只用文档内容,不用外部知识
  • 如果没找到要明确说,不要编造(这是 RAG 最常见的问题)
  • 要求标注来源,增加可信度

2.10 本章作业

✅****基础功能

  • 能上传 .txt 文件并看到"入库成功"提示
  • 文档列表正常展示(标题、片段数、分类)
  • 基于上传的文档提问,能得到回答
  • 回答上方能看到参考文档来源

✅****进阶功能

  • 上传时能看到进度条
  • 展开来源时能看到原始片段内容和相似度分数
  • 分类筛选:只在"HR制度"里提问,不检索其他分类
  • 对文档内没有的内容提问,AI 正确回复"未找到相关内容"
✅****测试场景
  1. 上传一份包含假期制度的文档,提问:年假怎么计算?
  2. 提问一个文档里完全没有的问题(如:明天天气怎么样),验证 AI 不会胡编
  3. 上传多个不同分类的文档,测试分类筛选是否生效

2.11 常见问题

Q:提示"未配置 OPENAI_API_KEY,无法使用 RAG 功能"?

RAG 的 Embedding 步骤需要 OpenAI 的 API Key。 在 server/.env 里加上 OPENAI_API_KEY=sk-xxx

如果想用免费方案,可以用本地 Ollama 的 Embedding:

bash
ollama pull nomic-embed-text

然后把 model.js 里的 OpenAIEmbeddings 换成 OllamaEmbeddings

Q:提示"连接 Chroma 失败"?

确认 Chroma 已启动:

bash
docker run -d -p 8000:8000 chromadb/chroma
# 验证:curl http://localhost:8000/api/v1/heartbeat
Q:PDF 上传后内容为空?

需要安装 pdf-parse:

bash
cd server && npm install pdf-parse
Q:提问结果不准确,搜到了不相关的文档?

可以适当调高相似度阈值:

plain
// query.js
const SIMILARITY_THRESHOLD = 0.4  // 从 0.3 调高到 0.4
Q:向量化很慢?

OpenAI Embedding API 的延迟通常在 1-3 秒/次。 对于大文档(几百个分片),可以:

  1. 增大 chunkSize 到 800,减少片段数
  2. 用批量 API(embedDocuments 比循环 embedQuery 快)
  3. 本地 Embedding 模型(无网络延迟)