跳到主要内容

RAG 文档问答实战

副标题:文档切分、Embedding、向量检索与引用来源

目标:让你能做出“文档问答/企业知识库”这种最常见、最能落地的 AI 应用,并且做到 可追溯引用来源

目录

RAG 解决的是什么问题

只靠模型本身来回答“公司内部制度/产品文档/操作手册”,很容易:

  • 答得像对,但细节错(幻觉)
  • 答案无法追溯来源(用户不信)
  • 新文档更新后,模型知识跟不上

RAG 的核心价值:

  • 用检索把“最新/私有知识”注入上下文
  • 用引用让答案可验证

端到端流程:从文档到可引用回答

一个可落地的最小流程:

  1. 文档接入:PDF/Markdown/网页
  2. 解析与清洗:抽正文、去噪(页眉页脚/目录)
  3. 切分 chunk:为每个 chunk 生成 chunkId + 元数据(docId、页码、标题层级)
  4. Embedding:把 chunk 转成向量
  5. 入库:向量 + 元数据写入向量库
  6. 检索:用户问题 embedding → TopK chunk
  7. 生成:把 TopK chunk 作为“证据”放进 Prompt → 模型回答
  8. 引用:回答中标注 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:chunkId
  • vector:embedding
  • text:原文 chunk
  • meta: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%

迭代顺序(最省时间):

  1. 先修切分与元数据(最常见问题根源)
  2. 再修检索策略(TopK/重排/去重)
  3. 最后修 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