跳到主要内容

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;
}

Embedding 模型选型(2026 视角)

记“选型逻辑”而非死记型号:

  • 闭源 API:OpenAI text-embedding-3(large/small)、Cohere embed v3、Voyage、Google Gemini embedding 等,接入快、质量稳。
  • 开源可自托管bge-m3(多语言、支持稠密+稀疏+多向量)、gtee5 系列、jina-embeddings 等,数据不出域。
  • 选型要点:① 是否支持中文/多语言;② 向量维度(影响存储与检索成本);③ 是否支持长文本指令化(query/passage 分开编码);④ 换 embedding 模型要重建全部向量,所以别频繁换。

面试加分:embedding 模型和生成模型是两套独立选择——很多人只盯生成模型,忽略了“检索质量上限其实由 embedding + 重排决定”。

向量库怎么选

你做作品集/中小项目时的优先级:

  • 本地/轻量:FAISS、Chroma、sqlite-vecpgvector(直接用 Postgres,工程最省心)
  • 服务化:Milvus / Qdrant / Weaviate / Pinecone(适合更像生产、要规模与多租户)

选择原则:

  • 先能跑通流程,再谈性能/规模;中小项目 pgvector / Qdrant 通常够用且好运维
  • 生产前再做“多租户、冷热分层、权限隔离、混合检索”等复杂设计

向量库字段建议(最小字段集)

  • 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 先大后小,先“召回”,再“过滤”

进阶 RAG:从“朴素 RAG”到“能上线的 RAG”(2026 必会)

上面是 Naive RAG(朴素 RAG):切分 → 向量检索 TopK → 塞进 Prompt。它能跑通 Demo,但生产里召回率/准确率经常不够。下面是当前主流的增强手段,面试被问“怎么提升 RAG 效果”时按这个层次答

1)混合检索(Hybrid Search):向量 + 关键词

纯向量检索擅长“语义相近”,但对专有名词、编号、缩写、代码符号经常漏召回。解法是同时跑两路再融合

  • 稀疏检索:BM25 / 关键词(擅长精确匹配,如“错误码 E1024”“GAAP”)
  • 稠密检索:向量(擅长语义,如“怎么报销” ≈ “费用申请流程”)
  • 融合:用 RRF(Reciprocal Rank Fusion,倒数排名融合) 把两路结果合并排序。

一句话:Hybrid Search 几乎是“免费的召回率提升”,生产 RAG 基本默认开启。

2)重排模型(Reranker):召回之后的精筛

向量检索是“双塔”——query 和文档分开编码,速度快但精度有限。重排模型(Cross-Encoder,如 Cohere Rerank、bge-reranker、Jina Reranker)把 query 和每个候选拼在一起打分,精度高但慢,所以用法是:

  • 先用向量/混合检索召回 TopK=20~50(要快、要全)
  • 再用 Reranker 精排取 TopN=3~5(要准)塞进 Prompt

这就是“先召回后精排”的两阶段检索,是性价比最高的提效手段之一。

3)上下文检索(Contextual Retrieval)

Anthropic 提出的实用技巧:入库前,给每个 chunk 用 LLM 补一段“它在全文里的上下文说明”再做 embedding。例如一个 chunk 是“收入增长了 3%”,补上“本段出自 2024 年 Q2 财报,讨论的是 ACME 公司营收”后,检索命中率显著提升。常与 Hybrid Search + Rerank 叠加使用。

4)查询改写与多查询(Query Rewriting / Multi-Query)

用户问法往往口语、模糊。在检索前用一次(便宜的)LLM 调用:

  • 改写:把口语问题改成更适合检索的表述
  • 多查询:生成 2~3 个不同角度的子查询分别检索再合并(提升召回)
  • HyDE:先让模型“假设一个答案”,用假设答案去检索(有时比原问题更好召回)

5)GraphRAG(知识图谱增强)

当问题需要跨多文档、多实体关系的全局理解(如“X 和 Y 的关系对项目 Z 有什么影响”),朴素 RAG 的“TopK 片段”会断片。GraphRAG先把语料抽取成实体-关系知识图谱,检索时沿图聚合相关实体与社区摘要,更适合全局性、关系型问题。代价是构建成本高。

6)Agentic RAG(智能体式检索)

把检索从“一次性 TopK”升级为模型自主决策的多步过程:模型可以判断要不要检索、检索什么、检索几次、是否需要换库或追问,检索变成 Agent 的一个工具调用(见 Agent 工作流与 MCP 落地)。适合复杂、需要多轮取证的问题,代价是更慢更贵。

进阶链路总览(Mermaid)

面试怎么答“RAG 不准怎么优化”

按“便宜→贵”的顺序排查(也是回答顺序):

  1. 数据层:切分策略 + 元数据(最常见根因)、上下文检索
  2. 检索层:开 Hybrid Search → 加 Reranker → 调 TopK/TopN
  3. 查询层:查询改写 / 多查询 / HyDE
  4. 生成层:强约束引用与拒答
  5. 架构层:仍不够再上 GraphRAG / Agentic RAG(成本高,最后考虑)

把证据塞进 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