Skip to content
RAG 系统架构设计:从文档解析到增强生成完整流程
1. 概述:RAG 的定位与完整流程
RAG(Retrieval-Augmented Generation,检索增强生成)将信息检索与大语言模型生成组合起来。核心思路是在生成阶段之前增加一个检索步骤:从外部知识库中找到与用户问题相关的文档片段,将它们作为上下文交给 LLM。这样,语言模型不需要把私有知识全部存入参数,而是在回答时直接参考证据。
适合 RAG 的典型场景包括企业内部知识库问答、产品文档助手、客服辅助、合规审查等。它更适合解决“模型不知道、但资料库里有”的问题,而不是改变模型本身的行为方式。
一个完整 RAG 流程如下所示:
text
[文档] → [解析] → [清洗] → [分块] → [Embedding] → [索引]
↓
[用户查询] → [查询改写] → [混合检索] → [Rerank] → [上下文组装] → [LLM] → [答案]上述两条链路分别称为索引链路和查询链路。若按模块划分,朴素 RAG 将文档解析、向量检索和生成串联成一个固定管道;高级 RAG 在管道中加入查询改写、混合检索、Rerank、引用标注和评估闭环等机制。
2. 文档接入与解析:格式识别、OCR 与元数据提取
文档接入是 RAG 的入口。真实世界的文档形态包括纯文本、PDF、Word、HTML、Markdown、扫描件和表格等。解析的目标不是把文件读出来,而是把物理文件转换为结构化的文本块并保存元数据。
2.1 格式识别
文档解析的第一步是确定文件格式。工程中通常联合使用 MIME 类型、扩展名和内容探测三种方式来判断。MIME 类型由 HTTP 响应或文件系统提供;扩展名是常见提示,但可能被伪造或缺失;内容探测根据文件头部的魔数判断真实格式,可靠性更高。
以下示意代码使用 Node.js 生态中的 file-type 库,通过读取文件头部识别真实格式:
typescript
import { fileTypeFromFile } from 'file-type';
const type = await fileTypeFromFile('report.bin');
console.log(type?.mime); // 'application/pdf'代码执行后得到文件的 MIME 类型。这里的核心动作是“探测”:无论扩展名是 .bin 还是 .pdf,内容探测都依据文件头部的实际字节判断,因此可以避免扩展名伪造或缺失带来的问题。
2.2 解析器的选型
不同格式的文档需要使用不同的解析器。从公开的选型资料中可以归纳出以下方向[1][3]:
- 纯文本 PDF:pdfplumber 等轻量工具即可完成文本提取。Elasticsearch Labs 的表格解析实践也基于该工具[3]。
- 复杂 PDF(多栏、表格、图文混排):MinerU 对复杂版面支持较好,Marker 可将 PDF 转为 Markdown,适合学术与技术文档[1]。
- 混合格式文档:Unstructured 提供统一 API,覆盖 PDF、Word、Excel、HTML 等多种格式,并提供结构化输出[1]。
- 扫描件:先做 OCR,再进入文本解析流程。
解析器在“格式覆盖度”和“结构保留度”之间存在权衡。只支持文本提取的库体积小、依赖少,但对表格、多栏、注释的处理能力有限;能够输出 Markdown 或 JSON 结构的库更适合作为 RAG 链路的入口。
2.3 表格与图片处理
表格是 RAG 文档解析中的难点。如果直接把表格转成 CSV 或 JSON,检索时很难把上下文关联起来。Elasticsearch Labs 的实践采用“文本提取 + LLM 转换”的方式:先用 pdfplumber 提取文本和表格,再用 LLM 将清洗后的表格转换为人类可读的文本描述[3]。转换之后,表格内容保持可读,同时可以被向量检索索引。
typescript
// 示意:将表格结构转为文本描述
const tableMarkdown = `
| 产品 | 价格 |
|------|------|
| A | 100 |
`;
const description = await llm.call(
`将下面的表格转成一段自然语言描述,保留全部数据:\n${tableMarkdown}`
);把表格转成自然语言描述,目的是让后续的嵌入模型能够理解表格的语境。图片中的文字属于另一类处理对象,一般先经过 OCR 转为文本,再进入分块流程。
2.4 元数据提取与清洗
解析过程中应该同步提取元数据,例如文件标题、作者、创建时间、章节路径、页码、来源 URL 等。这些元数据会在检索阶段用于过滤,也会在生成阶段用于引用标注。当一个块被检索到时,可以通过它的 source、page 字段定位到原文。
文档解析流水线的标准环节包括:解析、文本清洗、分块、向量化、索引[2]。文本清洗包括去除页眉页脚、修复断行、去除无关字符等。清洗的输出质量直接决定分块和向量化的质量。
3. 分块策略:固定窗口、结构感知与语义切块
分块(Chunking)决定了一个文档被切分成多少个索引单元。这个设计决策会同时影响召回率、上下文窗口和生成质量[4]。
3.1 固定窗口分块
固定窗口是最简单的分块策略:按字符数或 token 数切割文本,再设置一个重叠区间来保持相邻块的连续性[4]。以下为按字符数切分并保留重叠区间的示意实现:
typescript
function fixedSizeChunk(text: string, size: number, overlap: number) {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
chunks.push(text.slice(start, start + size));
start += size - overlap;
}
return chunks;
}size 需要结合模型上下文窗口和文档长度来设置。块太小,单个块缺少上下文;块太大,向量化后会稀释主题,且可能超过模型的上下文限制[4]。重叠的本质是让处于边界的信息不至于被切断。
3.2 结构感知分块
如果文档本身具有明确的结构(标题、章节、表格),可以按结构边界切分。LangChain 为此提供了 MarkdownHeaderTextSplitter、HTMLHeaderTextSplitter、RecursiveCharacterTextSplitter 等工具[4]。结构感知切分能避免把一个段落拆散,适合法律文书、技术手册、API 文档等层级化、引用密集的文档[5]。
递归字符切分器是最常用的入口:它按照一组分隔符(如 \n\n、\n、。、空格)依次尝试切分,直到块大小满足限定条件。基本用法如下:
typescript
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
const chunks = await splitter.createDocuments([markdownText]);注意,chunkSize 默认按字符数计算。如果使用 token 计数,需要显式传入 tokenizer,因为 token 与字符的比例在不同语言中差异很大[4]。
3.3 语义切块
语义切块不是按照长度或结构边界,而是根据文本的语义相似度来切分。它先按句子切分,再通过嵌入判断相邻句子的相似度,在相似度明显下降的位置断开。LangChain 将其放在 langchain-experimental 中,说明其仍处于实验阶段[4]。
语义切块的优点是能产生更符合语义边界的块,缺点是计算成本高,且对嵌入模型的质量敏感。
3.4 父子文档检索
另一种比单纯增大块更灵活的方式是父子文档检索,也称为 small-to-big[4]。具体做法是:为同一个文档生成两类块,小块用于向量检索,大块用于交给 LLM 生成。
typescript
const smallChunks = await splitterSmall.splitText(article);
const parentChunks = await splitterLarge.splitText(article);
// 检索时命中 smallChunk,然后将其映射到 parentChunk 作为上下文这种模式缓解了“小块语义准确但不完整,大块信息完整但主题模糊”的矛盾。
4. 向量化与索引构建:Embedding 与混合索引
分块之后,文本需要变为可检索的表示。RAG 的索引层通常同时包含稠密向量索引和稀疏倒排索引。
4.1 Embedding
嵌入(Embedding)将文本映射为一个稠密向量。语义相近的文本,其向量在高维空间中的距离也相近。嵌入模型通常以句子或文档为单位进行编码,因此分块的大小会直接影响向量质量。
typescript
import { OpenAIEmbeddings } from '@langchain/openai';
// 示意:使用嵌入模型将文本转为向量
const embeddings = new OpenAIEmbeddings({ model: 'text-embedding-3-small' });
const vectors = await embeddings.embedDocuments(['文档块内容']);Embedding 模型的选择需要考虑维度、上下文长度、多语言能力以及成本。不同的模型返回的向量维度不同,选择后一般不会随意更换,因为向量索引是基于固定维度构建的。
4.2 向量索引
向量索引的任务是存储向量并支持近似最近邻搜索。常见的索引结构包括 HNSW、IVF 等。HNSW 在召回率和查询延迟之间平衡较好,是许多向量数据库的默认索引。
向量数据库(如 Milvus、Qdrant、Weaviate)和向量插件(如 pgvector、Elasticsearch dense_vector)都提供了这类索引。搜索时使用 queryEmbedding 作为输入,返回前 K 个最相似的块。
typescript
// Milvus 检索示意
await collection.search({
vector: queryEmbedding,
limit: 10,
});4.3 倒排索引与 BM25
倒排索引是传统全文检索的核心。它将词条映射到包含它的文档列表,配合 BM25 算法计算相关性。BM25 对词频、文档长度、逆文档频率进行加权,特别擅长处理“专有名词、精确短语、ID 编号”这类稀疏特征。
RAG 需要稀疏检索,因为稠密向量对精确匹配并不敏感。如果一个型号名称是 RAG-2024-X9,嵌入模型可能把它编码成语义向量后,和 RAG-2024-X8 距离很远,但用户要查的就是精确型号。BM25 可以解决这类问题。
需要说明的是,嵌入模型对精确匹配不敏感的原因在于向量空间按语义组织,不按字符组织;专有名词之间的字符差异在语义编码中可能被忽略。
4.4 混合检索与 RRF
混合检索(Hybrid Search)同时执行稠密向量检索和稀疏检索,再合并两路结果。合并方式之一是 RRF(Reciprocal Rank Fusion):对每个文档在多路结果中的排名取倒数,求和后得到融合分数。
typescript
function rrf(...rankings: string[][]): Map<string, number> {
const scores = new Map<string, number>();
rankings.forEach((ranking) => {
ranking.forEach((docId, i) => {
const k = 60; // RRF 常数
const score = 1 / (k + i + 1);
scores.set(docId, (scores.get(docId) ?? 0) + score);
});
});
return scores;
}RRF 的优点是无需对两路分数做归一化,因为它只依赖排名信息。向量数据库原生混合检索与 RRF 的差异在于:原生混合检索可以在一个查询中同时执行两路检索并返回融合结果,而不需要应用层自己合并。
4.5 元数据过滤
元数据过滤可以在检索之前缩小候选集,从而提高精度。常见的过滤条件包括文档类型、时间范围、部门、标签等。向量数据库和全文检索引擎都支持这类过滤。
typescript
await collection.search({
vector: queryEmbedding,
filter: `release_date >= '2024-01-01' && department = 'platform'`,
limit: 10,
});注意过滤的执行位置:有些引擎先执行 ANN 搜索再过滤,有些则在索引层过滤后再搜索。这两者在检索结果上可能不同,需要理解所使用引擎的实际语义。
5. 查询侧优化:查询改写、HyDE 与多路召回
查询侧优化的目标是在进入向量检索之前,把用户的原始问题转化为更有利于检索的形式。
5.1 查询改写
用户输入的查询往往包含指代、口语化表达或隐含意图。查询改写使用 LLM 将原始问题展开为多个可检索的子问题或关键词组合。
typescript
const rewritten = await llm.call(`
请将用户的问题改写为 3 个适合检索的独立问题,直接输出编号列表:
用户问题:帮我看看第二季度的API服务可用性报告里,有哪些和网关相关的故障?
`);
// 输出示例:
// 1. 第二季度 API 服务可用性报告
// 2. 网关故障统计
// 3. 第二季度 API 服务故障原因分析改写后的多个查询可以分别执行检索,再合并结果。它和“多路召回”有很多交叉。
5.2 HyDE
HyDE(Hypothetical Document Embeddings)的核心思想是:先用 LLM 根据查询生成一个假想的答案文档,然后对这个文档做嵌入,再用它去检索。假设“答案”与真实文档在语义上比“问题”更接近,因此生成的嵌入能提升召回率。
typescript
const hypothetical = await llm.call(question);
const vector = await embeddings.embedQuery(hypothetical);HyDE 需要一次额外的 LLM 调用,延迟和成本会上升。它适合查询与文档表达方式差异较大的场景。
5.3 多路召回
多路召回指在同一查询中组合多种检索方式:向量检索、BM25、知识图谱检索、SQL 查询等。每路召回的结果合并后进入 Rerank 阶段。这是一种提高召回率的架构策略,但它不解决排序问题,精确排序需要交给 Rerank。
6. Rerank 与上下文组装
6.1 Rerank
双塔式检索模型在向量召回阶段牺牲了一些精度,因为它需要把文本压缩成单个向量。Rerank 模型(交叉编码器)将 query 和候选文档同时送入编码器,得到更精确的相关性分数。Rerank 可以看作是在召回阶段之后的精排阶段。
typescript
// 伪代码:Rerank 的输入输出
const reranked = await rerankModel.rerank({
query: question,
documents: candidates.map((c) => c.text),
});
// 返回按相关性降序排列的文档列表和得分Rerank 的计算成本高于向量检索,因为每个候选文档都要和 query 做一次完整编码。因此,通常先召回几十个候选,再 Rerank 取前几。候选数量是一个权衡:太大会增加延迟,太小会漏掉正确答案。
6.2 上下文组装
上下文组装的目标是从 Rerank 结果中选出最终的上下文块,并保证整体长度不超过模型上下文窗口。组装时需考虑以下问题:
- 相关性:优先选择 Rerank 得分高的块。
- 去重:多个块可能包含重复内容,需要按内容哈希或相似度去重。
- 截断:块本身可能很长,需要按 token 数截断。
- 顺序:一般按文档原始阅读顺序排列,而不是按相关性排序。因为 LLM 生成时需要依赖连贯的上下文。
typescript
interface RankedDoc {
text: string;
order: number;
}
function buildContext(rankedDocs: RankedDoc[], maxTokens: number): string {
const selected: string[] = [];
let totalTokens = 0;
for (const doc of rankedDocs) {
const t = countTokens(doc.text);
if (totalTokens + t > maxTokens) {
continue;
}
selected.push(doc.text);
totalTokens += t;
}
return selected.sort((a, b) => a.order - b.order).join('\n\n');
}这里先按相关性和 token 预算筛选块,再按原始阅读顺序排序。这样既保证了上下文内容相关,又保持了逻辑连贯性。
7. Prompt 构造与增强生成
检索完成之后,系统需要把上下文和用户问题组合为 Prompt。一个标准的 RAG Prompt 包含以下部分:
- 系统指令:说明模型如何根据上下文回答。
- 上下文:从检索结果拼接的文本块。
- 问题:用户原始问题。
- 输出约束:例如要求引用来源、无法回答时明确说明。
typescript
interface RetrievedChunk {
text: string;
metadata: { source: string };
}
function buildRagPrompt(question: string, chunks: RetrievedChunk[]) {
const context = chunks
.map((chunk, i) => `[${i + 1}] ${chunk.text}\n来源:${chunk.metadata.source}`)
.join('\n\n');
return `你是知识库问答助手。请只根据提供的上下文回答用户问题,不能使用外部知识。如果上下文中没有足够信息,请直接回答“根据提供的信息无法回答”。
上下文:
${context}
请基于上文回答问题。答案中如涉及具体信息,请在句末标注引用编号,例如 [1]。
问题:${question}`;
}Prompt 构造的通用原则是将检索结果与问题之间的边界标识清楚。上下文如果太松散,模型可能忽略检索结果而去依赖自身知识。
8. 答案忠实性与引用标注
8.1 忠实性
忠实性(Faithfulness)指答案能否由检索到的上下文推导出来。RAG 的一个常见问题是模型把参数记忆中的内容混入答案。为了控制这一点,可以在 Prompt 层面对模型施加约束,也可以在系统层面通过评估集检测。
常用的检查方式包括:逐句匹配答案中的断言与上下文中的原始语句;如果某个断言在上下文中找不到支撑,则判定为不忠实。
8.2 引用标注
引用标注是把答案中的每个关键结论映射到原始文档位置。实现思路是:在上下文组装时保留每个块来源 ID;生成完成后,让 LLM 在答案中输出引用编号,再由系统把编号映射为文档链接。
typescript
// 上下文中携带来源
const context = chunks
.map((c, i) => `[${i + 1}] 来源:${c.source}, 页码 ${c.page}\n${c.text}`)
.join('\n\n');引用标注的意义在于可验证性。使用者可以根据引用定位到原始段落,判断答案是否正确。
9. 系统架构与工程实践
在原型阶段,RAG 可以是一个脚本;在系统化部署时,需要按模块拆分职责。
9.1 模块划分
一个 RAG 系统通常包含以下模块:
- 数据接入层:接入新的文档,触发解析流程。
- 文档处理流水线:解析、清洗、分块、向量化、写入索引。
- 索引存储层:向量数据库和全文检索引擎。
- 检索服务层:提供检索 API,执行查询改写、混合检索、Rerank。
- 生成服务层:构造 Prompt,调用 LLM,返回答案和引用。
- 评估模块:对检索和生成质量进行离线评估。
9.2 数据流
数据流分为两条链路:
- 离线/异步链路:文档入库 → 解析 → 分块 → 向量化 → 写入索引。
- 在线链路:用户查询 → 查询改写 → 多路召回 → Rerank → 上下文组装 → LLM 生成 → 返回。
离线链路通常使用异步任务队列(如 BullMQ、Celery)处理,避免同步等待长耗时解析任务。
typescript
// BullMQ 异步处理示例(示意)
import { Queue, Worker } from 'bullmq';
const ingestQueue = new Queue('doc-ingest');
const worker = new Worker('doc-ingest', async (job) => {
const { filePath } = job.data;
const doc = await parse(filePath);
const chunks = await split(doc);
await embedAndIndex(chunks);
});离线链路和在线链路之间通过索引存储层解耦。新文档完成索引后,在线检索立即可见。如果对一致性有更高要求,可以使用文档版本号或发布标记控制索引数据是否可被检索。
9.3 检索服务与生成服务的 API 设计
检索服务通常对外提供 POST /v1/retrieve,请求参数包括 query、top_k、filter,响应包含文档块列表、来源信息和得分。生成服务通常提供 POST /v1/generate,请求参数包括 question、history、context,响应包含答案和引用列表。
两个服务独立部署的好处是:检索服务可以被多种上层应用复用,生成服务可以独立升级模型或调整 Prompt 策略。
9.4 缓存与并发
缓存可以减少重复计算。常见缓存点包括:
- 查询改写结果:相同的 query 无需重复调用 LLM。
- 向量检索结果:短时间内的相同检索可直接返回。
- 生成结果:对于高频问题,可以直接缓存最终的答案。
并发方面,检索服务需要控制 embedding 和 rerank 的并发量,因为这两类模型都有吞吐上限。可以使用限流中间件保护下游模型服务。
9.5 可观测性与延迟预算
RAG 系统需要能够观测到每个环节的延迟和效果。应记录以下内容:
- 解析阶段的文件类型、解析耗时、提取文本长度。
- 索引阶段的文档 ID、分块数量。
- 检索阶段的召回数量、召回来源、Rerank 分数分布。
- 生成阶段的 prompt token、输出 token、生成延迟。
这些数据可以通过结构化日志和追踪系统(如 OpenTelemetry)记录。链路追踪对定位“召回为空”或“答案不忠实”这类问题至关重要。
在线链路的延迟预算需要分配给多个环节:查询改写(如果启用)、向量检索、Rerank、LLM 生成。LLM 生成通常是最大延迟来源,其次是 Rerank。设计时应该明确每个环节的延迟上限,并为每步调用设置超时。
成本方面,主要消耗集中在三处:文档解析中的 OCR/Layout 分析、Embedding 调用、LLM 生成。高频查询场景尤其需要对生成结果做缓存,否则 token 成本会随调用量线性增长。
10. RAG 评估与回归测试
RAG 系统的质量依赖多个模块的协同,不存在单一的准确率指标。因此,评估需要覆盖两个层面:检索质量与生成质量。
10.1 评估集构建
评估集是一个包含“问题-答案-支持文档”的集合。构建方式有两种:
- 从真实用户日志中抽样,人工标注正确答案和支持文档。
- 使用 LLM 从已有文档中自动生成问答对,再人工筛选。
第二种方式需要保证问题与文档真实相关,否则评估集本身会有噪声。
10.2 检索指标
检索质量指标:
- Recall@K:前 K 个结果中是否包含正确答案。
- Precision@K:前 K 个结果中有多少是相关的。
- MRR(Mean Reciprocal Rank):正确结果在排序中的位置。
这些指标可以通过对比检索结果与人工标注的相关文档来计算。
10.3 生成质量指标
生成质量指标:
- 忠实性(Faithfulness):答案中的每个断言是否被上下文支持。
- 相关性(Relevance):答案是否回答了用户的问题。
- 答案质量:可读性、完整性、拒绝回答的合理性等。
RAGAS 等评估框架提供了一套自动化的指标计算方法,使用 LLM 对生成结果打分。
10.4 回归测试
索引策略、分块参数、检索算法、Prompt 模板一旦修改,都可能影响最终答案。因此,需要把评估集纳入持续集成流程,在每次变更后运行回归测试,比较指标变化。
typescript
// 伪代码:回归测试
const report = await evaluate(testSet);
if (report.faithfulness < 0.85 || report.recallK < 0.9) {
throw new Error('RAG quality regression detected');
}评估是一个持续的过程。文档库更新、Embedding 模型升级、LLM 版本变化都会引起质量波动。
11. 生态与选型:LangChain、向量数据库与 GraphRAG
11.1 框架与组件
LangChain 和 LlamaIndex 是当前主流的 RAG 开发框架。LangChain 提供了一套标准化的接口,将文档加载、分块、嵌入、检索、生成组合成可配置的流水线。LlamaIndex 更侧重索引结构和数据连接,适合构建以文档知识库为中心的检索界面。
框架降低的是组装成本,而不是质量保证。RAG 的性能仍然由数据质量、分块策略、检索算法和模型选择决定。
11.2 向量数据库生态
向量数据库是 RAG 系统的核心依赖之一。选型时需要考虑:
- 向量维度支持与索引类型。
- 混合检索支持程度。
- 元数据过滤能力。
- 部署成本与运维复杂度。
常见的开源选项包括 Milvus、Qdrant、Weaviate,以及作为 PostgreSQL 扩展的 pgvector。它们对混合检索和过滤的原生支持存在差异。
11.3 GraphRAG
GraphRAG 在向量索引之外引入知识图谱,将实体和关系作为检索单位。它适合答案是跨多个文档推理得到的场景,例如“公司 A 在收购公司 B 之后,和公司 C 形成了什么竞争关系?”这类问题。知识图谱可以保存实体关系,但构建成本也更高,只有在文档关系密集时才值得引入。
11.4 Agentic RAG 与 Tool Calling
将 RAG 嵌入到 Agent 的 Tool Calling 中是另一个趋势。Agent 可以决定何时检索、检索什么,并在多轮对话中维护查询状态。这一方向扩大了 RAG 的能力边界,但也会引入更多的错误传播路径,需要更完善的评估和防呆设计。
12. 应用:从零构建的推进路径
RAG 不是单一组件,而是一条从文档到答案的完整链路。整条链路可以划分为五个环节:
- 文档接入与解析:决定系统能够利用哪些知识。
- 分块:决定知识被索引的基本单位。
- 向量化与索引:决定检索系统能否快速找到相关内容。
- 查询优化与 Rerank:决定检索结果的精度。
- 增强生成:决定最终答案的质量与可追溯性。
从零开始搭建时,可以按以下路径推进:
- 先搭建一个最小流水线,用少量文档跑通解析、分块、索引、检索、生成。
- 加入混合检索和 Rerank,观察召回质量的变化。
- 建立一个小规模评估集,量化每个改动的影响。
- 再根据延迟和成本预算,考虑缓存、异步处理与可观测性。
后续需要进一步关注的方向包括:RAG 评估框架的具体指标计算方式、向量数据库的索引参数调优,以及 GraphRAG 与 Agentic RAG 的系统设计。
