RAG 核心实战:检索增强生成

RAG = 向量搜索 + Prompt 拼接 + LLM 生成,让 LLM 基于你的私有数据回答问题

RAGRetrieval-Augmented Generation 的缩写,中文译为"检索增强生成"。

  • Retrieval(检索):从知识库中检索相关文档
  • Augmented(增强):用检索结果增强 LLM 的上下文
  • Generation(生成):LLM 基于增强后的上下文生成回答

核心思想:让 LLM 基于你的私有数据回答问题,而不是仅依赖训练时的知识。


一、本质理解

复制代码
传统搜索:
用户问题 → 关键词匹配 → 返回文档列表
RAG:      
用户问题 → 语义检索 → 拼接上下文 → LLM 生成回答

差别:传统搜索只能"找",RAG 能"理解 + 总结"

用后端架构类比

|---------------|-----------|----------------|
| RAG 组件 | 后端类比 | 职责 |
| Indexer | ETL 管道 | 数据分块、向量嵌入、写入索引 |
| Retriever | 查询引擎 | 向量搜索 + 关键词匹配 |
| PromptBuilder | SQL 构建器 | 拼接最终 Prompt |
| Generator | 外部 API 调用 | 调用 LLM 服务 |


二、核心流程

复制代码
【离线:索引阶段】
原始文档 → 分块 → Embedding → 向量存储

【在线:问答阶段】
用户问题 → Embedding → 向量检索 → Top-K 文档 → 拼接 Prompt → LLM → 回答

三、业务场景示例

场景 1:差标查询助手

业务背景:员工出差需要知道各部门、各职级的差旅标准(酒店价格、机票舱位等)

数据准备

复制代码
// 差标文档示例
docs := []Document{
    {
        ID: "差标-技术部-高级工程师",
        Content: `技术部高级工程师差旅标准:
- 机票:经济舱(单程不超过2000元)
- 酒店:一线城市500元/晚,二线城市400元/晚
- 高铁:一等座
- 餐饮补贴:100元/天
- 交通补贴:实报实销,上限50元/天`,
        Metadata: map[string]interface{}{
            "department": "技术部",
            "level":      "高级工程师",
            "type":       "差标",
        },
    },
    {
        ID: "差标-销售部-经理",
        Content: `销售部经理差旅标准:
- 机票:经济舱(单程不超过3000元)
- 酒店:一线城市800元/晚,二线城市600元/晚
- 高铁:一等座
- 餐饮补贴:150元/天
- 交通补贴:实报实销,上限100元/天
- 招待费:500元/次(需审批)`,
        Metadata: map[string]interface{}{
            "department": "销售部",
            "level":      "经理",
            "type":       "差标",
        },
    },
    {
        ID: "差标-管理层-总监",
        Content: `总监级差旅标准:
- 机票:公务舱(单程不超过5000元)
- 酒店:一线城市1000元/晚,二线城市800元/晚
- 高铁:商务座
- 餐饮补贴:200元/天
- 交通补贴:实报实销,上限150元/天
- 招待费:1000元/次`,
        Metadata: map[string]interface{}{
            "level": "总监",
            "type":  "差标",
        },
    },
}

问答效果

复制代码
用户: "我技术部高级工程师,去上海出差能住什么酒店?"

RAG 检索结果:
- 差标-技术部-高级工程师 (相似度 0.89)
- 差标-管理层-总监 (相似度 0.45)

LLM 回答:
根据您的职级(技术部高级工程师),差标规定:
- 上海属于一线城市,酒店标准为 500元/晚
- 机票可乘坐经济舱(单程不超过2000元)
- 餐饮补贴 100元/天

如需超出标准,请提前申请审批。

场景 2:机票退改签知识库

业务背景:客服需要快速回答各种机票退改签规则

数据准备

复制代码
// 机票规则文档
docs := []Document{
    {
        ID: "机票-国航-退票规则",
        Content: `国航退票规则:
- 起飞前24小时外:收取票价10%手续费
- 起飞前24小时内-2小时:收取票价20%手续费
- 起飞前2小时内:收取票价30%手续费
- 起飞后:不可退票
- 特价票:不可退票

退款时效:7-15个工作日`,
        Metadata: map[string]interface{}{
            "airline": "国航",
            "type":    "退票",
        },
    },
    {
        ID: "机票-东航-改签规则",
        Content: `东航改签规则:
- 同舱改签:
  - 起飞前4小时外:免费
  - 起飞前4小时内:收取票价5%
- 跨舱改签:需补差价 + 改签费
- 特价票:不可改签

改签时效:即时处理`,
        Metadata: map[string]interface{}{
            "airline": "东航",
            "type":    "改签",
        },
    },
    {
        ID: "机票-南航-特殊情况",
        Content: `南航特殊情况处理:
- 航班延误/取消:免费退改
- 疫情/自然灾害:免费退改
- 旅客因病:凭医院证明免费退票
- 公司原因:全额退款`,
        Metadata: map[string]interface{}{
            "airline": "南航",
            "type":    "特殊情况",
        },
    },
}

问答效果

复制代码
用户: "国航航班起飞前3小时想退票,怎么扣费?"

RAG 检索结果:
- 机票-国航-退票规则 (相似度 0.92)

LLM 回答:
根据国航退票规则,起飞前24小时内-2小时退票,收取票价20%手续费。

举例:票价1000元,手续费200元,实际退款800元。
退款将在7-15个工作日内到账。

建议:如果时间允许,可以考虑改签,费用可能更低。

场景 3:订单查询助手(混合检索)

业务背景:客服需要查询订单信息,用户描述模糊

混合检索优势

复制代码
用户: "查一下张三上个月去北京的机票订单"

纯向量检索问题:
- "张三" 是人名,语义相似度低
- "上个月" 是时间,语义匹配差

混合检索(向量 + 关键词):
┌─────────────────────┐   ┌─────────────────────┐
│  向量搜索            │   │  关键词搜索          │
│  "去北京的机票"      │   │  "张三"             │
│  order1 (0.85)      │   │  order2 (精确匹配)   │
│  order3 (0.72)      │   │  order5 (精确匹配)   │
└─────────────────────┘   └─────────────────────┘
          ↓                        ↓
┌─────────────────────────────────────────────────────┐
│  RRF 融合排序                                        │
│  order2: 语义 0.65 + 关键词精确 = 最终排名 1         │
│  order1: 语义 0.85 + 无关键词匹配 = 最终排名 2       │
└─────────────────────────────────────────────────────┘

代码实现

复制代码
// 混合检索
func (r *Retriever) HybridRetrieve(query string) ([]RetrievedDoc, error) {
    var wg sync.WaitGroup
    var vectorResults, keywordResults []SearchResult

    // 并行执行
    wg.Add(2)
    go func() {
        defer wg.Done()
        vectorResults, _ = r.vectorSearch(query)    // 语义检索
    }()
    go func() {
        defer wg.Done()
        keywordResults, _ = r.keywordSearch(query)  // 关键词检索
    }()
    wg.Wait()

    // RRF 融合
    return r.rrfFusion(vectorResults, keywordResults), nil
}

// RRF 融合算法
func (r *Retriever) rrfFusion(vector, keyword []SearchResult) []RetrievedDoc {
    scores := make(map[string]float32)
    k := float32(60) // RRF 参数

    // 向量检索得分
    for i, res := range vector {
        scores[res.ID] += 1 / (k + float32(i+1))
    }

    // 关键词检索得分
    for i, res := range keyword {
        scores[res.ID] += 1 / (k + float32(i+1))
    }

    // 排序返回
    return sortByScore(scores)
}

四、文档分块详解

4.1 为什么需要分块?

复制代码
问题:一篇 10000 字的文档,直接塞给 LLM?
1. Token 限制(GPT-4 约 8K-128K token)
2. 检索精度低(大海捞针)
3. 成本高(Token 计费)

解决:分块存储,检索时只取相关块

4.2 分块策略对比

|--------|----------|----------|--------|--------|
| 策略 | 后端类比 | 适用场景 | 优点 | 缺点 |
| 固定大小 | 分页查询 | 通用场景 | 简单可控 | 可能切断语义 |
| 按段落 | 按业务拆分 | 文章类 | 保持语义 | 块大小不均 |
| 按句子 | 精细索引 | 精确检索 | 精确度高 | 上下文少 |
| 按标题 | 按模块拆分 | 结构化文档 | 语义完整 | 需解析标题 |
| 语义分块 | 智能拆分 | 高精度场景 | 最佳语义 | 需要额外模型 |

4.3 业务场景分块建议

|----------|----------|--------------|-------------|----------|
| 业务场景 | 分块策略 | Chunk 大小 | Overlap | 原因 |
| 差标规则 | 按职级拆分 | 200-500字 | 0 | 每条规则独立完整 |
| 机票规则 | 按航司+类型拆分 | 300-600字 | 0 | 便于精确匹配 |
| 订单数据 | 按订单拆分 | 单条订单 | 0 | 元数据过滤更高效 |
| 长合同 | 按条款拆分 | 500-1000字 | 50字 | 保持条款完整性 |
| FAQ | 按问答对拆分 | 自然长度 | 0 | 一问一答完整 |
| 技术文档 | 按段落+标题 | 500-800字 | 100字 | 代码块完整 |

4.4 分块器代码实现

复制代码
// internal/rag/chunker.go

package rag

import (
    "regexp"
    "strings"
)

// Chunk 文档块
type Chunk struct {
    ID       string                 `json:"id"`
    Content  string                 `json:"content"`
    Metadata map[string]interface{} `json:"metadata"`
}

// Chunker 分块器
type Chunker struct {
    ChunkSize    int  // 块大小(字符数)
    ChunkOverlap int  // 重叠大小
}

func NewChunker(chunkSize, overlap int) *Chunker {
    return &Chunker{
        ChunkSize:    chunkSize,
        ChunkOverlap: overlap,
    }
}

// ========== 策略 1:固定大小分块 ==========

func (c *Chunker) Split(text string) []Chunk {
    var chunks []Chunk
    chunkIndex := 0

    for start := 0; start < len(text); {
        end := start + c.ChunkSize
        if end > len(text) {
            end = len(text)
        }

        chunk := Chunk{
            ID:      fmt.Sprintf("chunk-%d", chunkIndex),
            Content: strings.TrimSpace(text[start:end]),
        }
        chunks = append(chunks, chunk)
        chunkIndex++

        // 下一个块的起始位置(考虑重叠)
        start = end - c.ChunkOverlap
        if start < 0 {
            start = 0
        }
    }

    return chunks
}

// ========== 策略 2:按段落分块(推荐) ==========

func (c *Chunker) SplitByParagraph(text string) []Chunk {
    var chunks []Chunk
    paragraphs := strings.Split(text, "\n\n")

    currentChunk := ""
    chunkIndex := 0

    for _, para := range paragraphs {
        para = strings.TrimSpace(para)
        if para == "" {
            continue
        }

        // 如果加上当前段落不超过块大小,则追加
        if len(currentChunk)+len(para)+2 <= c.ChunkSize {
            if currentChunk != "" {
                currentChunk += "\n\n"
            }
            currentChunk += para
        } else {
            // 保存当前块
            if currentChunk != "" {
                chunks = append(chunks, Chunk{
                    ID:      fmt.Sprintf("chunk-%d", chunkIndex),
                    Content: currentChunk,
                })
                chunkIndex++
            }

            // 处理重叠
            if c.ChunkOverlap > 0 && len(currentChunk) > c.ChunkOverlap {
                currentChunk = currentChunk[len(currentChunk)-c.ChunkOverlap:] + "\n\n" + para
            } else {
                currentChunk = para
            }
        }
    }

    // 保存最后一块
    if currentChunk != "" {
        chunks = append(chunks, Chunk{
            ID:      fmt.Sprintf("chunk-%d", chunkIndex),
            Content: strings.TrimSpace(currentChunk),
        })
    }

    return chunks
}

// ========== 策略 3:按句子分块(精细检索) ==========

func (c *Chunker) SplitBySentence(text string) []Chunk {
    // 中文句号、英文句号、问号、感叹号
    re := regexp.MustCompile(`([。.!?!?]+)`)
    parts := re.Split(text, -1)
    delimiters := re.FindAllString(text, -1)

    var sentences []string
    for i, part := range parts {
        part = strings.TrimSpace(part)
        if part == "" {
            continue
        }
        if i < len(delimiters) {
            sentences = append(sentences, part+delimiters[i])
        } else {
            sentences = append(sentences, part)
        }
    }

    var chunks []Chunk
    currentChunk := ""
    chunkIndex := 0

    for _, sentence := range sentences {
        if len(currentChunk)+len(sentence) <= c.ChunkSize {
            currentChunk += sentence
        } else {
            if currentChunk != "" {
                chunks = append(chunks, Chunk{
                    ID:      fmt.Sprintf("chunk-%d", chunkIndex),
                    Content: currentChunk,
                })
                chunkIndex++
            }
            currentChunk = sentence
        }
    }

    if currentChunk != "" {
        chunks = append(chunks, Chunk{
            ID:      fmt.Sprintf("chunk-%d", chunkIndex),
            Content: currentChunk,
        })
    }

    return chunks
}

// ========== 策略 4:按标题分块(结构化文档) ==========

func (c *Chunker) SplitByHeading(text string) []Chunk {
    // 匹配 Markdown 标题:# ## ### 等
    re := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)

    matches := re.FindAllStringSubmatchIndex(text, -1)

    var chunks []Chunk

    if len(matches) == 0 {
        // 没有标题,按段落分
        return c.SplitByParagraph(text)
    }

    for i, match := range matches {
        start := match[0]
        var end int
        if i+1 < len(matches) {
            end = matches[i+1][0]
        } else {
            end = len(text)
        }

        content := strings.TrimSpace(text[start:end])
        if content != "" {
            // 提取标题作为元数据
            titleMatch := re.FindStringSubmatch(content)
            title := ""
            if len(titleMatch) > 2 {
                title = titleMatch[2]
            }

            chunks = append(chunks, Chunk{
                ID:      fmt.Sprintf("section-%d", i),
                Content: content,
                Metadata: map[string]interface{}{
                    "title": title,
                },
            })
        }
    }

    return chunks
}

// ========== 策略 5:业务规则分块(差标专用) ==========

func (c *Chunker) SplitByRule(text string, rulePattern string) []Chunk {
    // 按业务规则模式拆分
    // 例如:差标按 "部门 + 职级" 拆分
    re := regexp.MustCompile(rulePattern)

    matches := re.FindAllStringSubmatchIndex(text, -1)

    var chunks []Chunk

    if len(matches) == 0 {
        return c.SplitByParagraph(text)
    }

    for i, match := range matches {
        start := match[0]
        var end int
        if i+1 < len(matches) {
            end = matches[i+1][0]
        } else {
            end = len(text)
        }

        content := strings.TrimSpace(text[start:end])
        if content != "" {
            chunks = append(chunks, Chunk{
                ID:      fmt.Sprintf("rule-%d", i),
                Content: content,
            })
        }
    }

    return chunks
}

4.5 业务分块示例

差标分块

复制代码
// 原始长文档
fullDoc := `公司差旅管理制度

第一章 总则
本制度适用于公司全体员工...

第二章 技术部差标
技术部初级工程师:机票经济舱(1500元内),酒店300元/晚
技术部高级工程师:机票经济舱(2000元内),酒店500元/晚
技术部经理:机票经济舱(2500元内),酒店600元/晚

第三章 销售部差标
销售部专员:机票经济舱(2000元内),酒店400元/晚
销售部经理:机票经济舱(3000元内),酒店800元/晚

第四章 管理层差标
总监:公务舱(5000元内),酒店1000元/晚
...`

// 按标题分块(推荐)
chunker := NewChunker(1000, 0)
chunks := chunker.SplitByHeading(fullDoc)

// 结果:
// chunk-0: "第一章 总则\n本制度适用于..."
// chunk-1: "第二章 技术部差标\n技术部初级工程师..."
// chunk-2: "第三章 销售部差标\n销售部专员..."
// chunk-3: "第四章 管理层差标\n总监..."

机票规则分块

复制代码
// 按航司+规则类型分块
airlineRules := `国航退票规则:
- 起飞前24小时外:收取票价10%
- 起飞前24小时内-2小时:收取票价20%
- 起飞前2小时内:收取票价30%

国航改签规则:
- 同舱改签:免费(起飞前4小时外)
- 跨舱改签:补差价 + 5%改签费

东航退票规则:
- 起飞前48小时外:收取票价5%
- 起飞前48小时内:收取票价15%
...`

// 使用业务规则分块
chunker := NewChunker(500, 0)
// 匹配 "航司 + 规则类型" 开头的行
rulePattern := `(?m)^[国东南海西北]航[退改签]+规则:`
chunks := chunker.SplitByRule(airlineRules, rulePattern)

// 结果:
// rule-0: "国航退票规则:\n- 起飞前24小时外..."
// rule-1: "国航改签规则:\n- 同舱改签..."
// rule-2: "东航退票规则:\n- 起飞前48小时外..."

4.6 分块最佳实践

复制代码
// 推荐配置
type ChunkingConfig struct {
    // 通用文档
    Default struct {
        Strategy string // "paragraph"
        Size     int    // 500
        Overlap  int    // 50
    }

    // 差标规则
    TravelPolicy struct {
        Strategy string // "heading" 或 "rule"
        Size     int    // 500
        Overlap  int    // 0(规则独立,不需要重叠)
    }

    // 机票规则
    AirlineRule struct {
        Strategy string // "rule"
        Size     int    // 400
        Overlap  int    // 0
    }

    // 技术文档
    TechDoc struct {
        Strategy string // "heading"
        Size     int    // 800
        Overlap  int    // 100(代码块可能跨标题)
    }
}

// 使用示例
func getChunker(docType string) *Chunker {
    switch docType {
    case "travel-policy":
        return NewChunker(500, 0)   // 差标:无重叠
    case "airline-rule":
        return NewChunker(400, 0)   // 机票规则:无重叠
    case "tech-doc":
        return NewChunker(800, 100) // 技术文档:有重叠
    default:
        return NewChunker(500, 50)  // 默认
    }
}

4.7 分块注意事项

|--------|--------------------|
| 问题 | 解决方案 |
| 切断语义单元 | 使用按段落/标题分块,而非固定大小 |
| 丢失上下文 | 增加 Overlap(10-20%) |
| 块太小 | 合并相邻小块,设置最小块大小 |
| 块太大 | 拆分大块,设置最大块大小 |
| 代码块被切断 | 识别代码块边界,整体保留 |
| 表格被切断 | 识别表格结构,整体保留 |


五、元数据过滤

场景:先过滤再检索,提高精度

复制代码
// 用户: "技术部高级工程师差标"
query := "技术部高级工程师差标"

// 元数据过滤
filter := map[string]interface{}{
    "department": "技术部",
    "type":       "差标",
}

// 先过滤,再向量检索
results := retriever.SearchWithFilter(collection, query, filter, topK=3)

SQL 类比

复制代码
-- 传统查询
SELECT * FROM docs WHERE content LIKE '%技术部%' AND content LIKE '%高级%'

-- RAG 元数据过滤 + 向量检索
SELECT * FROM docs
WHERE metadata->>'department' = '技术部'
  AND metadata->>'type' = '差标'
ORDER BY cosine_similarity(query_vector, doc_vector) DESC
LIMIT 3

六、LLM 调用详解

6.1 为什么需要 LLM?

复制代码
传统搜索:返回原始文档列表
RAG:理解文档 + 生成自然语言回答

关键区别:LLM 能"理解 + 总结",而不是简单返回

6.2 LLM 提供商对比

|----------|------------|---------------------|----------|
| 提供商 | API 格式 | 模型 | 特点 |
| OpenAI | OpenAI 格式 | GPT-4o, GPT-4o-mini | 业界标准,贵 |
| 阿里云 | OpenAI 兼容 | Qwen 系列 | 国内访问快,便宜 |
| 智谱 | OpenAI 兼容 | GLM-4 | 中文能力强 |
| DeepSeek | OpenAI 兼容 | DeepSeek-V3 | 极便宜,能力强 |
| 本地模型 | OpenAI 兼容 | Ollama, vLLM | 私有部署,免费 |

核心要点:大多数国内厂商兼容 OpenAI API 格式,代码可复用!

6.3 Generator 代码实现

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

package rag

import (
    "bufio"
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
    "time"
)

// Generator LLM 生成器
type Generator struct {
    apiKey     string
    baseURL    string
    model      string
    httpClient *http.Client
}

// GeneratorConfig 配置
type GeneratorConfig struct {
    APIKey  string
    BaseURL string
    Model   string
    Timeout time.Duration
}

func NewGenerator(config GeneratorConfig) *Generator {
    if config.Timeout == 0 {
        config.Timeout = 60 * time.Second
    }

    return &Generator{
        apiKey:  config.APIKey,
        baseURL: config.BaseURL,
        model:   config.Model,
        httpClient: &http.Client{
            Timeout: config.Timeout,
        },
    }
}

// ========== 基础调用 ==========

// Generate 生成回答
func (g *Generator) Generate(systemPrompt, userPrompt string) (string, error) {
    return g.GenerateWithContext(context.Background(), systemPrompt, userPrompt)
}

// GenerateWithContext 带上下文的生成
func (g *Generator) GenerateWithContext(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
    reqBody := map[string]interface{}{
        "model": g.model,
        "messages": []map[string]string{
            {"role": "system", "content": systemPrompt},
            {"role": "user", "content": userPrompt},
        },
        "temperature": 0.7,
        "max_tokens":  2048,
    }

    body, _ := json.Marshal(reqBody)

    req, err := http.NewRequestWithContext(ctx, "POST", g.baseURL+"/chat/completions", bytes.NewReader(body))
    if err != nil {
        return "", err
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+g.apiKey)

    resp, err := g.httpClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        respBody, _ := io.ReadAll(resp.Body)
        return "", fmt.Errorf("LLM API error: %s", string(respBody))
    }

    var result struct {
        Choices []struct {
            Message struct {
                Content string `json:"content"`
            } `json:"message"`
        } `json:"choices"`
        Usage struct {
            PromptTokens     int `json:"prompt_tokens"`
            CompletionTokens int `json:"completion_tokens"`
            TotalTokens      int `json:"total_tokens"`
        } `json:"usage"`
    }

    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return "", err
    }

    if len(result.Choices) == 0 {
        return "", fmt.Errorf("no response generated")
    }

    return result.Choices[0].Message.Content, nil
}

// ========== 流式输出 ==========

// StreamChunk 流式输出块
type StreamChunk struct {
    Content string
    Done    bool
    Error   error
}

// GenerateStream 流式生成
func (g *Generator) GenerateStream(ctx context.Context, systemPrompt, userPrompt string) <-chan StreamChunk {
    ch := make(chan StreamChunk)

    go func() {
        defer close(ch)

        reqBody := map[string]interface{}{
            "model": g.model,
            "messages": []map[string]string{
                {"role": "system", "content": systemPrompt},
                {"role": "user", "content": userPrompt},
            },
            "temperature": 0.7,
            "max_tokens":  2048,
            "stream":      true,
        }

        body, _ := json.Marshal(reqBody)

        req, err := http.NewRequestWithContext(ctx, "POST", g.baseURL+"/chat/completions", bytes.NewReader(body))
        if err != nil {
            ch <- StreamChunk{Error: err}
            return
        }

        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Authorization", "Bearer "+g.apiKey)

        resp, err := g.httpClient.Do(req)
        if err != nil {
            ch <- StreamChunk{Error: err}
            return
        }
        defer resp.Body.Close()

        reader := bufio.NewReader(resp.Body)

        for {
            line, err := reader.ReadString('\n')
            if err != nil {
                if err == io.EOF {
                    ch <- StreamChunk{Done: true}
                } else {
                    ch <- StreamChunk{Error: err}
                }
                return
            }

            line = strings.TrimSpace(line)
            if !strings.HasPrefix(line, "data: ") {
                continue
            }

            data := strings.TrimPrefix(line, "data: ")
            if data == "[DONE]" {
                ch <- StreamChunk{Done: true}
                return
            }

            var chunk struct {
                Choices []struct {
                    Delta struct {
                        Content string `json:"content"`
                    } `json:"delta"`
                } `json:"choices"`
            }

            if json.Unmarshal([]byte(data), &chunk) == nil && len(chunk.Choices) > 0 {
                ch <- StreamChunk{Content: chunk.Choices[0].Delta.Content}
            }
        }
    }()

    return ch
}

// ========== 多模型适配 ==========

// 预设配置
var Presets = map[string]GeneratorConfig{
    "openai": {
        BaseURL: "https://api.openai.com/v1",
        Model:   "gpt-4o-mini",
    },
    "aliyun": {
        BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
        Model:   "qwen-turbo",
    },
    "deepseek": {
        BaseURL: "https://api.deepseek.com/v1",
        Model:   "deepseek-chat",
    },
    "zhipu": {
        BaseURL: "https://open.bigmodel.cn/api/paas/v4",
        Model:   "glm-4-flash",
    },
}

// NewGeneratorFromPreset 从预设创建
func NewGeneratorFromPreset(preset, apiKey string) *Generator {
    config, ok := Presets[preset]
    if !ok {
        config = Presets["openai"]
    }
    config.APIKey = apiKey
    return NewGenerator(config)
}

6.4 使用示例

基础调用

复制代码
// 创建生成器(以阿里云为例)
generator := rag.NewGeneratorFromPreset("aliyun", "sk-xxx")

// 生成回答
answer, err := generator.Generate(
    "你是一个差标查询助手,请根据参考资料回答问题。",
    "技术部高级工程师去上海出差能住什么酒店?",
)

流式输出

复制代码
// 流式生成
stream := generator.GenerateStream(ctx, systemPrompt, userPrompt)

for chunk := range stream {
    if chunk.Error != nil {
        log.Error(chunk.Error)
        break
    }
    if chunk.Done {
        fmt.Println("\n--- 完成 ---")
        break
    }
    fmt.Print(chunk.Content) // 实时输出
}

超时控制

复制代码
// 带超时的调用
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

answer, err := generator.GenerateWithContext(ctx, systemPrompt, userPrompt)
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        return "请求超时,请稍后重试"
    }
    return err.Error()
}

6.5 成本优化

|---------------|---------------------------------|-------------|
| 优化方法 | 说明 | 效果 |
| 选择便宜模型 | DeepSeek/GPT-4o-mini/Qwen-Turbo | 成本降 90% |
| 缓存热门问题 | 相同问题直接返回缓存 | 减少 API 调用 |
| 控制 max_tokens | 限制输出长度 | 降低 Token 消耗 |
| 流式输出 | 边生成边返回 | 提升体验,不降成本 |
| Prompt 精简 | 去掉无用的上下文 | 降低输入 Token |

成本对比(100万 Token):

|-------------|----------|----------|-----------|
| 模型 | 输入价格 | 输出价格 | 总成本估算 |
| GPT-4o | 2.5/1M | 10/1M | ~12.5 | | GPT-4o-mini | 0.15/1M | 0.6/1M | \~0.75 |
| Qwen-Turbo | ¥0.3/1M | ¥0.6/1M | ~¥0.9 |
| DeepSeek-V3 | ¥1/1M | ¥2/1M | ~¥3 |
| GLM-4-Flash | 免费 | 免费 | ¥0 |

6.6 错误处理

复制代码
// 错误处理最佳实践
func (s *Service) Ask(question string) (*Answer, error) {
    // 1. 重试机制
    var answer string
    var err error
    for i := 0; i < 3; i++ {
        answer, err = s.generator.Generate(s.prompt.SystemPrompt, prompt)
        if err == nil {
            break
        }

        // 判断错误类型
        if isRateLimitError(err) {
            time.Sleep(time.Duration(i+1) * time.Second) // 指数退避
            continue
        }
        if isInvalidRequestError(err) {
            return nil, fmt.Errorf("请求参数错误: %w", err) // 不重试
        }
    }

    if err != nil {
        return nil, fmt.Errorf("LLM 调用失败: %w", err)
    }

    return &Answer{Question: question, Answer: answer}, nil
}

func isRateLimitError(err error) bool {
    return strings.Contains(err.Error(), "rate limit") ||
           strings.Contains(err.Error(), "429")
}

func isInvalidRequestError(err error) bool {
    return strings.Contains(err.Error(), "400") ||
           strings.Contains(err.Error(), "invalid")
}

6.7 多模型切换

复制代码
// 多模型配置
type MultiGenerator struct {
    primary   *Generator
    fallbacks []*Generator
}

func (m *MultiGenerator) Generate(system, user string) (string, error) {
    // 尝试主模型
    answer, err := m.primary.Generate(system, user)
    if err == nil {
        return answer, nil
    }

    // 主模型失败,尝试备选模型
    for _, fallback := range m.fallbacks {
        answer, err = fallback.Generate(system, user)
        if err == nil {
            return answer, nil
        }
    }

    return "", fmt.Errorf("所有模型都失败")
}

// 使用示例
multiGen := &MultiGenerator{
    primary:   NewGeneratorFromPreset("aliyun", apiKey1),
    fallbacks: []*Generator{
        NewGeneratorFromPreset("deepseek", apiKey2),
        NewGeneratorFromPreset("zhipu", apiKey3),
    },
}

七、实战代码框架

复制代码
// internal/rag/service.go

type Service struct {
    vectorSvc  *vector.Service    // 你已有的向量服务
    chunker    *Chunker           // 新增:分块器
    prompt     *PromptBuilder     // 新增:Prompt构建
    generator  *Generator         // 新增:LLM调用
}

// 核心问答方法
func (s *Service) Ask(question string, filter map[string]interface{}) (*Answer, error) {
    // 1. 混合检索(向量 + 关键词)
    docs, err := s.retriever.HybridRetrieve(s.collection, question, filter)
    if err != nil {
        return nil, err
    }

    // 2. 构建 Prompt
    prompt := s.prompt.Build(question, docs)

    // 3. LLM 生成
    answer, err := s.generator.Generate(s.prompt.SystemPrompt, prompt)
    if err != nil {
        return nil, err
    }

    // 4. 返回结果(含来源)
    return &Answer{
        Question: question,
        Answer:   answer,
        Sources:  docs,  // 引用来源
    }, nil
}

八、优化要点

|---------|---------------------------|----------|
| 优化项 | 方法 | 效果 |
| 分块大小 | 500-1000字符,overlap 10-20% | 平衡精度和上下文 |
| 混合检索 | 向量 + 关键词 + RRF融合 | 语义+精确兼顾 |
| 元数据过滤 | 先过滤再检索 | 提高相关性 |
| Top-K | 初始取20,Rerank后取5 | 精排提高质量 |
| 缓存 | 热门问题向量缓存 | 减少API调用 |
| 引用来源 | 返回检索到的文档 | 可追溯、可信 |


九、一句话总结

RAG = 向量搜索 + Prompt拼接 + LLM调用

你已经掌握了:

  1. ✅ 向量嵌入、向量存储、相似度搜索、混合检索(已有)
  2. ✅ 文档分块(第四节)
  3. ✅ Prompt 模板(记忆系统已记录)
  4. ✅ LLM API 调用

恭喜!已经可以构建完整的 RAG 应用了!

相关推荐
神奇小汤圆4 分钟前
Loop Runtime 架构拆解:别再手动催 Agent,先把工程闭环跑起来
后端
浩风祭月18 分钟前
Cursor + Claude Code实战:从需求分析到测试提交的完整流程
ai编程·claude·cursor
程序员cxuan19 分钟前
幽默,一个 Github 名字叫“马尾辫”,但是他给你省了 80% 的 token
人工智能·后端·程序员
程序员晓琪25 分钟前
约定大于配置:基于 Java 包名自动生成 API 版本路由的最佳实践
java·spring boot·后端
didadida26234 分钟前
Isshin AI Agent:LLM 工具路由架构
ai编程
银卡35 分钟前
RAG Embedding 模型选型
后端
用户5598224812236 分钟前
Claude Code + DeepSeek V4 Pro 说"不行"时,别信
后端
孟健40 分钟前
GLM-5.2能打了,但还不能替代GPT
ai编程
leeyi1 小时前
Manus Agent:一个全能 AI,和一支研究团队
后端·aigc·agent
东坡白菜1 小时前
破局全栈:前端开发的Java入门实战记录—JPA(2)
java·后端