RAG = 向量搜索 + Prompt 拼接 + LLM 生成,让 LLM 基于你的私有数据回答问题
RAG 是 Retrieval-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调用
你已经掌握了:
- ✅ 向量嵌入、向量存储、相似度搜索、混合检索(已有)
- ✅ 文档分块(第四节)
- ✅ Prompt 模板(记忆系统已记录)
- ✅ LLM API 调用
恭喜!已经可以构建完整的 RAG 应用了!