第8讲:RAG 生产化——Go 实现亿级向量召回 + 混合检索 + 重排序 Pipeline

一、开篇:RAG 的四个核心挑战

2026 年,RAG 已经是大模型落地的标配架构。但生产环境中,简单的 "Embedding → 向量检索 → LLM 生成" 链条远远不够:

挑战 现象 生产级解法
检索精度不足​ Top-K 返回大量无关片段 混合检索 + 重排序
大规模性能瓶颈​ 千万级以上向量检索延迟飙升 分片 + HNSW 索引 + GPU 加速
查询意图漂移​ 用户问法多样,直接检索效果差 Query Rewrite + HyDE
上下文窗口浪费​ 召回的片段塞满 Context 但有效信息少 动态剪枝 + 相关性过滤

生产级 RAG Pipeline = Query Understanding → Multi-Stage Retrieval → Reranking → Context Assembly → Generation


二、整体架构

复制代码
用户查询
    │
    ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Stage 1: Query Understanding                  │
│                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ Query Rewrite│  │  Query       │  │  HyDE        │          │
│  │ (扩写/改写)  │  │  Decompose   │  │ (虚拟文档)   │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└──────────────────────────┬──────────────────────────────────────┘
                           │ 多条查询
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│              Stage 2: Multi-Channel Retrieval                    │
│                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 向量检索     │  │  BM25 关键词  │  │ 结构化过滤   │          │
│  │ (Dense)      │  │ (Sparse)     │  │ (Metadata)   │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
│         │                │                │                     │
│         └────────────────┴────────────────┘                     │
│                              │                                   │
│                              ▼                                   │
│                     ┌──────────────┐                            │
│                     │  Fusion      │                            │
│                     │  (RRF/CC)    │                            │
│                     └──────────────┘                            │
└──────────────────────────┬──────────────────────────────────────┘
                           │ Top-N 候选
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│              Stage 3: Re-ranking                                 │
│                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ Cross-Encoder│  │  LLM Reranker│  │ 规则过滤     │          │
│  │ (轻量级)     │  │ (精确但慢)   │  │ (去重/去噪)  │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└──────────────────────────┬──────────────────────────────────────┘
                           │ Top-K 精选
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│              Stage 4: Context Assembly                           │
│                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 窗口裁剪     │  │ 相关性排序   │  │ 引用标记     │          │
│  │ (Token 预算) │  │ (重要在前)   │  │ (来源追溯)   │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└──────────────────────────┬──────────────────────────────────────┘
                           │ 优化后的 Context
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│              Stage 5: Generation                                 │
│                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ System Prompt│  │  Context     │  │ 引用格式     │          │
│  │ + 指令       │  │ 注入         │  │ 规范化       │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
│         │                │                │                     │
│         └────────────────┴────────────────┘                     │
│                              │                                   │
│                              ▼                                   │
│                     ┌──────────────┐                            │
│                     │  LLM 生成    │                            │
│                     └──────────────┘                            │
└─────────────────────────────────────────────────────────────────┘

三、Stage 1: Query Understanding

复制代码
// rag/query.go
package rag

import (
    "context"
    "encoding/json"
    "fmt"
    "strings"
)

// QueryUnderstanding 查询理解结果
type QueryUnderstanding struct {
    OriginalQuery    string   `json:"original_query"`
    RewrittenQueries []string `json:"rewritten_queries"` // 改写后的多条查询
    DecomposedParts  []string `json:"decomposed_parts"`  // 分解后的子问题
    HypotheticalDoc  string   `json:"hypothetical_doc"`  // HyDE 生成的虚拟文档
    Intent           string   `json:"intent"`            // 查询意图分类
    Filters          map[string]interface{} `json:"filters"` // 结构化过滤条件
}

// QueryProcessor 查询处理器
type QueryProcessor struct {
    llm LLMClient
}

func NewQueryProcessor(llm LLMClient) *QueryProcessor {
    return &QueryProcessor{llm: llm}
}

// Process 处理用户查询
func (qp *QueryProcessor) Process(ctx context.Context, query string) (*QueryUnderstanding, error) {
    result := &QueryUnderstanding{
        OriginalQuery: query,
    }

    // 1. 查询改写(并行执行)
    rewriteChan := make(chan []string, 1)
    hydeChan := make(chan string, 1)
    intentChan := make(chan string, 1)

    go func() {
        rewrites, _ := qp.rewriteQuery(ctx, query)
        rewriteChan <- rewrites
    }()
    go func() {
        doc, _ := qp.generateHyDE(ctx, query)
        hydeChan <- doc
    }()
    go func() {
        intent, filters, _ := qp.classifyIntent(ctx, query)
        result.Intent = intent
        result.Filters = filters
        intentChan <- intent
    }()

    result.RewrittenQueries = <-rewriteChan
    result.HypotheticalDoc = <-hydeChan
    <-intentChan

    // 2. 如果是复合问题,分解为子问题
    if result.Intent == "complex" {
        parts, _ := qp.decomposeQuery(ctx, query)
        result.DecomposedParts = parts
    }

    return result, nil
}

func (qp *QueryProcessor) rewriteQuery(ctx context.Context, query string) ([]string, error) {
    prompt := fmt.Sprintf(`你是一个查询改写专家。请将以下用户查询改写为 3 种不同的表述方式,以提高检索召回率。

原查询:%s

要求:
1. 保持原意不变
2. 使用不同的措辞和句式
3. 适当补充同义词和相关术语
4. 每条不超过 50 个字

请输出 JSON 数组:["改写1", "改写2", "改写3"]`, query)

    resp, err := qp.llm.Chat(ctx, []Message{{Role: "user", Content: prompt}})
    if err != nil {
        return []string{query}, nil // 降级返回原查询
    }

    var rewrites []string
    if err := json.Unmarshal([]byte(extractJSON(resp)), &rewrites); err != nil {
        return []string{query}, nil
    }

    // 去重并限制数量
    seen := map[string]bool{query: true}
    var result []string
    for _, r := range rewrites {
        r = strings.TrimSpace(r)
        if !seen[r] && r != "" {
            seen[r] = true
            result = append(result, r)
        }
    }

    if len(result) == 0 {
        result = []string{query}
    }

    return result, nil
}

func (qp *QueryProcessor) generateHyDE(ctx context.Context, query string) (string, error) {
    // HyDE: Hypothetical Document Embeddings
    // 先生成一个假设的理想文档,然后用这个文档去做向量检索
    prompt := fmt.Sprintf(`请根据以下查询,生成一段理想的文档片段。这段文档应该包含能够完美回答该查询的内容。

查询:%s

要求:
1. 假设存在这样一篇文档,它包含了回答该问题的所有必要信息
2. 文档风格客观、专业
3. 长度 100-200 字
4. 不要提及"根据查询"或"假设"等字样`, query)

    resp, err := qp.llm.Chat(ctx, []Message{{Role: "user", Content: prompt}})
    if err != nil {
        return query, nil
    }

    return resp, nil
}

func (qp *QueryProcessor) classifyIntent(ctx context.Context, query string) (string, map[string]interface{}, error) {
    prompt := fmt.Sprintf(`分析以下查询的意图类别和可能的过滤条件。

查询:%s

请输出 JSON:
{
    "intent": "factual|analytical|comparative|procedural|complex",
    "filters": {
        "date_range": null 或 {"start": "2024-01", "end": "2024-12"},
        "category": null 或 "技术文档|产品手册|FAQ",
        "language": null 或 "zh|en"
    }
}`, query)

    resp, err := qp.llm.Chat(ctx, []Message{{Role: "user", Content: prompt}})
    if err != nil {
        return "factual", nil, nil
    }

    var result struct {
        Intent  string                 `json:"intent"`
        Filters map[string]interface{} `json:"filters"`
    }
    json.Unmarshal([]byte(extractJSON(resp)), &result)

    return result.Intent, result.Filters, nil
}

func (qp *QueryProcessor) decomposeQuery(ctx context.Context, query string) ([]string, error) {
    prompt := fmt.Sprintf(`将以下复杂查询分解为多个简单的子问题。

查询:%s

要求:
1. 每个子问题应该可以被独立检索
2. 子问题之间可能有依赖关系
3. 保持信息的完整性

请输出 JSON 数组:["子问题1", "子问题2", ...]`, query)

    resp, err := qp.llm.Chat(ctx, []Message{{Role: "user", Content: prompt}})
    if err != nil {
        return []string{query}, nil
    }

    var parts []string
    json.Unmarshal([]byte(extractJSON(resp)), &parts)

    if len(parts) == 0 {
        parts = []string{query}
    }

    return parts, nil
}

四、Stage 2: Multi-Channel Retrieval

复制代码
// rag/retrieval.go
package rag

import (
    "context"
    "fmt"
    "math"
    "sort"
    "sync"
)

// Document 文档
type Document struct {
    ID       string                 `json:"id"`
    Content  string                 `json:"content"`
    Metadata map[string]interface{} `json:"metadata"`
    Score    float64                `json:"score"`
    Source   string                 `json:"source"` // vector, bm25, filter
}

// VectorIndex 向量索引接口
type VectorIndex interface {
    Search(ctx context.Context, embedding []float32, topK int, filters map[string]interface{}) ([]Document, error)
    BatchSearch(ctx context.Context, embeddings [][]float32, topK int, filters map[string]interface{}) ([][]Document, error)
}

// BM25Index BM25 索引接口
type BM25Index interface {
    Search(ctx context.Context, query string, topK int) ([]Document, error)
}

// Retriever 检索器
type Retriever struct {
    vectorIndex VectorIndex
    bm25Index   BM25Index
    embedder    Embedder
    config      RetrieverConfig
}

type RetrieverConfig struct {
    VectorTopK     int     `json:"vector_top_k"`     // 向量检索返回数量
    BM25TopK       int     `json:"bm25_top_k"`       // BM25 返回数量
    FinalTopK      int     `json:"final_top_k"`      // 融合后最终数量
    VectorWeight   float64 `json:"vector_weight"`    // 向量得分权重
    BM25Weight     float64 `json:"bm25_weight"`      // BM25 得分权重
    EnableRRF      bool    `json:"enable_rrf"`       // 启用 RRF 融合
    RRFDamping     int     `json:"rrf_damping"`      // RRF 阻尼系数
}

func DefaultRetrieverConfig() RetrieverConfig {
    return RetrieverConfig{
        VectorTopK:   50,
        BM25TopK:     50,
        FinalTopK:    20,
        VectorWeight: 0.6,
        BM25Weight:   0.4,
        EnableRRF:    true,
        RRFDamping:   60,
    }
}

// Retrieve 执行多通道检索
func (r *Retriever) Retrieve(ctx context.Context, queries []string, filters map[string]interface{}) ([]Document, error) {
    var allDocs []Document
    var mu sync.Mutex
    var wg sync.WaitGroup

    // 对每条查询分别执行向量检索和 BM25 检索
    for _, query := range queries {
        wg.Add(2)

        // 向量检索
        go func(q string) {
            defer wg.Done()
            docs, err := r.vectorSearch(ctx, q, filters)
            if err != nil {
                return
            }
            mu.Lock()
            allDocs = append(allDocs, docs...)
            mu.Unlock()
        }(query)

        // BM25 检索
        go func(q string) {
            defer wg.Done()
            docs, err := r.bm25Search(ctx, q)
            if err != nil {
                return
            }
            mu.Lock()
            allDocs = append(allDocs, docs...)
            mu.Unlock()
        }(query)
    }

    wg.Wait()

    // 去重并融合
    return r.fuseResults(allDocs), nil
}

func (r *Retriever) vectorSearch(ctx context.Context, query string, filters map[string]interface{}) ([]Document, error) {
    // 1. 生成查询向量
    embedding, err := r.embedder.Embed(ctx, query)
    if err != nil {
        return nil, fmt.Errorf("embed query: %w", err)
    }

    // 2. 向量检索
    docs, err := r.vectorIndex.Search(ctx, embedding, r.config.VectorTopK, filters)
    if err != nil {
        return nil, fmt.Errorf("vector search: %w", err)
    }

    // 3. 标记来源
    for i := range docs {
        docs[i].Source = "vector"
    }

    return docs, nil
}

func (r *Retriever) bm25Search(ctx context.Context, query string) ([]Document, error) {
    docs, err := r.bm25Index.Search(ctx, query, r.config.BM25TopK)
    if err != nil {
        return nil, fmt.Errorf("bm25 search: %w", err)
    }

    for i := range docs {
        docs[i].Source = "bm25"
    }

    return docs, nil
}

// fuseResults 融合多路检索结果
func (r *Retriever) fuseResults(docs []Document) []Document {
    if len(docs) == 0 {
        return nil
    }

    // 按文档 ID 分组
    groups := make(map[string][]Document)
    for _, doc := range docs {
        groups[doc.ID] = append(groups[doc.ID], doc)
    }

    var fused []Document
    for id, versions := range groups {
        if r.config.EnableRRF {
            // Reciprocal Rank Fusion
            score := 0.0
            for _, v := range versions {
                rank := 0
                if v.Source == "vector" {
                    rank = int((1 - v.Score) * float64(r.config.VectorTopK))
                } else {
                    rank = int((1 - v.Score) * float64(r.config.BM25TopK))
                }
                score += 1.0 / float64(r.config.RRFDamping+rank+1)
            }
            fused = append(fused, Document{
                ID:      id,
                Content: versions[0].Content,
                Metadata: versions[0].Metadata,
                Score:   score,
                Source:  "fusion",
            })
        } else {
            // Weighted Score Fusion
            score := 0.0
            for _, v := range versions {
                weight := r.config.VectorWeight
                if v.Source == "bm25" {
                    weight = r.config.BM25Weight
                }
                score += v.Score * weight
            }
            fused = append(fused, Document{
                ID:      id,
                Content: versions[0].Content,
                Metadata: versions[0].Metadata,
                Score:   score,
                Source:  "fusion",
            })
        }
    }

    // 按得分排序
    sort.Slice(fused, func(i, j int) bool {
        return fused[i].Score > fused[j].Score
    })

    // 取 Top-K
    if len(fused) > r.config.FinalTopK {
        fused = fused[:r.config.FinalTopK]
    }

    return fused
}

// Embedder 嵌入器接口
type Embedder interface {
    Embed(ctx context.Context, text string) ([]float32, error)
    BatchEmbed(ctx context.Context, texts []string) ([][]float32, error)
}

五、Stage 3: Re-ranking

复制代码
// rag/reranker.go
package rag

import (
    "context"
    "fmt"
    "sort"
    "sync"
)

// Reranker 重排序器
type Reranker struct {
    crossEncoder CrossEncoder
    llm          LLMClient
    config       RerankerConfig
}

type RerankerConfig struct {
    EnableCrossEncoder bool    `json:"enable_cross_encoder"` // 启用 Cross-Encoder
    EnableLLMRerank    bool    `json:"enable_llm_rerank"`    // 启用 LLM 重排序
    CrossEncoderTopK   int     `json:"cross_encoder_top_k"`  // CE 处理的数量
    LLMRerankTopK      int     `json:"llm_rerank_top_k"`     // LLM 处理的数量
    FinalTopK          int     `json:"final_top_k"`          // 最终保留数量
    RelevanceThreshold float64 `json:"relevance_threshold"`  // 相关性阈值
}

// CrossEncoder Cross-Encoder 接口
type CrossEncoder interface {
    Score(ctx context.Context, query string, documents []string) ([]float64, error)
}

func NewReranker(ce CrossEncoder, llm LLMClient, config RerankerConfig) *Reranker {
    return &Reranker{
        crossEncoder: ce,
        llm:          llm,
        config:       config,
    }
}

// Rerank 重排序
func (r *Reranker) Rerank(ctx context.Context, query string, documents []Document) ([]Document, error) {
    if len(documents) == 0 {
        return nil, nil
    }

    // 1. Cross-Encoder 初筛
    if r.config.EnableCrossEncoder && r.crossEncoder != nil {
        docs, err := r.rerankWithCE(ctx, query, documents)
        if err == nil {
            documents = docs
        }
    }

    // 2. LLM 精排
    if r.config.EnableLLMRerank && r.llm != nil {
        docs, err := r.rerankWithLLM(ctx, query, documents)
        if err == nil {
            documents = docs
        }
    }

    // 3. 过滤低相关性文档
    var filtered []Document
    for _, doc := range documents {
        if doc.Score >= r.config.RelevanceThreshold {
            filtered = append(filtered, doc)
        }
    }

    // 4. 取 Top-K
    if len(filtered) > r.config.FinalTopK {
        filtered = filtered[:r.config.FinalTopK]
    }

    return filtered, nil
}

func (r *Reranker) rerankWithCE(ctx context.Context, query string, documents []Document) ([]Document, error) {
    // 只对 Top-N 进行 CE 打分
    n := r.config.CrossEncoderTopK
    if n > len(documents) {
        n = len(documents)
    }

    candidates := documents[:n]
    contents := make([]string, len(candidates))
    for i, doc := range candidates {
        contents[i] = doc.Content
    }

    scores, err := r.crossEncoder.Score(ctx, query, contents)
    if err != nil {
        return documents, err
    }

    for i := range candidates {
        candidates[i].Score = scores[i]
        candidates[i].Source = "cross-encoder"
    }

    // 合并剩余文档
    result := append(candidates, documents[n:]...)

    sort.Slice(result, func(i, j int) bool {
        return result[i].Score > result[j].Score
    })

    return result, nil
}

func (r *Reranker) rerankWithLLM(ctx context.Context, query string, documents []Document) ([]Document, error) {
    n := r.config.LLMRerankTopK
    if n > len(documents) {
        n = len(documents)
    }

    candidates := documents[:n]

    // 批量打分
    type scoredDoc struct {
        index int
        score float64
    }

    results := make(chan scoredDoc, n)
    var wg sync.WaitGroup

    for i, doc := range candidates {
        wg.Add(1)
        go func(idx int, content string) {
            defer wg.Done()
            score, err := r.scoreWithLLM(ctx, query, content)
            if err != nil {
                score = 0.5 // 出错给中等分
            }
            results <- scoredDoc{idx, score}
        }(i, doc.Content)
    }

    wg.Wait()
    close(results)

    for result := range results {
        candidates[result.index].Score = result.score
        candidates[result.index].Source = "llm-rerank"
    }

    // 合并剩余文档
    result := append(candidates, documents[n:]...)

    sort.Slice(result, func(i, j int) bool {
        return result[i].Score > result[j].Score
    })

    return result, nil
}

func (r *Reranker) scoreWithLLM(ctx context.Context, query, document string) (float64, error) {
    prompt := fmt.Sprintf(`评估以下文档片段与查询的相关性,输出 0.0 到 1.0 之间的分数。

查询:%s

文档片段:
%s

请只输出一个浮点数,表示相关性分数。`, query, truncate(document, 500))

    resp, err := r.llm.Chat(ctx, []Message{{Role: "user", Content: prompt}})
    if err != nil {
        return 0.5, nil
    }

    var score float64
    fmt.Sscanf(resp, "%f", &score)
    return clamp(score, 0, 1), nil
}

func truncate(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) > maxLen {
        return string(runes[:maxLen]) + "..."
    }
    return s
}

func clamp(v, min, max float64) float64 {
    if v < min {
        return min
    }
    if v > max {
        return max
    }
    return v
}

六、Stage 4: Context Assembly

复制代码
// rag/context.go
package rag

import (
    "fmt"
    "sort"
    "strings"
)

// ContextAssembler 上下文组装器
type ContextAssembler struct {
    maxTokens    int
    reserveRatio float64 // 为 LLM 回复预留的比例
    enableOrder  bool    // 是否按相关性排序
    enableRefs   bool    // 是否添加引用标记
}

func NewContextAssembler(maxTokens int, reserveRatio float64) *ContextAssembler {
    return &ContextAssembler{
        maxTokens:    maxTokens,
        reserveRatio: reserveRatio,
        enableOrder:  true,
        enableRefs:   true,
    }
}

// Assemble 组装上下文
func (ca *ContextAssembler) Assemble(query string, documents []Document) (string, []Reference) {
    budget := int(float64(ca.maxTokens) * (1 - ca.reserveRatio))
    usedTokens := 0

    // 按相关性排序
    if ca.enableOrder {
        sorted := make([]Document, len(documents))
        copy(sorted, documents)
        sort.Slice(sorted, func(i, j int) bool {
            return sorted[i].Score > sorted[j].Score
        })
        documents = sorted
    }

    var sections []string
    var refs []Reference

    for i, doc := range documents {
        estimatedTokens := estimateTokens(doc.Content)

        // 如果超出预算,停止添加
        if usedTokens+estimatedTokens > budget {
            break
        }

        // 添加引用标记
        content := doc.Content
        if ca.enableRefs {
            refID := i + 1
            refs = append(refs, Reference{
                ID:       refID,
                Document: doc,
            })
            content = fmt.Sprintf("[%d] %s", refID, content)
        }

        sections = append(sections, content)
        usedTokens += estimatedTokens
    }

    context := strings.Join(sections, "\n\n---\n\n")
    return context, refs
}

// Reference 引用
type Reference struct {
    ID       int
    Document Document
}

// FormatReferences 格式化引用列表
func FormatReferences(refs []Reference) string {
    var sb strings.Builder
    sb.WriteString("\n\n**参考资料:**\n")
    for _, ref := range refs {
        title := "未知来源"
        if ref.Document.Metadata != nil {
            if t, ok := ref.Document.Metadata["title"]; ok {
                title = fmt.Sprintf("%v", t)
            }
        }
        sb.WriteString(fmt.Sprintf("[%d] %s (相关度: %.2f)\n", ref.ID, title, ref.Document.Score))
    }
    return sb.String()
}

func estimateTokens(text string) int {
    // 粗略估计:中英文混合约 1.5 字符/token
    return len([]rune(text)) / 2
}

七、Stage 5: Generator

复制代码
// rag/generator.go
package rag

import (
    "context"
    "fmt"
    "strings"
)

// GeneratorConfig 生成器配置
type GeneratorConfig struct {
    SystemPrompt  string
    Temperature   float32
    MaxTokens     int
    EnableCite    bool   // 启用引用
    CiteFormat    string // "markdown" 或 "plain"
}

// RAGGenerator RAG 生成器
type RAGGenerator struct {
    llm    LLMClient
    config GeneratorConfig
}

func NewRAGGenerator(llm LLMClient, config GeneratorConfig) *RAGGenerator {
    return &RAGGenerator{
        llm:    llm,
        config: config,
    }
}

// Generate 生成回答
func (g *RAGGenerator) Generate(ctx context.Context, query string, context string, refs []Reference) (string, error) {
    systemPrompt := g.buildSystemPrompt()
    userPrompt := g.buildUserPrompt(query, context, refs)

    messages := []Message{
        {Role: "system", Content: systemPrompt},
        {Role: "user", Content: userPrompt},
    }

    resp, err := g.llm.Chat(ctx, messages)
    if err != nil {
        return "", fmt.Errorf("generate: %w", err)
    }

    // 追加引用列表
    if g.config.EnableCite && len(refs) > 0 {
        resp += FormatReferences(refs)
    }

    return resp, nil
}

func (g *RAGGenerator) buildSystemPrompt() string {
    if g.config.SystemPrompt != "" {
        return g.config.SystemPrompt
    }

    citeRule := ""
    if g.config.EnableCite {
        citeRule = `
引用规则:
- 当你引用某个资料时,请在句末标注对应的引用编号,例如 [1]、[2]
- 如果一个观点来自多个资料,标注多个编号,例如 [1][2]
- 不要在句子开头标注引用`
    }

    return fmt.Sprintf(`你是一个专业的 AI 助手。请根据提供的参考资料回答问题。

核心原则:
1. 只使用参考资料中的信息回答问题
2. 如果参考资料不足以回答问题,请明确说明
3. 保持回答的准确性和客观性
4. 使用清晰、有条理的语言组织回答
5. 对于技术性问题,提供具体的细节和步骤%s`, citeRule)
}

func (g *RAGGenerator) buildUserPrompt(query string, context string, refs []Reference) string {
    var sb strings.Builder

    sb.WriteString("## 参考资料\n\n")
    sb.WriteString(context)
    sb.WriteString("\n\n")

    sb.WriteString("## 用户问题\n\n")
    sb.WriteString(query)

    return sb.String()
}

八、完整 Pipeline

复制代码
// rag/pipeline.go
package rag

import (
    "context"
    "fmt"
    "time"
)

// RAGPipeline 完整 RAG 流水线
type RAGPipeline struct {
    queryProcessor  *QueryProcessor
    retriever       *Retriever
    reranker        *Reranker
    contextAssembler *ContextAssembler
    generator       *RAGGenerator
    config          PipelineConfig
}

type PipelineConfig struct {
    MaxQueryTokens     int     `json:"max_query_tokens"`
    EnableRewrite      bool    `json:"enable_rewrite"`
    EnableHyDE         bool    `json:"enable_hyde"`
    EnableDecompose    bool    `json:"enable_decompose"`
    EnableRerank       bool    `json:"enable_rerank"`
    ContextMaxTokens   int     `json:"context_max_tokens"`
    ContextReserveRatio float64 `json:"context_reserve_ratio"`
    LogLevel           string  `json:"log_level"`
}

func DefaultPipelineConfig() PipelineConfig {
    return PipelineConfig{
        MaxQueryTokens:      512,
        EnableRewrite:       true,
        EnableHyDE:          true,
        EnableDecompose:     true,
        EnableRerank:        true,
        ContextMaxTokens:    4000,
        ContextReserveRatio: 0.3,
        LogLevel:            "info",
    }
}

// RAGResult RAG 结果
type RAGResult struct {
    Answer      string      `json:"answer"`
    References  []Reference `json:"references"`
    Metrics     RAGMetrics  `json:"metrics"`
}

type RAGMetrics struct {
    TotalDurationMs  int64   `json:"total_duration_ms"`
    QueryProcessMs   int64   `json:"query_process_ms"`
    RetrievalMs      int64   `json:"retrieval_ms"`
    RerankMs         int64   `json:"rerank_ms"`
    GenerationMs     int64   `json:"generation_ms"`
    RetrievedDocs    int     `json:"retrieved_docs"`
    UsedDocs         int     `json:"used_docs"`
    ContextTokens    int     `json:"context_tokens"`
}

// Run 执行完整 RAG 流水线
func (p *RAGPipeline) Run(ctx context.Context, query string) (*RAGResult, error) {
    start := time.Now()
    result := &RAGResult{
        Metrics: RAGMetrics{},
    }

    // Stage 1: Query Understanding
    t1 := time.Now()
    understanding, err := p.queryProcessor.Process(ctx, query)
    if err != nil {
        return nil, fmt.Errorf("query processing: %w", err)
    }
    result.Metrics.QueryProcessMs = time.Since(t1).Milliseconds()

    // 构建检索查询列表
    searchQueries := []string{query}
    if p.config.EnableRewrite {
        searchQueries = append(searchQueries, understanding.RewrittenQueries...)
    }
    if p.config.EnableHyDE && understanding.HypotheticalDoc != "" {
        searchQueries = append(searchQueries, understanding.HypotheticalDoc)
    }
    if p.config.EnableDecompose {
        searchQueries = append(searchQueries, understanding.DecomposedParts...)
    }

    // Stage 2: Retrieval
    t2 := time.Now()
    documents, err := p.retriever.Retrieve(ctx, searchQueries, understanding.Filters)
    if err != nil {
        return nil, fmt.Errorf("retrieval: %w", err)
    }
    result.Metrics.RetrievalMs = time.Since(t2).Milliseconds()
    result.Metrics.RetrievedDocs = len(documents)

    // Stage 3: Re-ranking
    t3 := time.Now()
    if p.config.EnableRerank && len(documents) > 0 {
        documents, err = p.reranker.Rerank(ctx, query, documents)
        if err != nil {
            // 重排序失败不影响后续流程
            fmt.Printf("rerank warning: %v\n", err)
        }
    }
    result.Metrics.RerankMs = time.Since(t3).Milliseconds()

    // Stage 4: Context Assembly
    context, refs := p.contextAssembler.Assemble(query, documents)
    result.References = refs
    result.Metrics.UsedDocs = len(refs)
    result.Metrics.ContextTokens = estimateTokens(context)

    // Stage 5: Generation
    t4 := time.Now()
    answer, err := p.generator.Generate(ctx, query, context, refs)
    if err != nil {
        return nil, fmt.Errorf("generation: %w", err)
    }
    result.Metrics.GenerationMs = time.Since(t4).Milliseconds()

    result.Answer = answer
    result.Metrics.TotalDurationMs = time.Since(start).Milliseconds()

    return result, nil
}

// NewRAGPipeline 创建 RAG 流水线
func NewRAGPipeline(
    llm LLMClient,
    embedder Embedder,
    vectorIndex VectorIndex,
    bm25Index BM25Index,
    crossEncoder CrossEncoder,
    config PipelineConfig,
) *RAGPipeline {
    retrieverConfig := DefaultRetrieverConfig()
    rerankerConfig := RerankerConfig{
        EnableCrossEncoder: crossEncoder != nil,
        EnableLLMRerank:    true,
        CrossEncoderTopK:   30,
        LLMRerankTopK:      10,
        FinalTopK:          10,
        RelevanceThreshold: 0.3,
    }
    generatorConfig := GeneratorConfig{
        Temperature: 0.3,
        MaxTokens:   2048,
        EnableCite:  true,
        CiteFormat:  "markdown",
    }

    return &RAGPipeline{
        queryProcessor:   NewQueryProcessor(llm),
        retriever:        NewRetriever(vectorIndex, bm25Index, embedder, retrieverConfig),
        reranker:         NewReranker(crossEncoder, llm, rerankerConfig),
        contextAssembler: NewContextAssembler(config.ContextMaxTokens, config.ContextReserveRatio),
        generator:        NewRAGGenerator(llm, generatorConfig),
        config:           config,
    }
}

// LLMClient LLM 客户端接口
type LLMClient interface {
    Chat(ctx context.Context, messages []Message) (string, error)
}

九、使用示例

复制代码
// example/rag_example.go
package main

import (
    "context"
    "fmt"
    "log"
    "rag"
)

func main() {
    ctx := context.Background()

    // 创建 RAG Pipeline
    pipeline := rag.NewRAGPipeline(
        &MyLLMClient{},      // LLM
        &MyEmbedder{},       // Embedder
        &MyVectorIndex{},    // 向量索引
        &MyBM25Index{},      // BM25 索引
        &MyCrossEncoder{},   // Cross-Encoder(可为 nil)
        rag.DefaultPipelineConfig(),
    )

    // 执行查询
    result, err := pipeline.Run(ctx, "如何使用 Go 实现一个高性能的 RAG 系统?")
    if err != nil {
        log.Fatal(err)
    }

    // 输出结果
    fmt.Printf("回答:\n%s\n\n", result.Answer)
    fmt.Printf("参考资料:%d 篇\n", len(result.References))
    fmt.Printf("耗时:%dms (查询=%dms, 检索=%dms, 重排=%dms, 生成=%dms)\n",
        result.Metrics.TotalDurationMs,
        result.Metrics.QueryProcessMs,
        result.Metrics.RetrievalMs,
        result.Metrics.RerankMs,
        result.Metrics.GenerationMs)
}

十、关键要点

  1. Query Understanding 是 RAG 的第一道关卡 --- 改写、分解、HyDE 三条路并行,显著提升召回率
  2. 多通道检索比单一向量检索强 30%+ --- 向量 + BM25 + 结构化过滤互补
  3. RRF 融合比加权融合更鲁棒 --- 不需要调权重,对不同规模的检索结果都稳定
  4. Cross-Encoder 重排序是性价比最高的精度提升手段 --- 只用它对 Top-30 打分,成本可控
  5. LLM 重排序只用于 Top-10 的精排 --- 不要用 LLM 打全量,太贵太慢
  6. Context Assembly 决定 LLM 能否正确利用信息 --- 裁剪、排序、引用标记缺一不可
  7. 端到端耗时控制在 3 秒以内 --- 超过 5 秒用户就能感知到延迟

十一、预告

第 9 讲预告:《大模型应用监控与可观测性:OpenTelemetry + Prometheus + Grafana 全链路追踪》

你将学到:

  • 为什么 LLM 应用的可观测性比传统服务更重要
  • OpenTelemetry 埋点:Span / Trace / Metric 在 RAG 链路中的应用
  • 自定义指标:Token 消耗、模型延迟、缓存命中率、重排收益
  • 告警规则:成本异常、延迟飙升、召回率为零
  • Go 实现的轻量级监控 SDK

🧰 开发之余的小工具推荐

处理 Base64、JSON 格式化、JWT 解析、Crontab 计算、PDF 合并压缩这些碎片需求,我常用一个纯前端本地工具箱:zz365.top。所有计算在浏览器完成,文件不上服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。

相关推荐
tachibana23 小时前
Go 语言 Map 的扩容过程是怎样的?
开发语言·后端·golang·go·语言
念何架构之路3 小时前
go-grpc服务端调用链
开发语言·后端·golang
泡海椒3 小时前
JQuickExcel 大文件排障与调优:从实际路径定位 OOM、临时盘和慢任务
开发语言·数据库·缓存·oracle·excel
羑悻3 小时前
atomic凭什么线程安全?无锁编程为何有时比锁还慢?C++高并发必懂的原子操作真相!
后端
SamDeepThinking3 小时前
synchronized 和 lock 有什么区别?
java·后端·面试
m0_587383003 小时前
无人自助拼豆系统的全栈开发方案
java·后端·struts·小程序·架构
雪碧透心凉_3 小时前
PythonWin32API窗口与鼠标操作
开发语言·python·计算机外设
人道领域3 小时前
【面试八股速通篇01】锁与并发工具
java·开发语言·面试·c#
kimnoic3 小时前
PHP 8.3 数据库备份失败怎么排查
开发语言·php