源码
2.1 本章目标
学完本章,你能实现:
- 上传 PDF / TXT / Markdown 文档到知识库
- 用语义搜索找到最相关的文档片段
- 生成带来源标注的回答(告诉用户答案来自哪里)
- 按分类管理文档(技术文档、HR 制度、产品手册...)
本章对应项目功能:模块二 — 知识库问答
2.2 RAG 是什么,解决什么问题
问题
直接问大模型"我们公司差旅报销标准是多少",它不知道。 因为这是公司内部规定,不在模型的训练数据里。
方案一:把所有文档塞进 Prompt
system: 你是助手,以下是公司所有制度文档:[1000页文档]
user: 差旅报销标准是多少?
问题:上下文窗口撑不住,而且 token 费用极高。
方案二:RAG(Retrieval-Augmented Generation)
用户问题 → 向量化问题 → 在文档库里语义搜索
→ 找到最相关的 3-5 段 → 连同问题一起发给模型
→ 模型根据这几段内容回答
只把相关内容给模型,既省 token,质量还更高。
RAG 的两个阶段
【离线阶段:文档入库】
文档文件
→ 读取文本
→ 按 500 字分片(Chunking)
→ 每片向量化(Embedding,变成数字数组)
→ 存入向量数据库(Chroma)
【在线阶段:用户提问】
用户问题
→ 问题向量化
→ 在向量库里找相似度最高的片段
→ 把片段 + 问题发给模型
→ 模型生成回答
2.3 核心概念:Embedding(向量化)
Embedding 是把文字转成数字数组(向量)的技术。
"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 个数字)
语义相近的文字,它们的向量在空间中距离更近。 这就是"语义搜索"的原理:找向量距离最近的文档,就是找语义最相关的文档。
// 用余弦相似度衡量两段文字有多像
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)
不能把整篇文档存成一个向量,太长了:
- Embedding 模型有长度限制(通常 512-8000 tokens)
- 太长的文档,向量不够精确,搜索质量差
- 只需要最相关的几段,不需要整篇
分片策略
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每片最多 500 字符
chunkOverlap: 50, // 相邻两片重叠 50 字(防止语义在边界断裂)
// 分割优先级:先按段落,再按句子,实在不行按字符
separators: ['\n\n', '\n', '。', ';', ',', ' ', ''],
})
为什么要 overlap(重叠)?
原文:...前三名员工可获得奖金。奖金标准为:
第一名 5000 元,第二名 3000 元...
不重叠分片:
片段1:...前三名员工可获得奖金。
片段2:奖金标准为:第一名 5000 元...
问题:用户问"奖金怎么发",片段2 里没有"获得奖金"这几个字,
向量搜索可能找不到片段2。
重叠分片(overlap=50):
片段1:...前三名员工可获得奖金。奖金标准为:
片段2:奖金标准为:第一名 5000 元,第二名...
效果:每个片段都包含前后的上下文,搜索准确率大幅提升。
2.5 向量数据库:Chroma
Chroma 是专门为 AI 应用设计的开源向量数据库。
启动 Chroma
docker run -d -p 8000:8000 chromadb/chroma
基本操作
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 实现了完整的入库流程:
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 实现了检索 + 生成:
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 个接口:
POST /api/knowledge/documents 上传文档入库
GET /api/knowledge/documents 获取文档列表(可按分类过滤)
DELETE /api/knowledge/documents/:docId 删除文档
POST /api/knowledge/query/stream RAG 问答(SSE 流式)
流式接口的 SSE 事件顺序
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 也可以):
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 回答上方展示参考的文档片段,可以展开查看原文:
<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:
const expandedSources = reactive({})
function toggleSource(msgId, idx) {
const key = `${msgId}_${idx}`
expandedSources[key] = !expandedSources[key]
}
2.7.3 knowledge store 核心逻辑
// 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 相似度阈值的选择
// query.js
const SIMILARITY_THRESHOLD = 0.3 // 低于 0.3 的结果过滤掉
这个值怎么选?用实际数据测试:
| 阈值 | 效果 |
|---|---|
| 0.1 | 引入大量无关文档,回答质量差,容易"胡说" |
| 0.3 | 推荐起点,平衡召回率和精确率 |
| 0.5 | 只返回非常相关的,但可能漏掉有用的 |
| 0.7 | 很严格,很多问题会返回"未找到相关内容" |
实际业务中根据文档类型和用户反馈调整。
2.9 System Prompt 的重要性
RAG 的效果很大程度取决于 System Prompt:
// 好的 RAG System Prompt
const RAG_SYSTEM = `你是知识库助手。
规则:
1. 只根据下方参考文档回答,不使用文档之外的知识
2. 如果文档中没有相关内容,明确说"知识库中未找到相关内容",不要编造
3. 回答要准确、简洁
4. 在回答末尾用【来源:文档名】标注使用了哪些文档`
// 不好的 System Prompt(没有约束)
const BAD_SYSTEM = '你是一个助手,根据参考文档回答。'
// 模型可能会在文档内容之外加入自己的"知识",导致回答不可靠
关键约束
- 明确说只用文档内容,不用外部知识
- 如果没找到要明确说,不要编造(这是 RAG 最常见的问题)
- 要求标注来源,增加可信度
2.10 本章作业
✅****基础功能
- 能上传 .txt 文件并看到"入库成功"提示
- 文档列表正常展示(标题、片段数、分类)
- 基于上传的文档提问,能得到回答
- 回答上方能看到参考文档来源
✅****进阶功能
- 上传时能看到进度条
- 展开来源时能看到原始片段内容和相似度分数
- 分类筛选:只在"HR制度"里提问,不检索其他分类
- 对文档内没有的内容提问,AI 正确回复"未找到相关内容"
✅****测试场景
- 上传一份包含假期制度的文档,提问:年假怎么计算?
- 提问一个文档里完全没有的问题(如:明天天气怎么样),验证 AI 不会胡编
- 上传多个不同分类的文档,测试分类筛选是否生效
2.11 常见问题
Q:提示"未配置 OPENAI_API_KEY,无法使用 RAG 功能"?
RAG 的 Embedding 步骤需要 OpenAI 的 API Key。 在 server/.env 里加上 OPENAI_API_KEY=sk-xxx。
如果想用免费方案,可以用本地 Ollama 的 Embedding:
ollama pull nomic-embed-text
然后把 model.js 里的 OpenAIEmbeddings 换成 OllamaEmbeddings。
Q:提示"连接 Chroma 失败"?
确认 Chroma 已启动:
docker run -d -p 8000:8000 chromadb/chroma
# 验证:curl http://localhost:8000/api/v1/heartbeat
Q:PDF 上传后内容为空?
需要安装 pdf-parse:
cd server && npm install pdf-parse
Q:提问结果不准确,搜到了不相关的文档?
可以适当调高相似度阈值:
// query.js
const SIMILARITY_THRESHOLD = 0.4 // 从 0.3 调高到 0.4
Q:向量化很慢?
OpenAI Embedding API 的延迟通常在 1-3 秒/次。 对于大文档(几百个分片),可以:
- 增大
chunkSize到 800,减少片段数 - 用批量 API(
embedDocuments比循环embedQuery快) - 本地 Embedding 模型(无网络延迟)