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 应用了!

相关推荐
guyoung1 小时前
BoxAgnts 运行时(1)——运行时工程决定 Agent 未来
agent·ai编程
传说之后1 小时前
Go Web 从标准库到Gin框架的源码级解析
后端
RainCity1 小时前
Java Swing 自定义组件库分享(十)
java·笔记·后端
程序员鱼皮1 小时前
我用 GitHub 仓库养 AI 龙虾,自动开发上线项目!保姆级教程
前端·人工智能·ai·程序员·github·编程·ai编程
智联视频超融合平台1 小时前
数字孪生+AR虚实叠加:让“看不见的电“在眼前实时预演
后端·ar·restful·虚拟现实
子安柠2 小时前
Go语言并发编程:协程与管道详解
开发语言·后端·golang
Java程序员-小白2 小时前
Spring Boot整合Sa-Token框架(入门篇)
java·spring boot·后端·sa-token
倔强的石头_2 小时前
我用 QClaw 搭了一个健身私教 Agent:从运动记录到饮食建议
ai编程
绝知此事2 小时前
ELK 从入门到精通:Spring Boot 实战三部曲(三)—— 高级应用与架构设计
spring boot·后端·elk