RAG模块详细讲解
- 前置知识可以按需阅读以下文章 以便更好的学习RAG模块RAG基础概念RAG基础结构
字数: 5181 | 语雀原文
前置知识
可以按需阅读以下文章 以便更好的学习RAG模块
RAG 在 Saber 项目的位置
整个项目大致分四层:
- HTTP 接口
- 应用层(chat)
- 领域层(domain)
- 基础设施(infrastructure)
RAG 模块是领域层的一个子包*internal/domain/rag** 下图标了一颗 ⭐ 表示"我们的视野中心":*
image.png
五大组件分工
打开 internal/domain/rag/ 目录,5 个核心 .go 文件就是***"你将来面试会被问到的全部":***
internal/domain/rag/
├── rag.go # Engine:对外门面
├── splitter.go # 切分(父块 + 子块 + 代码块保护)
├── hybrid.go # 混合检索(Milvus + ES + Neo4j + RRF)
├── rewriter.go # Query 改写
└── reranker.go # 精排
| 组件 | 文件 | 一句话职责 | 类比 |
|---|---|---|---|
| Engine | rag.go | 对外暴露 Ingest / QueryWithHistory,编排全流程 | 餐厅大堂经理 |
| Splitter | splitter.go | 把长文档切成父块 + 子块(递归 + 代码块保护) | 把书撕成便利贴 |
| HybridStore | hybrid.go | 三路混合检索 + 一级 RRF + 二级 RRF + 模式降级 | 投票合议庭 |
| Rewriter | rewriter.go | 改写用户问题成 N 条等价 query(消歧 + 多样化) | 把口语翻译成书面问题 |
| Reranker | reranker.go | LLM listwise 给候选段落打 0-10 分精排 | 评委二次复评 |
image.png
上传一篇文档 - 喂资料(Ingest注入 向量化)
设想用户上传了一篇 8000 字的《2024 公司报销制度.md》。它会被这样处理:
image.png
**代码入口在 rag.go#L160-L204 的 **Ingest** / ****IngestWithMetadata**
下面 5 步逐步拆:
step 1: 父块切分(~800 字符)
这一步在干嘛? 把整篇文章先按"语义大段"切成 ~800 字符的块。这些大块不是用来检索的,而是用来给 LLM 看的**——后面"small-to-big"环节会用到。、
代码在 rag.go#L165:
parents := e.parentSplitter.Split(doc)
父块大小怎么定的?看 rag.go#L108-L122 的 **NewEngine**:
parentSize := cfg.ChunkSize * 4
if parentSize cursor { atoms = append(atoms, atom{text: text[cursor:open]}) }
atoms = append(atoms, atom{text: text[open:close], atomic: true}) // ← 不可切
cursor = close
}
// ...
return atoms
}
这是面试时一个很好的 talk track***:「我们处理技术文档时把 ***```*** 代码块标记为原子单元,避免切分破坏代码语义」。***
step 3-6: 多路存储一起写
切完之后,每个子块要被写到 4 个地方。我们一气讲完,因为它们的代码都在同一个函数 hybrid.go#L105-170 的 IndexWithParentsAndMetadata。
step 3:每个子块 Embed
var emb []float64
if hs.embedFn != nil {
emb, _ = hs.embedFn(c.Content) // 调 LLM 的 embedding 接口
}
注意 hs.embedFn 是回调——不是 import llm 包来调的。这就是第 4 章会讲的"反向依赖注入"。
step 4:写 PostgreSQL(老底)
pgID, err := hs.chunks.SavePGWithMetadata(docHash, i, c.Content, parentContent, embJSON, ragchunk.Metadata{
DocumentID: metadata.DocumentID,
VersionID: metadata.VersionID,
Section: metadata.Section,
})
hybrid.go#L132-141。关键设计:每条子块行里都直接存了它的父块原文(parentContent)。后面 small-to-big 不需要再 join 表,PG 一次查询就能拿到子+父两份内容。
为什么 PG 是"老底"?因为它是 RAG 唯一的"事实真相源(source of truth)"。Milvus / ES 都只是它的派生索引,丢了可以从 PG 重建。所以 hybrid.go#L42-44 写:「PG 不可用直接 mode=unavailable」——没有事实源,就玩不下去。
step 5:写 Elasticsearch(逐条)
if hs.chunks.ESAvailable() {
if err := hs.chunks.IndexES(pgID, c.Content, docHash, i); err != nil {
log.Printf("⚠️ RAG chunk 索引到 ES 失败 (pg_id=%d): %v", pgID, err)
}
}
hybrid.go#L144-149。注意写失败只打 log 不 return error——主链路继续跑,只是这条 chunk 在 ES 里查不到(仍然能从 Milvus 查到)。这是优雅降级的另一种形态。
step 6:批量写 Milvus
if hs.chunks.MilvusAvailable() && len(emb) > 0 {
pgIDs = append(pgIDs, pgID)
contents = append(contents, c.Content)
embeddings = append(embeddings, emb32)
}
// ↓ 循环结束后批量插入
if len(pgIDs) > 0 {
if err := hs.chunks.InsertMilvus(pgIDs, contents, embeddings); err != nil {
log.Printf("⚠️ RAG chunks 写入 Milvus 失败: %v", err)
}
}
hybrid.go#L151-168。为什么 Milvus 用批量、ES 用逐条? 因为 Milvus 单条插入有 RPC 开销,1000 条逐条要 ~10 秒;批量降到 ~1 秒。ES 没那么强烈的批量收益,逐条写代码更简单。
step 7: 异步写 Neo4j(KG)
主流程已经返回了,KG 才在另一个 goroutine 里慢慢建图。代码 rag.go#L186-195:
if e.kg != nil && e.kg.Available() && len(indexed) > 0 {
refs := make([]knowledge.ChunkRef, len(indexed))
for i, c := range indexed {
refs[i] = knowledge.ChunkRef{ID: c.ID, PGID: c.PGID, Content: c.Content}
}
goSafe("rag.kg-index", func() { e.kg.IndexDocument(docHash, refs) })
}
为什么异步? KG 建图要再调 LLM 抽取实体关系,单文档耗时可能 5-30 秒。如果同步,用户上传文档的 HTTP 请求就超时了。
为什么是 goSafe 不是 go? Neo4j 网络断连或 panic 时,普通的 go func() 会让整个 Go 进程挂掉(Go 的规则:goroutine panic 没人 recover 就拖崩主进程)。
goSafe 包了一层 recover,源码 rag.go#L36-45:
func goSafe(name string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("⚠️ goroutine panic [%s]: %v\n%s", name, r, debug.Stack())
}
}()
fn()
}()
}
关键细节:传给 IndexDocument 的是 indexed(含真实 PGID)而不是 chunks。
为什么?
因为 KG 节点要把 pg_id 持久化进去——后续检索时三路 RRF 融合需要靠 pg_id 把 KG 节点和 PG 行对齐。如果 KG 不存 pg_id,KG 召回的实体就和 PG 的 chunk 对不上,融合也就没意义了(详见 hybrid.go#L378-383)。
docHash:幂等 + 删除的关键
每次 Ingest 第一步算 docHash(hybrid.go#L107):
docHash := fmt.Sprintf("%x", sha256.Sum256([]byte(docContent)))[:16]
作用 1:幂等。同一文档重复上传,docHash 一样,repo 层会去重。
作用 2:按文档整体删除。Engine.Delete(docHash) 一次把 PG / ES / Milvus / Neo4j 里这个文档的全部 chunk 都删掉,源码 rag.go#L218-229。
总结:Ingest 是「父块切 → 子块切 → PG 写老底 → ES/Milvus 同步派生 → Neo4j 异步建图」,切分核心是按语义强度递归降级 + 代码块原子保护。
用户提问(Query改写)
image.png
设定一个具体场景:
用户和 Agent 已经聊了几轮,前几句聊到了"我们的微服务架构"。现在用户输入:
"上面讲的微服务怎么实现的?"
这是一个非常典型的"省略 + 指代"型问题。"上面讲的"指代不明,纯靠这句话本身根本搜不到东西。我们看 RAG 是怎么处理的。
image.png
主流程入口在 rag.go#L250-301 的 **QueryWithHistory**。下面我们 step-by-step 拆。
step 1: 改写(Rewriter)
这一步在干嘛? *** 把"上面讲的微服务怎么实现的?"这种依赖对话历史的问题,改写成自包含*的独立查询,并顺手生成几条"措辞不同但语义等价"的变体。
主流程代码 rag.go#L255-262:
queries := []string{question}
if e.rewriter != nil {
rewritten := e.rewriter.Rewrite(question, history)
if len(rewritten) > 0 {
queries = rewritten
}
}
实现在 rewriter.go#L66-114 的 ***LLMRewriter.Rewrite***。它做了三件事:
- 把最近 6 轮历史摘要拼到 prompt 里。
- 调 LLM 让它输出 JSON
{"queries": [...]}。 - **解析失败一律 **fallback 到
[原查询]。 prompt 设计很有讲究 rewriter.go#L52-63:
1) 先把当前问题改写成一句**自包含的独立查询**(消除指代、补全省略)。
2) 再生成若干条**等价但措辞不同**的查询变体(同义词替换、抽象/具体切换、不同语序)。
输出**严格 JSON**,不要任何说明文字、不要 markdown 代码块:
{"queries": ["独立查询", "变体1", "变体2"]}
关键设计:始终保留原查询。看 rewriter.go#L103-113:
queries := parseRewriteJSON(raw)
if len(queries) == 0 {
log.Printf("⚠️ Query rewrite 解析失败,回退原查询...")
return []string{query}
}
queries = dedupKeepOrder(append(queries, query)) // ← 把原 query 也塞进去
if len(queries) > r.numQueries {
queries = queries[:r.numQueries]
}
return queries
***为什么始终保留原查询? *** LLM 改写有可能完全跑偏(比如把"微服务"改写成不相关的"微小服务"),原查询能保底召回。这是工程上的"safety net"思维。
回到我们的例子,三条改写后的 query 大致是:
["继续讲微服务架构如何实现", "微服务实现方案", "上面讲的微服务怎么实现的?"]
注意第三条是"原查询"——多保一份。
step 2-4: 三路检索 + 一级 RRF + 二级 RRF(核心)
整章最复杂的部分。我们用两张图把它拆成两层来看。
主流程入口 rag.go#L264-265:
// 2) 多查询并发检索 + 跨查询 RRF 合并 + 可选 rerank(在 store 内部完成)
hybridResults := e.store.SearchMulti(queries, e.cfg.TopK)
实现在 hybrid.go#L206-274 的 SearchMulti。它分成两层 RRF。
一级 RRF(单条 query 内三路融合)
每条 query 都单独跑一次"三路检索 + 三路 RRF":
image.png
代码在 hybrid.go#L319-434 的 **searchHybrid**。核心 RRF 计算在 hybrid.go#L362-384:
rrfScores := make(map[int64]float64)
for rank, hit := range milvusHits {
rrfScores[hit.ID] += 1.0 / float64(k+rank+1)
}
for rank, hit := range esHits {
rrfScores[hit.PGID] += 1.0 / float64(k+rank+1)
}
if hs.kg != nil && hs.kg.Available() {
kgWeight := hs.cfg.KGWeight
if kgWeight 1 {
results = hs.reranker.Rerank(query, results, topK)
} else if topK > 0 && len(results) > topK {
results = results[:topK]
}
return results
}
实现 reranker.go#L66-135 的 ***LLMReranker.Rerank***。Prompt 设计 reranker.go#L47-63:
你是检索系统的精排器。给定用户问题和若干候选段落(每条带编号 idx),
判断每条段落对回答该问题的**相关性 + 信息密度**,给 0~10 的整数分。
打分准则:
- 10:直接回答了问题
- 7~9:包含明确相关事实
- 4~6:弱相关 / 部分相关
- 1~3:仅出现共现关键词,不能用来回答
- 0:无关 / 噪声
输出**严格 JSON**...
{"scores": [{"idx": 0, "score": 9}, {"idx": 1, "score": 3}, ...]}
PS:注意是 listwise——一次把所有候选给 LLM,让它统一打分。不是 pointwise(一次给一条评分 N 次)。listwise 的好处:LLM 能看到候选间的相对关系,分数尺度一致。
关键设计:LLM 分数主排序,原 RRF 分作 tiebreaker,reranker.go#L116-121:
sort.SliceStable(pool, func(i, j int) bool {
if pool[i].llm != pool[j].llm {
return pool[i].llm > pool[j].llm // 主:LLM 分
}
return pool[i].rrf > pool[j].rrf // 副:RRF 分(tiebreaker)
})
***为什么 RRF 分作 tiebreaker? *** LLM 给整数分(0-10),打平时不少。这时让 RRF 来做"深度排序"——因为 RRF 是连续值,几乎不打平。
另一个关键兜底reranker.go#L108-114:LLM 漏给某条打分时,给它一个"低分但不是负无穷"的兜底值(-1)。不丢弃——避免 LLM 漏判把正确答案删掉。
step 6: small-to-big 父块回填
这一步在干嘛?** 之前检索命中的是子块**(200 字精准命中),但喂给 LLM 看的应该是父块(800 字完整上下文)。这一步就是做这个替换。
代码 rag.go#L267-282:
results := make([]SearchResult, 0, len(hybridResults))
var parts []string
for _, hr := range hybridResults {
// 3) small-to-big:优先用父块原文做上下文
display := hr.Parent
if display == "" {
display = hr.Chunk.Content
}
results = append(results, SearchResult{
Chunk: Chunk{ID: hr.Chunk.ID, Content: display},
Similarity: hr.Score,
})
if display != "" {
parts = append(parts, display)
}
}
hr.Parent 字段是从 PG 直接查出来的(02 章讲过:每个子块行里都存了它的父块原文)。所以这一步**没有额外 **IO,纯内存替换。
为什么不直接用大块检索?** ** 关键在 embedding 的特性:
- 把一段 800 字塞进 embedding 模型,输出一个固定维度的向量。
- 这段 800 字里同时讲了 ABC 三件事,向量是三件事的"语义平均"。
- 你问 A,问题向量和这个"平均向量"的距离是中等——既不近也不远。
- 而那段 200 字的子块里只讲 A 这一件事,向量集中指向 A,问 A 时它命中得很准。 所以小块精准命中 + 大块给上下文 = 检索质量和信息量的最佳折衷。 PS:这是 LlamaIndex 在 2023 年提出的 small-to-big 技巧,本项目把它落地成"PG 表里直接存父块原文 + 检索后内存替换"的最简单形态
step 7: 拼提示词喂 LLM
最后一步:把若干个父块拼成 context,套上 system prompt 和用户问题,调 LLM 生成最终答案
代码 rag.go#L284-300:
context := strings.Join(parts, "\n\n")
if context == "" {
return "知识库中未找到相关内容。", results
}
if e.generateFn != nil {
systemPrompt := "你是一个基于知识库回答问题的助手。请仅根据提供的上下文内容回答问题,不要编造信息。如果上下文不足以回答,请说明。"
askQuery := question
if len(queries) > 0 && queries[0] != "" {
askQuery = queries[0] // ← 用独立化后的版本
}
userMsg := fmt.Sprintf("上下文:\n%s\n\n问题:%s", context, askQuery)
answer := e.generateFn(systemPrompt, userMsg)
return answer, results
}
return fmt.Sprintf("【知识库检索结果】\n%s", context), results
关键细节 1:
system prompt 明确要求"仅根据上下文回答,不编造"——这是防 LLM 幻觉的最后一道闸。
关键细节 2:
askQuery 用 Rewriter 的第一条(独立化后的版本),不用原始的"上面讲的微服务……"。因为独立化版本对 LLM 来说更好理解。
关键细节 3:
generateFn == nil(没注入 LLM)时不会崩,而是直接把检索原文返回。这是又一处优雅降级。
总结:Query 是「改写 → 三路检索 → 一级 RRF(路间)→ 二级 RRF(query 间)→ LLM 精排 → small-to-big 父块回填 → 喂 LLM 合成」,每一步失败都有兜底。