RAG 从零搭建:多模态加载 → Embedding → 混合检索 → Rerank 全链路

Gabrielle Lv5

RAG 解决什么问题?

LLM 有两个先天局限:知识截止日期,以及幻觉(对不知道的事情也会编出答案)。RAG(Retrieval-Augmented Generation,检索增强生成)是当前最成熟的解决方案——在 LLM 回答问题之前,先从知识库中检索相关文档,把检索结果和问题一起塞进 prompt,让 LLM 基于真实文档回答。

对代码审查 Agent 来说,RAG 的具体场景是:用户问”这个项目的路由设计有什么问题?”——Agent 不应该只分析用户贴出来的那一段代码,而是应该检索项目中的所有路由文件、配置文件、中间件,综合判断。

全链路架构

整个 RAG 子系统分为两个阶段——索引(离线)和查询(在线):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌── 索引阶段(index)──────────────────────────────────────┐
│ │
│ 文档上传 → MultiFormatLoader → RecursiveSplitter │
│ ↓ ↓ │
│ text/pdf/image chunk=500, overlap=50 │
│ │
│ → BGE-M3 Embedding (SiliconFlow API) │
│ ↓ │
│ → MemVectorStore (内存向量存储) │
│ → BM25Retriever (关键词索引,同步构建) │
│ │
└──────────────────────────────────────────────────────────┘

┌── 查询阶段(query)───────────────────────────────────────┐
│ │
│ 用户问题 → BGE-M3 Embedding → 向量检索 (Top 50) │
│ │ ↘ │
│ │ BM25 关键词检索 (Top 50) │
│ │ ↙ │
│ └────────→ RRF 融合 ──→ BGE-Reranker 精排 (Top 5) │
│ ↓ │
│ Prompt 组装 → LLM 生成答案 │
│ │
└──────────────────────────────────────────────────────────┘

核心编排在 rag-pipeline.ts 中,它不自己干活,只负责按顺序串联各模块。所有组件通过 constructor 注入,后续换任何一块(比如向量存储从内存升级到 Pinecone)不改本文件。

第一站:文档加载与切分

多格式文档加载由 MultiFormatLoader 统一入口,内部按文件类型分发:

1
2
3
text → TextParser(保留原始格式 + 行号标注)
pdf → PDFParser(pdf-parse 提取文本层)
image → ImageParser(提取 base64,后续可接多模态模型描述图片内容)

加载后的文档进入 splitDocument() 做递归切分。选择递归切分而非固定长度,原因很明确:代码文件有自然的分隔符。\n\n(函数间空行)→ \n(单行)→ (空格)的优先级依次尝试切分点,不会把函数拦腰截断。语义切分需要额外的 Embedding 模型做句子边界检测,对代码场景收益不大,反而增加延迟。

参数设置:chunkSize = 500overlap = 50。overlap 的作用是让相邻块之间有 50 字符的内容重叠——防止某个关键信息恰好在切分边界上被割裂,导致两个块各留一半、检索时谁也找不全。

第二站:Embedding —— 文本变向量

Embedding 模型我选了 BAAI/bge-m3,通过 SiliconFlow API 调用。BGE-M3 是目前中英文检索场景表现最好的开源模型之一,支持 1024 维向量输出,且 API 兼容 OpenAI 格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// siliconflow-embedding.ts — SiliconFlow BGE-M3 调用
async embedBatch(texts: string[]): Promise<number[][]> {
const res = await fetch(`${this.baseURL}/embeddings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ model: "BAAI/bge-m3", input: texts }),
});
const json = await res.json();
return json.data.map((d: any) => d.embedding);
}

EmbeddingModel 接口抽象了 embed()embedBatch() 两个方法。索引阶段用 embedBatch() 批量转换节省 API 调用,查询阶段用 embed() 单条转换。

为什么用余弦相似度而不是欧氏距离? BGE-M3 输出的向量归一化后,余弦相似度只关注方向而非长度。两个内容相似的文档,向量方向一致但长度可能因文本长度不同而有差异——欧氏距离会被长度误导,余弦不会。

第三站:向量存储

当前向量存储用的是 MemVectorStore——一个内存数组,暴力遍历所有向量计算余弦相似度,再排序取 Top-K:

1
2
3
4
5
6
7
8
9
// mem-vector-store.ts — 内存暴力检索
async query(queryEmbedding: number[], topK: number): Promise<RetrievedChunk[]> {
const scored = this.entries.map((entry) => ({
entry,
similarity: cosineSimilarity(queryEmbedding, entry.embedding),
}));
scored.sort((a, b) => b.similarity - a.similarity);
return scored.slice(0, topK).map((s) => ({ ...s.entry.chunk, similarity: s.similarity }));
}

坦白说这是 O(n) 暴力遍历。生产环境应该换成 Pinecone 或 pgvector。但 VectorStore 接口已经抽象好了——add()query()clear()getChunkById()——换存储只改一个实现类,接口不动。对于代码审查场景(通常几十个文件以内),内存级别完全够用。

第四站:混合检索 —— BM25 + 向量 + RRF 融合

纯向量检索有一个盲区:它能找到语义相似的内容,但不一定能找到精确匹配的关键词。 比如用户问”React.memo 在哪些文件里用了?”,向量检索可能返回一堆关于性能优化的文档,但漏掉了 React.memo 这个精确词出现在 LegacyComponent.tsx 中的那次。

混合检索的方案:向量检索 + BM25 关键词检索,两路并行,结果用 RRF 融合。

BM25:经典但有效的关键词打分

BM25 是信息检索领域的经典算法,核心思想三句话:

  1. 词频(TF):查询词在文档中出现越多次 → 分数越高,但有饱和上限(k1=1.5 防止出现 100 次比出现 10 次分数高 10 倍)
  2. 逆文档频率(IDF):查询词越稀有 → 权重越大(”useEffect” 比 “const” 更有区分度)
  3. 文档长度归一化:长文档天然包含更多词 → 需要打折(b=0.75)
1
2
3
4
// bm25.ts — BM25 核心打分公式
const numerator = tf * (this.k1 + 1);
const denominator = tf + this.k1 * (1 - this.b + this.b * (docLen / this.avgDocLength));
total += idf * (numerator / denominator);

分词逻辑也考虑到了代码场景:

1
2
3
4
5
6
7
8
9
10
11
// bm25.ts — 中英混合分词
function tokenize(text: string): string[] {
const tokens: string[] = [];
const regex = /[a-z0-9]+|[一-鿿]/g; // 英文/数字连续 + 单个中文
const lower = text.toLowerCase();
let match: RegExpExecArray | null;
while ((match = regex.exec(lower)) !== null) {
tokens.push(match[0]);
}
return tokens.filter((t) => t.length > 0);
}

英文按词拆(”useEffect” 是一个 token),中文按字拆(”性能优化” 拆成 “性”、”能”、”优”、”化”)。严格来说对中文不友好——理想方案应该用 jieba 分词——但对代码场景(关键词以英文 API 名为主)影响不大。

RRF:倒数排名融合

两路检索各返回排序结果,但分值尺度完全不同(BM25 是浮点数,余弦相似度是 [-1, 1])。直接比较分数无意义。RRF(Reciprocal Rank Fusion)的思路是:不看原始分数,只看排名。

1
2
RRF_score(chunk) = 1 / (k + rank)
k = 60

同一个 chunk 在向量检索排第 2、在 BM25 排第 5,它的 RRF 分数 = 1/(60+2) + 1/(60+5) ≈ 0.0161 + 0.0154 = 0.0315。两个检索器都给出高排名的文档会得到奖励(共识 = 相关)。

1
2
3
4
5
6
7
8
9
10
11
12
// hybrid-search.ts — RRF 融合
export function rrfFusion(resultsA: RankedDoc[], resultsB: RankedDoc[], k = 60): Map<string, number> {
const scores = new Map<string, number>();
for (let i = 0; i < resultsA.length; i++) {
scores.set(resultsA[i].chunkId, 1 / (k + i + 1));
}
for (let i = 0; i < resultsB.length; i++) {
const current = scores.get(resultsB[i].chunkId) ?? 0;
scores.set(resultsB[i].chunkId, current + 1 / (k + i + 1));
}
return scores;
}

k=60 的选择:让第 1 名(1/61 ≈ 0.0164)和第 50 名(1/110 ≈ 0.0091)不会差距过大,排名信息保留但不被极端放大。

第五站:Rerank 精排

混合检索返回的是粗略排序。最后一步用 Reranker 做精排——对 Top 50 候选重新打分,取最相关的 5 个。当前 Reranker 是 Mock 实现(直接取前 5),架构上预留了替换为 BGE-Reranker 或 Cohere Rerank API 的接口:

1
2
3
4
5
6
7
8
9
10
// rerank.ts — Reranker 接口 + Mock 实现
export interface Reranker {
rerank(query: string, candidates: RerankCandidate[], topN: number): Promise<RerankCandidate[]>;
}

export class MockReranker implements Reranker {
async rerank(_query: string, candidates: RerankCandidate[], topN: number): Promise<RerankCandidate[]> {
return candidates.slice(0, topN);
}
}

终点:Prompt 组装

检索到的文档块最终被拼进一个结构化 Prompt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// rag-pipeline.ts — Prompt 组装
function buildPrompt(question: string, sources: RetrievedChunk[]): string {
const docsText = sources
.map((s, i) => `### 文档 ${i + 1}(来源:${s.metadata.source})\n${s.pageContent}`)
.join("\n\n");

return [
"你是一个知识库问答助手。请严格根据以下参考文档回答问题。",
"",
"## 参考文档",
docsText,
"",
"## 规则",
"1. 回答必须基于上述文档,不要编造。",
"2. 如果文档未覆盖,直接说「文档中未找到相关答案」。",
"3. 引用时注明来源。",
"",
"## 用户问题",
question,
].join("\n");
}

四层结构——角色设定 → 参考文档带来源标注 → 防幻觉规则 → 用户问题。每篇文档都标注了来源路径,LLM 回答时能引用出处,用户能回溯验证。

RAG 评估:怎么知道检索好不好?

RAG 系统需要一个评估机制来验证检索质量。我实现了两个指标:

Faithfulness(忠实度):把 LLM 生成的答案拆成多条独立断言,逐条检查每条断言是否被检索到的文档支持。如果答案说”项目使用了 React 19 的 useOptimistic Hook”,但检索结果中没有任何文档提到 useOptimistic,这条断言就是幻觉。

Answer Relevancy(回答相关性):反向操作——根据 LLM 的答案生成几个假设性问题,然后检查这些问题和用户原始问题的语义相似度。如果 LLM 回答了一堆无关内容,反向生成的问题会和原始问题差异很大。

评估端点通过 /api/rag/evaluate 暴露,返回各项指标的具体数值,供调试和迭代用。

小结

RAG 不是”把文档扔给 LLM”就完事了。一个完整的 RAG 流水线涉及六个环节:多模态加载 → 递归切分 → Embedding 向量化 → 混合检索(向量 + BM25 + RRF)→ Rerank 精排 → Prompt 组装。每一步都有自己的设计取舍,而每个取舍都要能讲清楚为什么——这是从”会用 RAG”到”能讲 RAG”的分界线。


本文是「Building an AI Code Review Agent」系列的终篇。四篇文章覆盖了一个全栈 AI Agent 从核心引擎(ReAct 循环)到上下文治理(三层压缩)、安全护栏(四重奏)再到知识检索(RAG 全链路)的完整工程实践。所有代码开源在 github.com/Zoella-w/code-agent,欢迎查阅和讨论。

  • Title: RAG 从零搭建:多模态加载 → Embedding → 混合检索 → Rerank 全链路
  • Author: Gabrielle
  • Created at : 2026-08-12 14:00:00
  • Updated at : 2026-08-10 23:12:56
  • Link: https://zoella-w.github.io/2026/08/12/103-agent-rag-pipeline/
  • License: This work is licensed under CC BY-NC-SA 4.0.