RAG 从零搭建:多模态加载 → Embedding → 混合检索 → Rerank 全链路
RAG 解决什么问题?
LLM 有两个先天局限:知识截止日期,以及幻觉(对不知道的事情也会编出答案)。RAG(Retrieval-Augmented Generation,检索增强生成)是当前最成熟的解决方案——在 LLM 回答问题之前,先从知识库中检索相关文档,把检索结果和问题一起塞进 prompt,让 LLM 基于真实文档回答。
对代码审查 Agent 来说,RAG 的具体场景是:用户问”这个项目的路由设计有什么问题?”——Agent 不应该只分析用户贴出来的那一段代码,而是应该检索项目中的所有路由文件、配置文件、中间件,综合判断。
全链路架构
整个 RAG 子系统分为两个阶段——索引(离线)和查询(在线):
1 | ┌── 索引阶段(index)──────────────────────────────────────┐ |
核心编排在 rag-pipeline.ts 中,它不自己干活,只负责按顺序串联各模块。所有组件通过 constructor 注入,后续换任何一块(比如向量存储从内存升级到 Pinecone)不改本文件。
第一站:文档加载与切分
多格式文档加载由 MultiFormatLoader 统一入口,内部按文件类型分发:
1 | text → TextParser(保留原始格式 + 行号标注) |
加载后的文档进入 splitDocument() 做递归切分。选择递归切分而非固定长度,原因很明确:代码文件有自然的分隔符。 按 \n\n(函数间空行)→ \n(单行)→ (空格)的优先级依次尝试切分点,不会把函数拦腰截断。语义切分需要额外的 Embedding 模型做句子边界检测,对代码场景收益不大,反而增加延迟。
参数设置:chunkSize = 500,overlap = 50。overlap 的作用是让相邻块之间有 50 字符的内容重叠——防止某个关键信息恰好在切分边界上被割裂,导致两个块各留一半、检索时谁也找不全。
第二站:Embedding —— 文本变向量
Embedding 模型我选了 BAAI/bge-m3,通过 SiliconFlow API 调用。BGE-M3 是目前中英文检索场景表现最好的开源模型之一,支持 1024 维向量输出,且 API 兼容 OpenAI 格式:
1 | // siliconflow-embedding.ts — SiliconFlow BGE-M3 调用 |
EmbeddingModel 接口抽象了 embed() 和 embedBatch() 两个方法。索引阶段用 embedBatch() 批量转换节省 API 调用,查询阶段用 embed() 单条转换。
为什么用余弦相似度而不是欧氏距离? BGE-M3 输出的向量归一化后,余弦相似度只关注方向而非长度。两个内容相似的文档,向量方向一致但长度可能因文本长度不同而有差异——欧氏距离会被长度误导,余弦不会。
第三站:向量存储
当前向量存储用的是 MemVectorStore——一个内存数组,暴力遍历所有向量计算余弦相似度,再排序取 Top-K:
1 | // mem-vector-store.ts — 内存暴力检索 |
坦白说这是 O(n) 暴力遍历。生产环境应该换成 Pinecone 或 pgvector。但 VectorStore 接口已经抽象好了——add()、query()、clear()、getChunkById()——换存储只改一个实现类,接口不动。对于代码审查场景(通常几十个文件以内),内存级别完全够用。
第四站:混合检索 —— BM25 + 向量 + RRF 融合
纯向量检索有一个盲区:它能找到语义相似的内容,但不一定能找到精确匹配的关键词。 比如用户问”React.memo 在哪些文件里用了?”,向量检索可能返回一堆关于性能优化的文档,但漏掉了 React.memo 这个精确词出现在 LegacyComponent.tsx 中的那次。
混合检索的方案:向量检索 + BM25 关键词检索,两路并行,结果用 RRF 融合。
BM25:经典但有效的关键词打分
BM25 是信息检索领域的经典算法,核心思想三句话:
- 词频(TF):查询词在文档中出现越多次 → 分数越高,但有饱和上限(k1=1.5 防止出现 100 次比出现 10 次分数高 10 倍)
- 逆文档频率(IDF):查询词越稀有 → 权重越大(”useEffect” 比 “const” 更有区分度)
- 文档长度归一化:长文档天然包含更多词 → 需要打折(b=0.75)
1 | // bm25.ts — BM25 核心打分公式 |
分词逻辑也考虑到了代码场景:
1 | // bm25.ts — 中英混合分词 |
英文按词拆(”useEffect” 是一个 token),中文按字拆(”性能优化” 拆成 “性”、”能”、”优”、”化”)。严格来说对中文不友好——理想方案应该用 jieba 分词——但对代码场景(关键词以英文 API 名为主)影响不大。
RRF:倒数排名融合
两路检索各返回排序结果,但分值尺度完全不同(BM25 是浮点数,余弦相似度是 [-1, 1])。直接比较分数无意义。RRF(Reciprocal Rank Fusion)的思路是:不看原始分数,只看排名。
1 | RRF_score(chunk) = 1 / (k + rank) |
同一个 chunk 在向量检索排第 2、在 BM25 排第 5,它的 RRF 分数 = 1/(60+2) + 1/(60+5) ≈ 0.0161 + 0.0154 = 0.0315。两个检索器都给出高排名的文档会得到奖励(共识 = 相关)。
1 | // hybrid-search.ts — RRF 融合 |
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 | // rerank.ts — Reranker 接口 + Mock 实现 |
终点:Prompt 组装
检索到的文档块最终被拼进一个结构化 Prompt:
1 | // rag-pipeline.ts — Prompt 组装 |
四层结构——角色设定 → 参考文档带来源标注 → 防幻觉规则 → 用户问题。每篇文档都标注了来源路径,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.