3487 字
约 11 分钟
1
RAG模块详细讲解

RAG模块详细讲解

  1. 前置知识可以按需阅读以下文章 以便更好的学习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 幻觉的最后一道闸。 关键细节 2askQuery 用 Rewriter 的第一条(独立化后的版本),不用原始的"上面讲的微服务……"。因为独立化版本对 LLM 来说更好理解。 关键细节 3generateFn == nil(没注入 LLM)时不会崩,而是直接把检索原文返回。这是又一处优雅降级。

总结:Query 是「改写 → 三路检索 → 一级 RRF(路间)→ 二级 RRF(query 间)→ LLM 精排 → small-to-big 父块回填 → 喂 LLM 合成」,每一步失败都有兜底。

RAG模块详细讲解
http://www.clxhxhhr.top/posts/752/
作者
clxstart
发布于
2026-09-18
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。
文章目录