从零开始手动实现 AI Agent(三)
目录
AI Agent 进阶:RAG 检索增强生成 —— 让 Agent 拥有"外部大脑"
🎯 当 Agent 需要回答它"不知道"的问题时,RAG 就是答案
前情回顾:上篇文章的局限
在上一篇文章《智能记忆管理与 Token 优化》中,我们成功实现了:
- ✅ Token 智能优化,节省 85% 成本
- ✅ 对话摘要,保持长期上下文
- ✅ 双轨记忆系统(完整历史 + 优化消息)
但是,这个 Agent 仍有一个根本性的局限:
它只能回答"它知道的"问题
【tips:作者写这个 agent 是给卖场项目用的,所以举例用卖场项目来举例】
用户: 卖场模式新加坡用什么域名?
Agent: 抱歉,我没有这方面的具体信息。这可能是一个内部配置问题,
建议您查阅相关文档或咨询技术团队。
问题在于:
- LLM 的知识是固定的:训练数据截止到某个时间点
- 无法访问私有数据:公司文档、内部知识库
- 无法实时更新:新信息需要重新训练模型
解决方案的演进
| 方案 | 说明 | 局限 |
|---|---|---|
| 微调模型 | 用私有数据重新训练 | 成本极高,更新困难 |
| 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 │
│ │
└────────────────────────────────────────────────────────────┘
总结
本次更新解决了什么?
- 知识局限 → RAG 让 Agent 能访问私有知识库
- 无法更新 → 随时导入新文档,即时生效
- 单一文件 → 模块化重构,代码更清晰
RAG 的核心价值
RAG = 检索(找到相关信息)+ 增强(组合到提示中)+ 生成(LLM 输出答案)
- 低成本:无需微调模型
- 高灵活:随时更新知识库
- 可追溯:知道答案来自哪个文档
- 私有化:数据完全本地存储
Agent 进化路线
Level 1: 基础对话 ✅
↓
Level 2: 智能记忆 ✅
↓
Level 3: RAG 知识库 ✅ ← 当前
↓
Level 4: 工具调用(Function Calling)
↓
Level 5: 多 Agent 协作
下一步改进方向
- 混合检索:结合关键词 + 向量搜索
- 文档重排序:使用 Reranker 提升准确性
- 多模态 RAG:支持图片、表格
- 流式回答:边检索边回答
作者注:RAG 是当前 AI 应用最重要的技术之一。它让 LLM 从"只会背书"变成"能查资料",这是 Agent 从玩具走向生产的关键一步。通过本地向量数据库 + Ollama 嵌入,你可以零成本搭建一个私有知识助手!
Happy Coding! 🚀
更多推荐


所有评论(0)