AI Agent 进阶:RAG 检索增强生成 —— 让 Agent 拥有"外部大脑"

🎯 当 Agent 需要回答它"不知道"的问题时,RAG 就是答案

前情回顾:上篇文章的局限

在上一篇文章《智能记忆管理与 Token 优化》中,我们成功实现了:

  • ✅ Token 智能优化,节省 85% 成本
  • ✅ 对话摘要,保持长期上下文
  • ✅ 双轨记忆系统(完整历史 + 优化消息)

但是,这个 Agent 仍有一个根本性的局限:

它只能回答"它知道的"问题

【tips:作者写这个 agent 是给卖场项目用的,所以举例用卖场项目来举例】

用户: 卖场模式新加坡用什么域名?
Agent: 抱歉,我没有这方面的具体信息。这可能是一个内部配置问题,
       建议您查阅相关文档或咨询技术团队。

问题在于:

  1. LLM 的知识是固定的:训练数据截止到某个时间点
  2. 无法访问私有数据:公司文档、内部知识库
  3. 无法实时更新:新信息需要重新训练模型

解决方案的演进

方案 说明 局限
微调模型 用私有数据重新训练 成本极高,更新困难
Prompt 塞入 把所有信息塞入提示词 Token 限制,成本高
RAG 检索相关信息再生成 ✅ 灵活、低成本、可更新

什么是 RAG?

RAG(Retrieval-Augmented Generation)= 检索增强生成

核心思想:先检索,后生成

┌─────────────────────────────────────────────────────────────┐
│                       RAG 工作流程                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   用户问题 ──▶ 向量检索 ──▶ 获取相关文档 ──▶ 组合提示 ──▶ LLM 生成  │
│      │           │              │              │           │
│      │           ▼              │              │           │
│      │      ┌─────────┐         │              │           │
│      │      │ 向量数据库 │◀────────┘              │           │
│      │      │ (知识库) │                        │           │
│      │      └─────────┘                        │           │
│      │                                         │           │
│      └─────────────────────────────────────────┘           │
│                                                             │
│   最终回复 ◀── LLM 基于上下文生成准确答案                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

RAG vs 传统方式

传统方式:

用户: 卖场新加坡域名是什么?
      ↓
LLM: (只能依赖训练数据,无法回答,由于卖场这是我自己的业务,接入的大模型肯定不知道是什么)

RAG 方式:

前置条件:把已有的关于卖场的文档导入到知识库中
      ↓
用户: 卖场新加坡域名是什么?
      ↓
检索系统: 从知识库找到相关文档片段
      ↓
组合提示: "根据以下信息回答:[新加坡域名:sg.store.com...]"
      ↓
LLM: 根据文档,新加坡卖场的域名是 sg.store.com

新的项目结构

ai-agent/
├── main.go              # 程序入口,支持命令行
├── agent/               # Agent 核心逻辑
│   ├── agent.go         # ChatAgent 实现
│   ├── cli.go           # 命令行交互
│   └── types.go         # 类型定义
├── config/              # 配置管理
│   └── config.go        # 统一配置加载
├── rag/                 # 🆕 RAG 核心模块
│   ├── client.go        # 向量数据库客户端
│   ├── collection.go    # 集合管理
│   ├── embedding.go     # 嵌入函数
│   ├── retriever.go     # 检索器
│   ├── import_markdown.go # 文档导入
│   └── types.go         # 类型定义
├── tools/               # 🆕 命令行工具
│   └── tools.go         # 导入、查询工具
└── chromadb_data/       # 向量数据库存储

RAG 核心组件详解

架构总览

┌─────────────────────────────────────────────────────────────┐
│                      RAG 模块架构                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────┐    ┌─────────────────┐    ┌─────────────┐ │
│  │   Client    │───▶│ CollectionMgr   │───▶│  Retriever  │ │
│  │  数据库客户端 │    │    集合管理器     │    │    检索器    │ │
│  └─────────────┘    └─────────────────┘    └─────────────┘ │
│         │                    │                     │        │
│         ▼                    ▼                     ▼        │
│  ┌─────────────┐    ┌─────────────────┐    ┌─────────────┐ │
│  │ ChromaDB    │    │ EmbeddingFunc   │    │  QueryResult│ │
│  │  向量存储    │    │    嵌入函数      │    │   查询结果   │ │
│  └─────────────┘    └─────────────────┘    └─────────────┘ │
│                                                             │
└─────────────────────────────────────────────────────────────┘

组件 1:Client(数据库客户端)

负责连接和管理向量数据库:

// rag/client.go

// Client 封装 ChromaDB 数据库
type Client struct {
    db          *chromem.DB
    storagePath string
}

// NewClient 创建数据库客户端
// 支持两种模式:持久化存储 和 内存存储
func NewClient(config ClientConfig) (*Client, error) {
    var db *chromem.DB
    var err error

    if config.StoragePath != "" {
        // 持久化存储:数据保存到磁盘
        if err := os.MkdirAll(config.StoragePath, 0755); err != nil {
            return nil, fmt.Errorf("failed to create storage directory: %w", err)
        }
        db, err = chromem.NewPersistentDB(config.StoragePath, false)
    } else {
        // 内存存储:程序退出后数据丢失
        db = chromem.NewDB()
    }

    return &Client{
        db:          db,
        storagePath: config.StoragePath,
    }, nil
}

为什么选择 chromem-go?

特性 chromem-go 其他方案
部署 嵌入式,无需额外服务 需要独立部署
依赖 纯 Go,零外部依赖 可能需要 Python 等
性能 本地查询,毫秒级 网络延迟
适用 中小规模(<100万文档) 大规模场景
简单使用,不搞复杂

组件 2:Embedding(嵌入函数)

将文本转换为向量,这是语义搜索的核心:

// rag/embedding.go

// tips:如果接入的模型中 chromem.EmbeddingFunc 不支持,那么可以自己实现 chromem.EmbeddingFunc 接口

// NewEmbeddingFunc 根据配置创建嵌入函数
// 
// 支持多种嵌入服务
func NewEmbeddingFunc(config EmbeddingConfig) chromem.EmbeddingFunc {
    switch config.Type {
    case "ollama":
        // 🌟 推荐:本地运行,免费,无需 API Key
        model := config.Model
        if model == "" {
            model = "nomic-embed-text" // 默认模型
        }
        return chromem.NewEmbeddingFuncOllama(model, config.BaseURL)

    case "openai":
        // OpenAI API,需要付费
        return chromem.NewEmbeddingFuncOpenAI(
            config.APIKey, 
            chromem.EmbeddingModelOpenAI(config.Model),
        )

    case "localai":
        // LocalAI,本地部署
        model := config.Model
        if model == "" {
            model = "bert-cpp-minilm-v6"
        }
        return chromem.NewEmbeddingFuncLocalAI(model)

    default:
        // 默认使用 OpenAI
        return chromem.NewEmbeddingFuncDefault()
    }
}

嵌入函数的作用图解:

文本输入: "卖场新加坡的域名是什么"
           ↓
      嵌入函数处理
           ↓
向量输出: [0.123, -0.456, 0.789, ..., 0.234]  (通常 384-1536 维)
           ↓
      存入向量数据库 / 用于相似度查询

组件 3:CollectionManager(集合管理器)

管理文档集合的 CRUD 操作:

// rag/collection.go

// CollectionManager 处理集合操作
type CollectionManager struct {
    client        *Client
    embeddingFunc chromem.EmbeddingFunc
}

// AddDocuments 向集合添加文档
func (cm *CollectionManager) AddDocuments(ctx context.Context, collectionName string, documents []Document) error {
    collection, err := cm.GetCollection(collectionName)
    if err != nil {
        return err
    }

    // 转换文档格式
    chromaDocs := make([]chromem.Document, len(documents))
    for i, doc := range documents {
        // 元数据类型转换
        metadata := make(map[string]string)
        for k, v := range doc.Metadata {
            metadata[k] = fmt.Sprintf("%v", v)
        }

        chromaDocs[i] = chromem.Document{
            ID:       doc.ID,
            Content:  doc.Content,
            Metadata: metadata,
        }
    }

    // 添加文档(会自动计算嵌入向量)
    err = collection.AddDocuments(ctx, chromaDocs, 1)
    return err
}

组件 4:Retriever(检索器)

RAG 的核心——根据查询找到相关文档:

// rag/retriever.go

// Retriever 处理 RAG 检索操作
type Retriever struct {
    collectionManager *CollectionManager
    collectionName    string
    defaultTopK       int
}

// Query 在集合中搜索相似文档
func (r *Retriever) Query(queryText string, topK int) ([]QueryResult, error) {
    collection, err := r.collectionManager.GetCollection(r.collectionName)
    if err != nil {
        return nil, err
    }

    // 检查文档数量,避免查询超过实际数量
    docCount := collection.Count()
    if docCount == 0 {
        return []QueryResult{}, nil
    }
    if topK > docCount {
        topK = docCount
    }

    // 执行向量相似度查询
    results, err := collection.Query(r.ctx, queryText, topK, nil, nil)
    if err != nil {
        return nil, err
    }

    // 转换结果格式
    queryResults := make([]QueryResult, 0, len(results))
    for _, result := range results {
        queryResults = append(queryResults, QueryResult{
            Document: Document{
                ID:       result.ID,
                Content:  result.Content,
                Metadata: convertMetadata(result.Metadata),
            },
            Score:    float64(result.Similarity),
            Distance: 1.0 - float64(result.Similarity),
        })
    }

    // 按相似度排序
    sort.Slice(queryResults, func(i, j int) bool {
        return queryResults[i].Score > queryResults[j].Score
    })

    return queryResults, nil
}

// GetRelevantContext 获取格式化的上下文,用于添加到提示中
func (r *Retriever) GetRelevantContext(queryText string, topK int) (string, error) {
    results, err := r.Query(queryText, topK)
    if err != nil {
        return "", err
    }

    if len(results) == 0 {
        return "", nil
    }

    // 格式化为可读的上下文
    context := "Relevant context from knowledge base:\n\n"
    for i, result := range results {
        context += fmt.Sprintf("[%d] %s\n", i+1, result.Document.Content)
        if i < len(results)-1 {
            context += "\n"
        }
    }

    return context, nil
}

检索流程图解:

用户问题: "新加坡域名是什么"
              │
              ▼
┌─────────────────────────────┐
│  1. 将问题转换为向量          │
│  Embedding("新加坡域名...")   │
│  → [0.12, -0.45, 0.78, ...]  │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│  2. 在向量数据库中搜索        │
│  找到最相似的 TopK 个文档     │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│  3. 返回相关文档              │
│  [Doc1: 新加坡配置...]        │
│  [Doc2: 域名说明...]          │
└─────────────────────────────┘

组件 5:Markdown 导入器

自动将 Markdown 文档切分并导入:

// rag/import_markdown.go

// ImportMarkdownFile 将 Markdown 文件导入到向量数据库
func ImportMarkdownFile(filePath, collectionName string, client *Client, embeddingFunc chromem.EmbeddingFunc) error {
    // 读取文件
    content, err := os.ReadFile(filePath)
    if err != nil {
        return err
    }

    // 按章节分割文档
    sections := splitMarkdownBySections(string(content))
    
    // 准备文档列表
    documents := make([]Document, 0, len(sections))
    for i, section := range sections {
        documents = append(documents, Document{
            ID:      fmt.Sprintf("%s-section-%d", getFileName(filePath), i+1),
            Content: section.Content,
            Metadata: map[string]interface{}{
                "title":    section.Title,
                "source":   filePath,
                "section":  i + 1,
                "category": "store-mode-docs",
            },
        })
    }

    // 添加到集合
    ctx := context.Background()
    return collectionManager.AddDocuments(ctx, collectionName, documents)
}

// splitMarkdownBySections 按标题(#)分割 Markdown
func splitMarkdownBySections(content string) []MarkdownSection {
    var sections []MarkdownSection
    lines := strings.Split(content, "\n")

    var currentSection MarkdownSection
    var currentLines []string

    for _, line := range lines {
        // 检测标题(以 # 开头)
        if strings.HasPrefix(strings.TrimSpace(line), "#") {
            // 保存上一个章节
            if currentSection.Title != "" {
                currentSection.Content = strings.Join(currentLines, "\n")
                sections = append(sections, currentSection)
            }

            // 开始新章节
            title := strings.TrimSpace(strings.TrimLeft(line, "# "))
            currentSection = MarkdownSection{Title: title}
            currentLines = []string{line}
        } else {
            currentLines = append(currentLines, line)
        }
    }

    // 保存最后一个章节
    if currentSection.Title != "" {
        currentSection.Content = strings.Join(currentLines, "\n")
        sections = append(sections, currentSection)
    }

    return sections
}

文档切分示例:

# 卖场配置指南          ← 章节 1
这是配置指南的介绍...

## 新加坡地区           ← 章节 2
域名: sg.store.com
配置说明...

## 马来西亚地区         ← 章节 3
域名: my.store.com

切分后:

Document 1: { title: "卖场配置指南", content: "这是配置指南的介绍..." }
Document 2: { title: "新加坡地区", content: "域名: sg.store.com\n配置说明..." }
Document 3: { title: "马来西亚地区", content: "域名: my.store.com" }

🔥 深入理解:文档切分策略

文档切分(Chunking)是 RAG 系统中最关键却最容易被忽视的环节。切分策略直接决定了检索的质量!

为什么需要切分文档?

向量检索的工作原理是:将文本转换为向量,然后计算向量之间的相似度。

问题是:如果把整篇文档作为一个向量,会发生什么?

原始文档 (5000字):
┌─────────────────────────────────────────────────┐
│ # 卖场配置指南                                   │
│ 本文档介绍各地区的卖场配置...                     │
│                                                 │
│ ## 新加坡地区                                    │
│ 域名: sg.store.com                              │
│ 配置说明...                                      │
│                                                 │
│ ## 马来西亚地区                                  │
│ 域名: my.store.com                              │
│ ...(还有 4000 字其他内容)                       │
└─────────────────────────────────────────────────┘
               ↓ 转换为向量
        [0.12, -0.34, 0.56, ...]

当用户问 “新加坡域名是什么” 时:

  • 问题向量会包含"新加坡"、"域名"的语义
  • 但文档向量被"稀释"了——5000 字的内容混在一起,"新加坡域名"只占很小一部分
  • 结果:相似度可能不高,检索效果差

所以必须切分! 将大文档切成小块,每块聚焦一个主题。

❌ 固定字符数切分的问题

最简单的想法:每 500 个字符切一块。

// 朴素的固定长度切分(反面教材)
func naiveChunk(content string, chunkSize int) []string {
    var chunks []string
    for i := 0; i < len(content); i += chunkSize {
        end := i + chunkSize
        if end > len(content) {
            end = len(content)
        }
        chunks = append(chunks, content[i:end])
    }
    return chunks
}

这种方法有严重问题:

问题 1:语义被截断
原文:
## 新加坡地区配置
新加坡的域名是 sg.store.com,服务器部署在新加坡数据中心。
配置文件路径为 /etc/store/sg.conf...

固定 500 字符切分后:
┌─────────────────────────────────────────────────┐
│ Chunk 1 (0-500):                                │
│ "...其他内容... ## 新加坡地区配置\n新加坡的域"   │  ← 标题和内容被切开!
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Chunk 2 (500-1000):                             │
│ "名是 sg.store.com,服务器部署在新加坡数据..."   │  ← 句子被从中间切断!
└─────────────────────────────────────────────────┘

用户问 “新加坡域名”,两个 chunk 都不完整,检索效果很差。

问题 2:上下文丢失
原文:
## 错误代码说明
以下是常见错误码:
- E001: 连接超时
- E002: 认证失败
- E003: 权限不足

固定切分后,可能变成:
Chunk 1: "## 错误代码说明\n以下是常见错误码:\n- E001: 连接超时\n- E00"
Chunk 2: "2: 认证失败\n- E003: 权限不足..."

当用户问 “E002 是什么错误”,Chunk 2 虽然包含答案,但丢失了标题上下文,LLM 可能不知道这是"错误代码"。

问题 3:中英文字符长度不一致
text1 := "Hello World"      // 11 字符,约 3 个 token
text2 := "你好世界"          // 4 字符,约 4 个 token

固定字符数对中文文档特别不友好:

  • 500 个英文字符 ≈ 125 tokens
  • 500 个中文字符 ≈ 333 tokens

同样的切分长度,中文块的语义容量是英文的 2.5 倍!

问题 4:无法利用文档结构

Markdown、HTML 等文档本身就有结构(标题、段落、列表)。固定切分完全忽略了这些结构信息。

✅ 语义切分:按章节标题切分

本文中,由于我自己的场景是把卖场相关的查询文档整理成了 markdown 格式的文档,因此这里采用语义切分策略:按 Markdown 标题(#)分割文档。【这是我自己在用的真实切分场景】

// splitMarkdownBySections 将 Markdown 内容按章节分割
func splitMarkdownBySections(content string) []MarkdownSection {
    var sections []MarkdownSection
    lines := strings.Split(content, "\n")

    var currentSection MarkdownSection
    var currentLines []string

    for _, line := range lines {
        // 🔑 关键:检测标题行(以 # 开头)
        if strings.HasPrefix(strings.TrimSpace(line), "#") {
            // 遇到新标题,保存上一个章节
            if currentSection.Title != "" {
                currentSection.Content = strings.TrimSpace(strings.Join(currentLines, "\n"))
                if currentSection.Content != "" {
                    sections = append(sections, currentSection)
                }
            }

            // 开始新章节,提取标题文本
            title := strings.TrimSpace(strings.TrimLeft(line, "# "))
            currentSection = MarkdownSection{
                Title: title,
            }
            currentLines = []string{line} // 包含标题行本身
        } else {
            // 非标题行,追加到当前章节
            currentLines = append(currentLines, line)
        }
    }

    // 保存最后一个章节
    if currentSection.Title != "" {
        currentSection.Content = strings.TrimSpace(strings.Join(currentLines, "\n"))
        if currentSection.Content != "" {
            sections = append(sections, currentSection)
        }
    }

    // 兜底:如果没有找到任何标题,整个文件作为一个文档
    if len(sections) == 0 {
        sections = append(sections, MarkdownSection{
            Title:   "完整文档",
            Content: content,
        })
    }

    return sections
}

语义切分的优势

优势 1:保持语义完整性
原文:
## 新加坡地区配置
新加坡的域名是 sg.store.com,服务器部署在新加坡数据中心。
配置文件路径为 /etc/store/sg.conf。

## 马来西亚地区配置
马来西亚的域名是 my.store.com...

语义切分后:
┌─────────────────────────────────────────────────┐
│ Chunk 1:                                        │
│ Title: "新加坡地区配置"                          │
│ Content: "## 新加坡地区配置                      │
│ 新加坡的域名是 sg.store.com,服务器部署在..."    │  ← 完整的一个主题!
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Chunk 2:                                        │
│ Title: "马来西亚地区配置"                        │
│ Content: "## 马来西亚地区配置                    │
│ 马来西亚的域名是 my.store.com..."               │  ← 另一个完整主题
└─────────────────────────────────────────────────┘

每个 chunk 都是一个自包含的知识单元,语义完整。

优势 2:标题提供元数据

我们不仅存储内容,还提取标题作为元数据:

documents = append(documents, Document{
    ID:      fmt.Sprintf("%s-section-%d", getFileName(filePath), i+1),
    Content: section.Content,
    Metadata: map[string]interface{}{
        "title":    section.Title,    // 🔑 标题作为元数据
        "source":   filePath,         // 来源文件
        "section":  i + 1,            // 章节序号
        "category": "store-mode-docs",
    },
})

这些元数据可以:

  • 用于过滤搜索:只搜索特定类别的文档
  • 展示给用户:告诉用户答案来自哪个章节
  • 辅助排序:按章节顺序展示结果
优势 3:向量语义更聚焦
固定切分的 Chunk 向量:
"...结尾 ## 新加坡配置 域名是..." → [混杂的语义向量]

语义切分的 Chunk 向量:
"## 新加坡配置 域名是 sg.store.com 服务器..." → [聚焦"新加坡配置"的向量]

当用户问 “新加坡域名”,语义切分的 chunk 向量与问题向量的相似度更高

切分策略对比

策略 语义完整性 上下文保留 实现复杂度 适用场景
固定字符数 ❌ 差 ❌ 差 ⭐ 简单 不推荐
固定 Token 数 ❌ 差 ❌ 差 ⭐⭐ 中等 不推荐
按标题切分 ✅ 好 ✅ 好 ⭐⭐ 中等 📄 结构化文档
递归切分 ✅ 好 ⭐ 一般 ⭐⭐⭐ 复杂 长文档
语义切分(AI) ✅✅ 最好 ✅✅ 最好 ⭐⭐⭐⭐ 复杂 高要求场景

切分流程图解

输入: Markdown 文件
         │
         ▼
┌─────────────────────────────────────────────────┐
│  1. 按行读取文件                                 │
│  lines := strings.Split(content, "\n")         │
└─────────────────────┬───────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────┐
│  2. 遍历每一行,检测标题                          │
│  if strings.HasPrefix(line, "#") {              │
│      // 遇到新标题                               │
│  }                                              │
└─────────────────────┬───────────────────────────┘
                      │
         ┌────────────┴────────────┐
         │                         │
    是标题行                    非标题行
         │                         │
         ▼                         ▼
┌─────────────────┐      ┌─────────────────┐
│ 保存上一个章节   │      │ 追加到当前章节   │
│ 开始新章节      │      │                 │
└─────────────────┘      └─────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────┐
│  3. 输出: 章节列表                               │
│  []MarkdownSection{                            │
│      {Title: "新加坡配置", Content: "..."},     │
│      {Title: "马来西亚配置", Content: "..."},   │
│  }                                              │
└─────────────────────────────────────────────────┘

实际效果对比

假设有一个 3000 字的文档,包含 6 个章节:

固定 500 字符切分:

  • 切成 6 个 chunks
  • 但边界随机,可能有 2-3 个 chunk 的语义被破坏
  • 检索准确率:~60%

按章节切分:

  • 切成 6 个 chunks(每个章节一个)
  • 每个 chunk 语义完整
  • 检索准确率:~90%+

进阶:处理过长章节

如果某个章节特别长(超过 2000 字),我们可以进一步切分:

// 可选:对过长的章节进行二次切分
const maxChunkSize = 2000

func splitLongSection(section MarkdownSection) []MarkdownSection {
    if len(section.Content) <= maxChunkSize {
        return []MarkdownSection{section}
    }
    
    // 按段落切分(空行分隔)
    paragraphs := strings.Split(section.Content, "\n\n")
    
    var result []MarkdownSection
    var currentContent strings.Builder
    
    for _, para := range paragraphs {
        if currentContent.Len()+len(para) > maxChunkSize && currentContent.Len() > 0 {
            result = append(result, MarkdownSection{
                Title:   section.Title + " (续)",
                Content: currentContent.String(),
            })
            currentContent.Reset()
        }
        currentContent.WriteString(para + "\n\n")
    }
    
    // 保存最后一部分
    if currentContent.Len() > 0 {
        result = append(result, MarkdownSection{
            Title:   section.Title,
            Content: currentContent.String(),
        })
    }
    
    return result
}

小结

没有什么完美的切割方法,这里也只是给出一个思路。真正实际应用中,还是需要根据文档的具体情况调整切分策略。


Agent 集成 RAG

新的 Agent 结构

// agent/agent.go

type ChatAgent struct {
    // ... 之前的字段 ...
    
    // 🆕 RAG 组件
    ragRetriever *rag.Retriever
    ragEnabled   bool
}

// NewChatAgentWithRAG 创建支持 RAG 的 Agent
func NewChatAgentWithRAG(apiKey, apiURL, model string, 
    maxTokens, keepRecentRounds int, config *config.Config) (*ChatAgent, error) {
    
    agent := NewChatAgentWithConfig(apiKey, apiURL, model, maxTokens, keepRecentRounds)

    if config.ChromaDB.RAGEnabled {
        // 初始化 ChromaDB 客户端
        chromaClient, err := rag.NewClient(rag.ClientConfig{
            StoragePath: config.ChromaDB.StoragePath,
        })
        if err != nil {
            return nil, err
        }

        // 创建嵌入函数
        embeddingFunc := rag.NewEmbeddingFunc(config.Embedding)

        // 创建集合管理器
        collectionManager := rag.NewCollectionManager(chromaClient, embeddingFunc)

        // 创建检索器
        retriever, err := rag.NewRetriever(collectionManager, rag.RetrieverConfig{
            CollectionName: config.ChromaDB.Collection,
            TopK:           config.ChromaDB.RAGTopK,
        })
        if err != nil {
            return nil, err
        }

        agent.ragRetriever = retriever
        agent.ragEnabled = true
    }

    return agent, nil
}

RAG 增强的消息发送

// SendMessage 发送消息(支持 RAG)
func (ca *ChatAgent) SendMessage(userMessage string) (string, error) {
    // 🆕 如果启用了 RAG,先检索相关上下文
    ragContext := ""
    if ca.ragEnabled && ca.ragRetriever != nil {
        context, err := ca.ragRetriever.GetRelevantContext(userMessage, 5)
        if err != nil {
            fmt.Printf("⚠️ Warning: RAG retrieval failed: %v\n", err)
        } else if context != "" {
            ragContext = context
        }
    }

    // 🆕 构建增强后的用户消息
    finalUserMessage := userMessage
    if ragContext != "" {
        // 将检索到的上下文添加到用户消息前面
        finalUserMessage = ragContext + "\n\nUser question: " + userMessage
    }

    // 继续原有的发送逻辑...
    ca.FullHistory = append(ca.FullHistory, Message{
        Role:    "user",
        Content: finalUserMessage,
    })
    
    // ... 后续处理 ...
}

RAG 增强流程:

用户输入: "新加坡域名是什么"
              │
              ▼
┌─────────────────────────────┐
│  RAG 检索相关文档            │
│  → 找到 3 个相关片段         │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│  组合增强消息                │
│  "Relevant context:         │
│   [1] 新加坡域名:sg.store... │
│   [2] 配置说明...            │
│                             │
│   User question: 新加坡域名" │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│  发送给 LLM                  │
│  LLM 基于上下文生成准确答案   │
└──────────────┬──────────────┘
               │
               ▼
返回: "根据文档,新加坡的域名是 sg.store.com"

运行展示

导入文档

调用 ImportMarkdown

# 将 Markdown 文件导入到向量数据库
go run . import docs/卖场配置.md store-mode-docs

# 输出
📄 正在导入文件: docs/卖场配置.md
📦 目标集合: store-mode-docs
⏳ 处理中...
成功导入 12 个章节到集合 'store-mode-docs'
✅ 成功将 docs/卖场配置.md 导入到集合 'store-mode-docs'

测试查询

调用 chromem 的 Query

# 测试向量检索
go run . query store-mode-docs "新加坡域名"

# 输出
🔍 查询: 新加坡域名
============================================================

[1] 相似度: 0.8932
标题: 新加坡地区配置
内容: 新加坡地区的域名是 sg.store.com,配置文件位于...

[2] 相似度: 0.7654
标题: 域名配置总览
内容: 各地区域名配置说明...

配置说明

完整配置示例

// config/config.go

type Config struct {
    API       APIConfig         // LLM API 配置
    ChromaDB  ChromaDBConfig    // 向量数据库配置
    Embedding rag.EmbeddingConfig // 嵌入函数配置
}

// 默认配置
config := &Config{
    API: APIConfig{
        APIURL:           "https://api.openai.com/v1/chat/completions",
        APIKey:           "your-api-key",
        Model:            "gpt-3.5-turbo",
        MaxTokens:        8000,
        KeepRecentRounds: 10,
    },
    ChromaDB: ChromaDBConfig{
        StoragePath: "./chromadb_data",  // 数据存储路径
        Collection:  "store-mode-docs",   // 默认集合名
        RAGEnabled:  true,                // 启用 RAG
        RAGTopK:     5,                   // 检索 Top 5 结果
    },
    Embedding: rag.EmbeddingConfig{
        Type:    "ollama",              // 使用 Ollama
        Model:   "nomic-embed-text",    // 嵌入模型
        BaseURL: "http://localhost:11434/api",
    },
}

嵌入服务选择

服务 优点 缺点 推荐场景
Ollama 免费、本地、快速 需要安装 🌟 开发/测试
OpenAI 高质量 付费、网络延迟 生产环境
LocalAI 本地、免费 需要部署 离线环境

使用 Ollama 的前置步骤

# 1. 安装 Ollama (访问 https://ollama.com)

# 2. 拉取嵌入模型
ollama pull nomic-embed-text

# 3. 验证服务运行
curl http://localhost:11434/api/tags

效果对比

无 RAG vs 有 RAG

无 RAG:

用户: 卖场新加坡的域名是什么?
Agent: 抱歉,我没有具体的内部配置信息。建议查阅相关文档。

有 RAG:

用户: 卖场新加坡的域名是什么?

[内部流程]
- 检索到相关文档: "新加坡地区域名: sg.store.com"
- 组合上下文发送给 LLM

Agent: 根据知识库文档,卖场新加坡的域名是 sg.store.com。

能力对比表

能力 无 RAG 有 RAG
回答通用问题
回答私有知识
引用来源
实时更新知识 ✅ (重新导入)
多语言文档

完整代码结构

┌────────────────────────────────────────────────────────────┐
│                    项目结构总览                             │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  main.go                    # 入口:命令分发                │
│  ├── import 命令            # 导入文档                     │
│  ├── query 命令             # 测试查询                     │
│  └── 默认                   # 启动聊天                     │
│                                                            │
│  agent/                     # Agent 核心                   │
│  ├── agent.go              # ChatAgent + RAG 集成          │
│  ├── cli.go                # 命令行交互                    │
│  └── types.go              # Message/Request/Response      │
│                                                            │
│  config/                    # 配置管理                     │
│  └── config.go             # 统一配置加载                  │
│                                                            │
│  rag/                       # 🆕 RAG 模块                  │
│  ├── client.go             # ChromaDB 客户端               │
│  ├── collection.go         # 集合管理                      │
│  ├── embedding.go          # 嵌入函数                      │
│  ├── retriever.go          # 向量检索                      │
│  ├── import_markdown.go    # 文档导入                      │
│  └── types.go              # Document/QueryResult          │
│                                                            │
│  tools/                     # 🆕 工具集                    │
│  └── tools.go              # ImportDoc/TestQuery           │
│                                                            │
└────────────────────────────────────────────────────────────┘

总结

本次更新解决了什么?

  1. 知识局限 → RAG 让 Agent 能访问私有知识库
  2. 无法更新 → 随时导入新文档,即时生效
  3. 单一文件 → 模块化重构,代码更清晰

RAG 的核心价值

RAG = 检索(找到相关信息)+ 增强(组合到提示中)+ 生成(LLM 输出答案)
  • 低成本:无需微调模型
  • 高灵活:随时更新知识库
  • 可追溯:知道答案来自哪个文档
  • 私有化:数据完全本地存储

Agent 进化路线

Level 1: 基础对话 ✅
    ↓
Level 2: 智能记忆 ✅
    ↓
Level 3: RAG 知识库 ✅ ← 当前
    ↓
Level 4: 工具调用(Function Calling)
    ↓
Level 5: 多 Agent 协作

下一步改进方向

  1. 混合检索:结合关键词 + 向量搜索
  2. 文档重排序:使用 Reranker 提升准确性
  3. 多模态 RAG:支持图片、表格
  4. 流式回答:边检索边回答

作者注:RAG 是当前 AI 应用最重要的技术之一。它让 LLM 从"只会背书"变成"能查资料",这是 Agent 从玩具走向生产的关键一步。通过本地向量数据库 + Ollama 嵌入,你可以零成本搭建一个私有知识助手!

Happy Coding! 🚀

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐