从零实现一个完整 RAG 系统:基于 Eino 框架的检索增强生成实战

从零实现一个完整 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
相关推荐
NotFound4862 小时前
实战分享怎样实现Spring Boot 中基于 WebClient 的 SSE 流式接口操作
java·spring boot·后端
青衫码上行2 小时前
【从零开始学习JVM】程序计数器
java·jvm·学习·面试
码事漫谈10 小时前
大模型输出的“隐性结构塌缩”问题及对策
前端·后端
怕浪猫10 小时前
2026 年前端工程师面试:一份来自面试官视角的真实复盘
面试
小江的记录本10 小时前
【网络安全】《网络安全常见攻击与防御》(附:《六大攻击核心特性横向对比表》)
java·网络·人工智能·后端·python·安全·web安全
努力的小雨11 小时前
龙虾量化实战法(QClaw)
后端
橙露11 小时前
SpringBoot 整合 MinIO:分布式文件存储上传下载
spring boot·分布式·后端
2401_8955213413 小时前
【Spring Security系列】Spring Security 过滤器详解与基于JDBC的认证实现
java·后端·spring
小码哥_常13 小时前
大文件上传不再卡顿:Spring Boot 分片上传、断点续传与进度条实现全解析
后端