RAG 文档问答实战
副标题:文档切分、Embedding、向量检索与引用来源
目标:让你能做出“文档问答/企业知识库”这种最常见、最能落地的 AI 应用,并且做到 可追溯引用来源。
目录
- RAG 解决的是什么问题
- 端到端流程:从文档到可引用回答
- 文档切分(chunk):决定命中率的第一步
- Embedding 与向量库:怎么选、怎么用
- 检索策略:TopK、重排与去重
- 把证据塞进 Prompt:让模型“基于资料回答”
- 引用来源:让用户能验证答案
- 评估与迭代:让 RAG 变得越来越准
- 最小可运行 Demo(RAG 版)
- 完整可运行代码(RAG Mock Demo)
RAG 解决的是什么问题
只靠模型本身来回答“公司内部制度/产品文档/操作手册”,很容易:
- 答得像对,但细节错(幻觉)
- 答案无法追溯来源(用户不信)
- 新文档更新后,模型知识跟不上
RAG 的核心价值:
- 用检索把“最新/私有知识”注入上下文
- 用引用让答案可验证
端到端流程:从文档到可引用回答
一个可落地的最小流程:
- 文档接入:PDF/Markdown/网页
- 解析与清洗:抽正文、去噪(页眉页脚/目录)
- 切分 chunk:为每个 chunk 生成
chunkId+ 元数据(docId、页码、标题层级) - Embedding:把 chunk 转成向量
- 入库:向量 + 元数据写入向量库
- 检索:用户问题 embedding → TopK chunk
- 生成:把 TopK chunk 作为“证据”放进 Prompt → 模型回答
- 引用:回答中标注 chunkId/docId,并在 UI 可展开查看证据
图示:RAG 最小可行链路(Mermaid)
你必须准备的“数据结构”
RAG 要稳定,靠的是 数据结构稳定。最小建议结构:
type ChunkMeta = {
docId: string;
docTitle: string;
chunkId: string;
sectionHeading?: string;
pageStart?: number;
pageEnd?: number;
sourceUrl?: string;
};
type ChunkRecord = {
text: string; // chunk 内容
embedding: number[];// 向量
meta: ChunkMeta; // 元数据
};
重点:
chunkId + docId + pageStart/pageEnd让“引用与回溯”变成可实现的能力,而不是口号。
文档切分(chunk):决定命中率的第一步
切分没有银弹,但有“坑位总结”:
切分目标
- 每个 chunk 要 信息密度足够(能独立理解)
- 每个 chunk 要 长度适中(太长塞不进上下文,太短语义不完整)
- chunk 要能关联到 可定位的来源(页码/标题/段落)
常见切分策略
- 按标题层级切分:适合 Markdown/结构化文档
- 按固定长度切分 + overlap:适合纯文本/PDF
- overlap(重叠)能减少“答案跨边界”导致的漏检
示例:按固定长度 + overlap 切分(伪代码)
function splitWithOverlap(text: string, chunkSize = 800, overlap = 120) {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push(text.slice(start, end));
start = end - overlap;
if (start < 0) start = 0;
}
return chunks;
}
示例:按标题层级切分(更适合 Markdown)
当文档是 Markdown 时,优先按标题切分,避免“语义断裂”:
## 安装
...内容...
## 配置
...内容...
实战建议:标题切分优先,标题太大再做二次“长度切分 + overlap”。
必做元数据(否则引用会很痛苦)
建议你至少存:
- docId、docTitle
- chunkId
- sectionHeading(可选)
- pageStart/pageEnd(PDF)
- sourceUrl/relativePath(可选)
元数据写入示例(入库时一起存)
{
"text": "......chunk内容......",
"embedding": [0.12, -0.03, 0.88, "..."],
"meta": {
"docId": "handbook-2025",
"docTitle": "员工手册",
"chunkId": "handbook-2025-00042",
"sectionHeading": "请假流程",
"pageStart": 12,
"pageEnd": 13,
"sourceUrl": "/docs/handbook.pdf"
}
}
Embedding 与向量库:怎么选、怎么用
Embedding 是什么
Embedding 把文本变成向量,使“语义相近”的内容在向量空间里更近,从而支持语义检索。
示例:Embedding 流程(伪代码)
async function embedText(texts: string[]): Promise<number[][]> {
// 伪代码:调用 Embedding API
const res = await fetch("/embedding-api", {
method: "POST",
body: JSON.stringify({ input: texts }),
});
const data = await res.json();
return data.vectors;
}
向量库怎么选
你做作品集/中小项目时的优先级:
- 本地/轻量:FAISS(适合快速验证)
- 服务化:Milvus / Pinecone(适合更像生产的部署)
选择原则:
- 先能跑通流程,再谈性能/规模
- 生产前再做“多租户、冷热分层、权限隔离”等复杂设计
向量库字段建议(最小字段集)
id:chunkIdvector:embeddingtext:原文 chunkmeta:docId/docTitle/page/section
关键:向量库一定要存原文,否则你没法做“引用回显”。
检索策略:TopK、重排与去重
最小检索策略就能跑通,但想“更准”,通常靠三件事:
- TopK:不要太小(漏),也不要太大(噪音)
- 重排(Rerank):用更强的模型/算法对候选 chunk 重新排序(提升相关性)
- 去重/合并:相邻 chunk 可合并成更完整证据,减少碎片化
一个很实用的工程经验:
- 先检索 TopK=10
- 再重排取 TopN=3-5
- 最终塞进 Prompt 的证据控制在“可读且不爆上下文”的范围
检索 + 重排(伪代码骨架)
async function retrieve(query: string) {
const qVec = await embedText([query]);
const topK = await vectorDB.search(qVec[0], { topK: 10 });
// 简化的重排:用更强的模型或相似度再排序
const reranked = rerank(query, topK); // 返回排序后的数组
return reranked.slice(0, 5);
}
去重/合并的实际做法
- 相邻 chunk 合并:chunkId 相邻时拼接成更完整证据
- 标题去重:同一标题下重复段落只保留 1-2 个
实战建议:TopK 先大后小,先“召回”,再“过滤”。
把证据塞进 Prompt:让模型“基于资料回答”
关键不在“把 chunk 放进去”,而在“要求模型按证据回答”:
- 明确规则:只能基于提供的证据回答
- 不足就说不知道:不要猜
- 输出要求:回答 + 引用编号
你可以用这种证据结构(利于引用):
- [S1] doc=xxx chunk=yyy 内容...
- [S2] doc=xxx chunk=zzz 内容...
然后要求模型:
- 每个关键结论后标注 [S1]/[S2]
可直接复用的 RAG Prompt 模板(强约束版本)
你是严谨的知识库助手。你只能基于【证据】回答问题。
规则:
1) 证据不足时,明确回答“根据当前证据无法确定”,并列出需要补充的信息。
2) 严禁编造引用/链接/编号/数据。
3) 每个关键结论后必须标注引用,如 [S1] [S2]。
输出格式:
- 结论(带引用)
- 依据(引用列表)
- 不确定点与需要补充的问题
【证据】
[S1] doc=... page=... title=...
内容:...
[S2] doc=... page=... title=...
内容:...
引用来源:让用户能验证答案
UI 上建议提供两层:
- 轻量引用:回答末尾列出引用列表(文档名 + 页码/章节)
- 可展开证据:点开能看到 chunk 原文,并能跳到原文位置
这样做的好处:
- 用户信任上升
- 你能把“模型胡说”迅速暴露并修正(文档/切分/检索都可能有问题)
UI 建议:引用与证据的“最小实现”
前端可用一个“引用面板”展示:
- 引用列表:[S1] 员工手册 · P12-13 · 请假流程
- 点击展开:显示 chunk 原文(支持复制/跳转)
这一步是 RAG 和“普通聊天机器人”的分水岭。
评估与迭代:让 RAG 变得越来越准
最小评估体系(建议你在作品集里写出来):
- 问题集:覆盖常见问法 + 边界问法(至少 30-50 条)
- 标注答案/证据:每个问题至少有 1 个正确证据来源
- 指标
- 检索命中率(正确证据是否在 TopK)
- 引用准确率(引用是否真的支持结论)
- 失败类型分布(没命中/命中但噪音/生成偏题)
评估用例集模板(可直接复制)
[
{
"question": "年假如何计算?",
"gold_sources": ["handbook-2025-00042"],
"notes": "答案应引用员工手册P12-13"
},
{
"question": "试用期可以请事假吗?",
"gold_sources": ["handbook-2025-00012"]
}
]
指标表(你可以直接放进作品集)
| 指标 | 定义 | 目标 |
|---|---|---|
| TopK 命中率 | 正确证据是否在 TopK | > 80% |
| 引用准确率 | 引用是否支撑结论 | > 90% |
| 无证据拒答率 | 无证据时是否拒答 | > 95% |
迭代顺序(最省时间):
- 先修切分与元数据(最常见问题根源)
- 再修检索策略(TopK/重排/去重)
- 最后修 Prompt(约束引用与拒答策略)
最小可运行 Demo(RAG 版)
目标:跑通“切分 → Embedding → 入库 → 检索 → 引用回答”的最小闭环。
最小可运行代码(伪代码骨架)
// 伪代码:RAG 最小闭环
const chunks = splitWithOverlap(docText, 800, 120);
const vectors = await embedText(chunks);
await vectorDB.upsert(chunks.map((text, i) => ({
id: `doc-001-${i}`,
vector: vectors[i],
text,
meta: { docId: "doc-001", docTitle: "员工手册" },
})));
const topK = await vectorDB.search(await embedText([question]).then(v => v[0]), { topK: 10 });
const evidence = topK.slice(0, 5).map((x, idx) => `[S${idx + 1}] ${x.text}`).join("\n");
const prompt = `请基于证据回答:\n${evidence}`;
完整 Demo 结构(最小 RAG 骨架)
demo-rag/
ingest/
split.ts
embed.ts
server/
rag-search.ts
rag-answer.ts
web/
App.tsx
常见坑排查清单
- 检索不准:chunk 过大/过小 → 调整 chunkSize + overlap
- 引用缺失:Prompt 未强约束 → 增加引用规则
- 回答跑偏:TopK 太大 → 先召回再重排
完整可运行代码(RAG Mock Demo)
源码目录:
docs/demos/rag-demo
cd docs/demos/rag-demo/server
npm i
npm run dev