从零实现一个完整 RAG 系统:基于 Eino 框架的检索增强生成实战
什么是 RAG?为什么需要它?
想象一下,你问 ChatGPT:"2024 年巴黎奥运会中国代表团拿了多少金牌?" 如果 ChatGPT 的训练数据截止到 2023 年,它只能猜一个大概数字------甚至可能一本正经地编造一个错误答案。这就是大语言模型的幻觉问题:模型不知道自己不知道什么。
RAG(Retrieval-Augmented Generation,检索增强生成) 就是为了解决这个问题而诞生的技术方案。它的核心思想很简单:
bash
用户提问 → 先去"知识库"里找相关资料 → 把资料塞给大模型 → 模型基于真实资料回答
就像一场开卷考试:学生可以翻阅课本和笔记再作答,答案自然更准确、更有据可查。
本文将手把手带你用 Eino 框架(字节跳动开源的 AI 应用开发框架)搭建一套完整的 RAG 系统,涵盖文档加载、切分、向量化、存储、检索全链路。
整体架构一览
我们的 RAG 系统由 6 个核心环节串联而成:
bash
原始文档 (.md)
↓
[1] 文档加载 (MdOpenFs) --- 读取文件到内存
↓
[2] 文档切分 (MdSplitter) --- 按 Markdown 标题拆成小段
↓
[3] 向量化 (ArkEmbedder) --- 每段文字变成一串向量
↓
[4] 存入向量库 (MilvusIndexer) --- 向量 + 原文一起写入 Milvus
↓
[5] 检索 (MilvusRetriever) --- 用户提问 → 向量匹配 → 取回最相关段落
↓
[6] 生成回答 (ChatModel) --- 把检索到的资料喂给 LLM,生成最终回答
下面逐一拆解每个环节的实现细节和技术要点。
第一步:环境准备
1.1 启动 Milvus 向量数据库
RAG 系统需要一个地方存储文档的向量表示。我们选用 Milvus------目前最流行的开源向量数据库之一。
项目提供了 Docker Compose 编排配置,一条命令启动全部依赖:
yml
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.18
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
ports:
- "9001:9001"
- "9000:9000"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.5.10
command: ["milvus", "run", "standalone"]
security_opt:
- seccomp:unconfined
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"
# 这是新增的 Attu 服务哦!
attu:
container_name: milvus-attu
image: zilliz/attu:v2.5
ports:
- "8000:3000" # 把本地的 8000 端口映射到容器的 3000 端口 (Attu 默认端口)
environment:
# MILVUS_URL 指向 Docker 网络里的 Milvus standalone 服务
MILVUS_URL: standalone:19530
depends_on:
- standalone # 确保 Milvus 启动后再启动 Attu
networks:
default:
name: milvus
这会启动 4 个服务:
| 服务 | 作用 | 端口 |
|---|---|---|
| etcd | Milvus 的元数据/协调服务 | 内部使用 |
| MinIO | 对象存储,持久化向量数据 | 9000 / 9001(控制台) |
| Milvus standalone | 向量数据库本体 | 19530 |
| Attu | Milvus 可视化管理界面 | 8000 |
启动后可以通过 http://localhost:8000 打开 Attu 界面,直观查看集合和数据。
1.2 配置 API 凭证
在 .env 文件中配置火山引擎 ARK 的凭证:
env
# ARK 火山引擎(用于嵌入模型 + 对话模型)
ARK_API_KEY=你的API密钥
MODEL=Doubao-Seed-1.8 # LLM 大模型
EMBEDDER=doubao-embedding-vision-251215 # 嵌入模型(多模态)
为什么选
doubao-embedding-vision-251215?这是一个多模态嵌入模型,输出二进制向量,配合汉明距离计算速度极快,非常适合大规模文档检索场景。后面会详细解释。
第二步:连接 Milvus
一切从数据库连接开始。我们在 MilvusCli.go 中封装了客户端初始化:
go
package RAG
import (
"context"
"log"
cli "github.com/milvus-io/milvus-sdk-go/v2/client"
)
var MilvusCli cli.Client // 全局共享客户端实例
func NewMilvusCliInit(ctx context.Context) {
client, err := cli.NewClient(ctx, cli.Config{
Address: "localhost:19530", // Milvus 默认端口
})
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
MilvusCli = client
}
用 init() 或显式函数调用一次即可,后续所有 Indexer 和 Retriever 共享这个连接。
第三步:定义 Collection Schema(数据表结构)
在往 Milvus 存数据前,需要先定义"表长什么样"。这和 MySQL 里建表 CREATE TABLE 是同一个概念:
go
var fields = []*entity.Field{
{Name: "id", DataType: entity.FieldTypeVarChar,
TypeParams: map[string]string{"max_length": "255"}, PrimaryKey: true},
// ↑ 主键,唯一标识每条记录
{Name: "vector", DataType: entity.FieldTypeBinaryVector,
TypeParams: map[string]string{"dim": "16384"}},
// ↑ 二进制向量列!dim 单位是比特(bit),16384 bit = 2048 byte = 2048 维
{Name: "content", DataType: entity.FieldTypeVarChar,
TypeParams: map[string]string{"max_length": "8192"}},
// ↑ 存储原文内容
{Name: "metadata", DataType: entity.FieldTypeJSON},
// ↑ 存储元数据(来源标签等),JSON 格式
}
关键设计决策:为什么用 BinaryVector 而不是 FloatVector?
这是整个系统最重要的技术选择之一:
| 类型 | 存储格式 | 相似度算法 | 适用场景 | 我们的模型输出 |
|---|---|---|---|---|
| FloatVector | 每维 4 字节(float32) | 余弦/L2 距离 | OpenAI ada 等标准浮点模型 | 不适用 |
| BinaryVector | 每维 1 bit (0或1) | 汉明距离(HAMMING) | 二进制嵌入模型 | []uint8 (0~255) |
我们用的 doubao-embedding-vision-251215 输出的是 2048 个 0~255 的整数,天然适合 BinaryVector 存储。
关于 dim: "16384" 的解释:Milvus 中 BinaryVector 的 dim 单位是比特 ,不是字节。模型输出 2048 个值 × 8 bit/byte = 16384 bits。如果填错成 2048,会导致维度不匹配报错。
数据行结构(binaryRow)
为了正确地将 Go 数据映射到 Milvus 列,我们需要一个带 milvus: tag 的结构体:
go
type binaryRow struct {
ID string `json:"id" milvus:"name:id"`
Content string `json:"content" milvus:"name:content"`
Vector []byte `json:"vector" milvus:"name:vector"` // BinaryVector 用 []byte
Metadata []byte `json:"metadata" milvus:"name:metadata"` // JSON 序列化后也是 []byte
}
踩坑经验 :如果用
map[string]interface{}返回行数据,Milvus SDK 会把[]byte错误地展开为多行,导致num_rows mismatch错误。必须使用 struct + tag + 指针的方式。
第四步:文档加载与智能切分
4.1 加载文档
go
func MdOpenFs(ctx context.Context, filePath string, splitter document.Transformer) ([]*schema.Document, error) {
bs, err := os.ReadFile(filePath) // 读文件为字节数组
return MdSplitter(ctx, bs, splitter) // 交给切分器处理
}
支持任意 Markdown 文件。我们的示例文档是一篇关于 CS:GO 解说"玩机器"的长文,包含人物简介、解说特色、名场面梗等多个章节。
4.2 按 Markdown 标题切分
一篇几万字的长文不能直接作为一个整体处理------既浪费 Token,也会导致检索精度下降。我们需要把它切成语义独立的小段:
go
func NewMdSplitter(ctx context.Context) (document.Transformer, error) {
splitter, err := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
Headers: map[string]string{
"#": "h1",
"##": "h2",
"###": "h3", // 按三级标题切分
},
TrimHeaders: false, // 保留标题本身作为片段开头
})
return splitter, nil
}
切分逻辑如下:
go
func MdSplitter(ctx context.Context, bs []byte, splitter document.Transformer) ([]*schema.Document, error) {
docs := []*schema.Document{{ID: uuid.New().String(), Content: string(bs)}}
results, _ := splitter.Transform(ctx, docs) // 执行切分
// 切分后为每个片段分配唯一 ID
for i := range results {
results[i].ID = uuid.New().String()
}
return results, nil
}
以示例文档为例,切分结果大致是:
ini
[片段0] ## 🎙️ 一、人物简介 (本名、早期经历、回国发展...)
[片段1] ## 二、解说特色与口头禅 (中西合璧风格、成语乱用...)
[片段2] ## 三、代表性梗/名场面 (豌豆射手、咬人猫...)
[片段3] ## 四、弹幕社区梗文化 (刘氏家族、世界观弹幕...)
[片段4] ## 五、生活与情感片段 (鸽子事件、心理健康...)
[片段5] ## 六、综述 (表格汇总)
[片段6] ### 👉 总结 (总结性描述+参考链接)
注意:切分后的每个片段都会被赋予唯一 UUID 作为主键。如果不做这一步,所有子文档会继承父文档的相同 ID,存入 Milvus 时因主键冲突互相覆盖,最终只剩最后一段。
第五步:向量化 ------ 文字变数字
这是 RAG 系统的灵魂步骤。计算机无法直接理解"刘氏家族是什么意思",但它能高效比较两个向量的相似度。
5.1 初始化 ARK 多模态嵌入模型
go
func NewArkEmbedder(ctx context.Context, timeout time.Duration) (*ark.Embedder, error) {
apiType := ark.APITypeMultiModal // ← 关键!多模态模型必须指定
embedder, err := ark.NewEmbedder(ctx, &ark.EmbeddingConfig{
APIKey: os.Getenv("ARK_API_KEY"),
Model: os.Getenv("EMBEDDER"), // doubao-embedding-vision-251215
Timeout: &timeout,
APIType: &apiType,
})
return embedder, nil
}
为什么要设 APITypeMultiModal?
ARK 平台的嵌入 API 有两种端点:
| APIType | 实际请求路径 | 适用模型类型 |
|---|---|---|
Text |
/api/v3/embeddings |
纯文本嵌入模型 |
MultiModal |
/api/v3/embeddings/multimodal |
多模态嵌入模型(vision 系列) |
doubao-embedding-vision-* 只部署在 multimodal 端点上。不设置会默认走 text 路径,返回 400 错误:does not support this api。
5.2 向量化过程
当 Embedder 收到文本时,内部流程是这样的:
ini
输入文本: "刘氏家族:粉丝根据他对职业选手Device的喜爱..."
↓
调用 ARK doubao-embedding-vision-251215 API
↓
返回: [23, 187, 45, 201, 0, 78, ...] 共 2048 个整数(每个 0~255)
↓
通过 DocumentConverter 转换为 []byte:
[0x17, 0xBB, 0x2D, 0xC9, 0x00, 0x4E, ...] 共 2048 字节
↓
存入 Milvus BinaryVector 字段(dim=16384 bits)
5.3 自定义 DocumentConverter
eino-ext 的 milvus indexer 组件提供了 DocumentConverter 钩子,让我们控制如何将模型输出转换为 Milvus 可接受的格式:
go
func binaryDocumentConverter(_ context.Context, docs []*schema.Document,
vectors [][]float64) ([]interface{}, error) {
rows := make([]interface{}, 0, len(docs))
for i, doc := range docs {
metadata, _ := json.Marshal(doc.MetaData)
// 核心:每个 float64(0~255) → 1 byte
byteVec := make([]byte, len(vectors[i]))
for j, v := range vectors[i] {
byteVec[j] = byte(v)
}
rows = append(rows, &binaryRow{ // 必须是指针!
ID: doc.ID,
Content: doc.Content,
Vector: byteVec, // []byte → BinaryVector
Metadata: metadata,
})
}
return rows, nil
}
这里有个容易踩的坑:retriever 组件也有默认的 VectorConverter ,它的转换方式是 float32 → 4 字节/值(为浮点模型设计的)。如果我们不在 retriever 端也配一个对称的自定义 converter,检索时会发送错误大小的向量导致维度不匹配。这也是为什么我们的 Retriever 也配了自定义 binaryVectorConverter。
第六步:存入向量库
go
func ArkInderer(ctx context.Context, timeout time.Duration) (*milvus.Indexer, error) {
// 清理旧集合(开发阶段方便反复调试)
if has, _ := MilvusCli.HasCollection(ctx, collection); has {
_ = MilvusCli.DropCollection(ctx, collection)
}
embedder, _ := NewArkEmbedder(ctx, timeout)
indexer, _ := milvus.NewIndexer(ctx, &milvus.IndexerConfig{
Client: MilvusCli,
Collection: collection, // 集合名: "AwesomeEino"
Fields: fields, // 第三步定义的 Schema
Embedding: embedder, // 第五步的嵌入器
MetricType: milvus.MetricType(entity.HAMMING), // 汉明距离
DocumentConverter: binaryDocumentConverter, // 自定义转换器
})
return indexer, nil
}
调用方只需一行:
go
indexer.Store(ctx, resDoc) // 将切分后的文档全部存入
Store 内部自动完成:对每段文本调 Embedder → 向量化 → 通过 Converter 格式化 → 写入 Milvus。
此时可以在 Attu 界面(http://localhost:8000)中看到 AwesomeEino 集合已包含 7 条记录。
第七步:检索相关文档
有了知识库,现在可以回答问题了。假设用户问:"刘氏家族是什么?"
7.1 初始化 Retriever
go
func Retriever(ctx context.Context, embedder *ark.Embedder) (*milvus.Retriever, error) {
retriever, _ := milvus.NewRetriever(ctx, &milvus.RetrieverConfig{
Client: MilvusCli,
Collection: "AwesomeEino",
VectorField: "vector", // 在哪个字段上搜索
OutputFields: []string{"id", "content", "metadata"}, // 返回哪些列
TopK: 2, // 返回最相关的 2 条
Embedding: embedder, // 复用同一个嵌入器!
MetricType: entity.HAMMING,
VectorConverter: binaryVectorConverter, // 与写入端对称的转换器
})
return retriever, nil
}
关键点 :检索时的 Embedder 必须和写入时用的是同一个模型。否则查询向量和库中向量的空间不一致,搜出来的结果毫无意义。
7.2 自定义 VectorConverter(与写入端对称)
go
func binaryVectorConverter(_ context.Context, vectors [][]float64) ([]entity.Vector, error) {
vecs := make([]entity.Vector, 0, len(vectors))
for _, vec := range vectors {
b := make([]byte, len(vec))
for i, v := range vec {
b[i] = byte(v) // 同样是 1 字节/值,与写入端一致
}
vecs = append(vecs, entity.BinaryVector(b))
}
return vecs, nil
}
7.3 执行检索
go
results, _ := retriever.Retrieve(ctx, "刘氏家族")
for _, doc := range results {
println(doc.ID)
println(doc.Content) // 最相关的文档片段
println("==========================")
}
检索流程内部发生的事情:
ini
用户问题: "刘氏家族"
↓
Embedder 向量化 → [12, 95, 203, ...] (2048 bytes 的二进制向量)
↓
Milvus 全库 HAMMING 距离计算
↓
返回 TopK=2 条最相似的记录:
[片段3] "## 四、弹幕社区梗文化 - 刘氏家族:粉丝根据他对..." (相似度最高)
[片段0] "## 一、人物简介 ..." (次高,也提到了相关背景)
第八步(可选):基于检索结果生成回答
检索只是 RAG 的一半。完整的 RAG 还包括把检索到的上下文喂给大模型,让它生成自然语言回答:
go
// 使用 ARK 大模型
model, _ := ark.NewChatModel(ctx, &ark.ChatModelConfig{
APIKey: os.Getenv("ARK_API_KEY"),
Model: os.Getenv("MODEL"), // Doubao-Seed-1.8
Timeout: &timeout,
})
messages := []*schema.Message{
schema.SystemMessage("你是一个助手,请根据以下参考资料回答用户问题。"),
schema.UserMessage(fmt.Sprintf("参考资料:%s\n\n问题:刘氏家族是什么?", retrievedContent)),
}
response, _ := model.Generate(ctx, messages)
println(response.Content)
这样模型就会基于真实的文档内容回答,而非凭空编造。
主程序入口:串联全部环节
go
func main() {
godotenv.Load(".env")
ctx := context.Background()
// 1. 初始化组件
embedder, _ := RAG.NewArkEmbedder(ctx, 30*time.Second)
RAG.NewMilvusCliInit(ctx)
retriever, _ := RAG.Retriever(ctx, embedder)
indexer, _ := RAG.ArkInderer(ctx, 30*time.Second)
splitter, _ := RAG.NewMdSplitter(ctx)
// 2. 加载文档 → 切分 → 存入向量库
resDoc, _ := RAG.MdOpenFs(ctx, "./document.md", splitter)
indexer.Store(ctx, resDoc)
// 3. 检索
results, _ := retriever.Retrieve(ctx, "刘氏家族")
// 4. 输出结果
for _, doc := range results {
fmt.Printf("ID: %s\n内容: %s\n%s\n", doc.ID, doc.Content,
strings.Repeat("=", 50))
}
}
短短不到 30 行代码,完成了从原始 Markdown 文档到智能问答的全部链路。
项目文件结构
bash
AwesomeEino/
├── cmd/run_RAG/
│ ├── main.go # 入口:串联全部组件
│ ├── .env # API 密钥配置
│ └── document.md # 待索引的 Markdown 文档
├── RAG/
│ ├── MilvusCli.go # Milvus 客户端初始化
│ ├── embedder.go # ARK 嵌入模型封装
│ ├── inderer.go # Indexer 创建 + Schema 定义
│ ├── inderer_binary.go # 自定义 DocumentConverter
│ ├── retriever.go # Retriever 创建
│ ├── mdOpenFs.go # 文件读取
│ ├── md_Splitter.go # 文档切分逻辑
│ └── mdSplitter_model.go # Markdown Header 切分器初始化
└── docker-compose.yml # Milvus 服务编排
运行指南
bash
# 1. 确保 Milvus 正在运行
docker compose up -d
# 2. 进入运行目录
cd cmd/run_RAG
# 3. 运行
go run .
预期输出:
markdown
切分出 7 个文档片段
ID: a3f7c2d1-e8b9...
内容: ## 四、弹幕社区梗文化
==================================================
ID: b5e8f3a2-c7d0...
内容: ## 一、人物简介...
==================================================
技术要点回顾与踩坑总结
| # | 问题 | 原因 | 解决方案 |
|---|---|---|---|
| 1 | does not support this api (400) |
vision 模型需走 multimodal 接口 | 设置 APIType: APITypeMultiModal |
| 2 | num_rows mismatch (写入时) |
map 中 []byte 被 SDK 展开为多行 |
改用 struct + milvus: tag + 指针 |
| 3 | dimension mismatch (检索时) |
默认 converter 按 4 字节/值编码 | 自定义 VectorConverter 按 1 字节/值编码 |
| 4 | expected dim 2048, actual 8192 |
BinaryVector 的 dim 单位是 bit 不是 byte | 2048 bytes × 8 = 16384 bits |
| 5 | 库中只有最后一段文档 | 切分后子文档 ID 相同,主键冲突覆盖 | 切分后为每段重新生成 UUID |
| 6 | context deadline exceeded |
30 作为 time.Duration = 30 纳秒 |
使用 30 * time.Second |