你有没有想过,当你在公司知识库里问“今年的年假政策是什么”,AI 为什么能准确找到答案? 它
当 Go 遇见 Gemini:构建高可靠 AI 向量化流水线的实战
发布时间: 2026-06-20 (a month ago)
GOAgent

你有没有想过,当你在公司知识库里问“今年的年假政策是什么”,AI 为什么能准确找到答案?

它背后经历了一套怎样的流程?今天我们就用大白话聊聊这件事。


一、为什么选 Go,而不是 Python?

先回答一个大家都会问的问题:AI 领域明明 Python 最流行,为什么偏要用 Go?

1.1 打个比方就明白了

如果把写代码比作做饭:

  • Python 就像一个工具齐全的大厨房,适合研究新菜式(训练模型、做实验)。
  • Go 就像一个快餐连锁店的中央厨房,适合稳定、大量、快速地出餐(处理用户请求)。

RAG(检索增强生成)知识库系统本质上属于后者。它的工作流程是:收到用户提问 → 调 AI 接口 → 等结果 → 返回给用户。这个过程中 90% 的时间其实是在等待网络回应,而不是在算数学题。Go 恰好最擅长处理“等待”这件事。

1.2 并发能力:Go 就像有无数个帮手

想象一下你去奶茶店排队:

  • Python 就像一个店员,一次只能服务一个人(这就是 GIL 全局锁)。来了 100 个客人,后面 99 个只能干等。
  • Go 就像有几千个店员,每个客人都能立刻被接待。而且这些“店员”(Goroutine)只占很少的内存资源,一台普通服务器就能同时处理上万个请求。

1.3 部署:一个文件搞定

Python 项目部署时经常遇到:本机能跑,服务器跑不了;环境版本不兼容;装依赖装到一半网络超时。而 Go 编译完就生成一个单一的二进制文件,传到服务器直接运行——省心程度堪比“拎包入住”。

1.4 Go 的不足,恰好用不上

有人说 Go 的 AI 生态不如 Python——说得对。但 RAG 系统不需要在本地训练模型,它只是调用 AI。你需要的是管理用户连接、读写数据库、调度任务,这些正是 Go 的强项。AI 负责“思考”,Go 负责“组织”,二者各司其职。


二、第一个问题:文件格式五花八门怎么办?

企业知识库里的文件千奇百怪:Word、PDF、TXT、Markdown、HTML……向量化的第一步是把这些文件变成纯文字。

2.1 一个偷懒又聪明的办法

如果每个格式都自己写代码解析,维护成本会很高。我们的做法是:找个“翻译官”。把文件上传到 Google Drive,利用它内置的转换功能统一转成 Google Docs 格式,再导出为纯文本。

go 复制代码
// 判断文件类型:能转成文本的才走 AI 处理
switch ext {
case ".pdf", ".docx", ".doc", ".txt", ".html", ".md":
    // 上传时自动转成 Google Docs 格式
    // 云存储会帮我们处理格式转换
    canConvert = true
}

// 提取文本:不管原文件是 PDF 还是 Word,统一导出为纯文本
func ExtractText(fileID string) (string, error) {
    resp, err := service.Files.Export(fileID, "text/plain").Download()
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    
    content, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }
    return string(content), nil
}

这样做的好处很直接:代码只需要处理“纯文本”这一种格式,其他所有格式兼容问题,统统交给 Google 等云端服务处理。

2.2 顺便还能去个重

上传时给文件计算一个“指纹”——业内叫 SHA256 哈希值。如果两个人传了同一份文件,指纹一样,直接跳过处理,省时省力。


三、第二个问题:长文档怎么切?

AI 有“阅读上限”(上下文窗口限制)。你不能把整本《三体》一次性丢给它,得切成小块。

3.1 为什么不能一刀切?

想象一段话:

“根据公司规定,员工每年享有 15 天带薪年假,可分次使用。”

如果恰好从“员工”和“每年”中间切开:

  • 第一块:“根据公司规定,员工”
  • 第二块:“每年享有 15 天带薪年假”

两句话单独看语义都很奇怪。解决方案是重叠切片——切的时候互相重叠一部分:

  • 第一块[根据公司规定,员工每年享有 15 天]
  • 第二块[每年享有 15 天带薪年假,可分次使用]

看到没?“每年享有 15 天”在两块里都出现了。这样无论从哪块检索,都不会丢失关键信息。代码实现也不复杂:

go 复制代码
// ChunkText 重叠切片算法
// chunkSize = 每块字数,overlap = 重叠字数
func ChunkText(text string, chunkSize int, overlap int) []string {
    runes := []rune(text) // 处理中文,按字符切分
    var chunks []string
    
    for i := 0; i < len(runes); {
        end := i + chunkSize
        if end > len(runes) {
            end = len(runes)
        }
        chunks = append(chunks, string(runes[i:end]))
        if end == len(runes) {
            break
        }
        i = end - overlap // 后退 overlap 个字,实现重叠
    }
    return chunks
}

[!IMPORTANT]
注意要用 []rune 而不是 []byte——中文在 UTF-8 编码下占 3 个字节,如果按字节切会切出乱码。

3.2 参数怎么定?

通常建议:每个片段 500-800 字,重叠 50-100 字。这相当于每段 A4 纸大半页的内容,相邻段落重叠一两句话,既能保证语义连贯,又不会让信息太碎片化。


四、第三个问题:怎么让 AI “读懂”这些片段?

文字切好了,但 AI 不理解文字,它只理解数字。这需要一个“翻译”过程——把每段文字转换成一个长长的数字列表,也就是向量(Embedding)

4.1 什么是向量?通俗版解释

想象你在地图上描述一个人的位置,需要“经度、纬度”两个坐标。而描述一段话的意思,则需要更多的坐标——比如 3072 个维度。把这 3072 个数字排成一串,就是这段话的“语义坐标”。

  • 意思相近的话:它们的 3072 个坐标值就越接近。
  • 意思相远的话:它们的坐标值相差就越大。

4.2 一次处理多个,少跑几趟

Gemini 提供了“批量翻译”功能——一次接口调用可以把几十段文字同时转成向量,省去反复调用网络接口的时间。

go 复制代码
func GetEmbeddings(texts []string) ([][]float32, error) {
    // 设置 10 秒超时
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    client, err := NewGeminiClient(ctx, apiKey)
    if err != nil {
        return nil, err
    }
    model := client.EmbeddingModel("gemini-embedding-001")

    batch := model.NewBatch()
    for _, text := range texts {
        batch.AddContent(genai.Text(text))
    }

    // 一次调用嵌入所有文本
    res, err := model.BatchEmbedContents(ctx, batch)
    if err != nil {
        return nil, err
    }
    
    var embeddings [][]float32
    for _, item := range res.Embeddings {
        embeddings = append(embeddings, item.Values)
    }
    return embeddings, nil
}

4.3 别忘了设个“闹钟”

调用第三方 AI 接口不可能百分之百成功。设置一个 10 秒的超时时间——如果 10 秒内没有回应,果断放弃并重试,或者告诉用户“网络开了个小差,请重试”。这总比让用户看着加载动画无限等待要强。


五、第四个问题:向量存哪里?怎么找?

向量需要存起来,等用户提问时再去检索。

5.1 用 PostgreSQL 就够了

市面上有专门的“向量数据库”(如 Milvus、Pinecone 等),但对于大多数企业,选择 PostgreSQL + pgvector 插件就足够了。原因有三:

  1. 少维护一个服务:本来项目就要用关系型数据库,不需要再额外搭一套向量数据库。
  2. 保证数据一致性(事务):能保证“写入 50 段向量”和“更新文档状态为已处理”要么全部成功,要么全部回滚。
  3. 备份简单:一条命令就能连同业务数据和向量数据一起备份。
go 复制代码
// 事务保证:切片入库和状态更新要么都成功,要么都回滚
db.Transaction(func(tx *gorm.DB) error {
    // 批量写入切片
    if err := tx.CreateInBatches(&chunks, 50).Error; err != nil {
        return err
    }
    // 更新文档状态
    if err := tx.Model(&doc).Update("status", "done").Error; err != nil {
        return err
    }
    return nil
})

5.2 怎么找最相似的?

用户提问时,先把问题的文本也转成向量,然后在数据库里找与它“距离最近”的 3-5 个片段:

go 复制代码
// SearchRelevantChunks 查找与问题向量最相似的切片
func SearchRelevantChunks(query string, topK int) ([]ChunkModel, error) {
    // 先把问题转成向量
    queryVectors, err := GetEmbeddings([]string{query})
    if err != nil {
        return nil, err
    }
    queryVector := queryVectors[0]

    var chunks []ChunkModel
    // 使用余弦距离(<=>)进行排序,值越小越相似
    db.Order("embedding <=> ?", queryVector).Limit(topK).Find(&chunks)
    return chunks, nil
}

[!TIP]
检索找 3-5 个片段是有讲究的——太少可能漏掉关键信息,太多则会把无关杂音带入,甚至撑爆 AI 的上下文窗口。


六、第五个问题:怎么让 AI 学会“引用”?

找完相关片段之后,不是直接丢给 AI 就完事了。如果让 AI 自由发挥,它可能会开始“一本正经地胡说八道”——这就是大家常说的 AI 幻觉

6.1 给 AI 设定“规矩”

我们需要把找到的知识库片段,拼装成一份带有来源和规则的指令,也就是 Prompt(提示词)

go 复制代码
// 拼接上下文:每段资料都带上标题和链接
var contextText string
for i, chunk := range chunks {
    contextText += fmt.Sprintf(
        "[资料 %d - 标题: %s | 链接: %s]\n内容: %s\n\n",
        i+1, chunk.Title, chunk.URL, chunk.Text,
    )
}

// 构造完整的 Prompt
prompt := fmt.Sprintf(`
【参考资料】:
%s
【用户提问】: %s
`, contextText, question)

组装完成后,AI 实际收到的指令长这样:

text 复制代码
【参考资料】:
[资料 1 - 标题: 员工手册 | 链接: https://internal.corp/docs/1]
内容: 员工每年享有 15 天带薪年假...

[资料 2 - 标题: 考勤制度 | 链接: https://internal.corp/docs/2]
内容: 请假需提前三天申请...

【用户提问】: 公司年假政策是什么?

同时,我们在系统提示词中附带三条铁律:

  1. 只能基于参考资料回答,绝对不能自己编造。
  2. 关键结论必须标注来源(例如:“根据[员工手册]...”)。
  3. 找不到答案就直接说不知道,不要强行解释。

配合降低模型的温度值(Temperature,降低创造力),让 AI 老老实实照本宣科,这样幻觉率就会大幅降低。


七、第六个问题:怎么让用户不等太久?

AI 生成完整的回答通常需要 5-20 秒。如果让用户盯着一个空白的加载图标干等,体验会非常糟糕。

7.1 “边想边说”的流式输出

Gemini 支持流式输出(Streaming)——就像一个人一边思考一边说话,想到一部分先吐出几句话。后端每收到一小块文字,通过通道(Channel)或回调函数立刻推送到前端:

go 复制代码
// AskStream 核心 RAG 问答流式输出函数
func AskStream(ctx context.Context, question string, onChunk func(string)) error {
    // 1. 把问题转成向量并搜索最相关的 5 个切片
    queryVec := GetEmbedding(question)
    chunks := SearchTopK(queryVec, 5)
    
    // 2. 拼接 Prompt
    prompt := BuildPrompt(chunks, question)
    
    // 3. 调用 Gemini 流式接口,逐块读取
    iter := model.GenerateContentStream(ctx, prompt)
    for {
        resp, err := iter.Next()
        if err == iterator.Done {
            break // 生成完毕
        }
        if err != nil {
            return err
        }
        
        text := extractText(resp)
        if text != "" {
            onChunk(text) // 推送一小块字给前端
        }
    }
    onChunk("[DONE]") // 告诉前端:回答完了
    return nil
}

这样,前端就能实现流畅的打字机效果

  • 0.5 秒:正在思考...
  • 1.0 秒:根据
  • 2.0 秒:根据 员工手册,
  • 3.0 秒:根据 员工手册,员工每年享有 15 天带薪年假...

用户的第一反应不再是“怎么这么慢”,而是“它已经在认真回答了”,感官速度体验会提升数倍。

7.2 后台开个“小号”单独跑

处理 AI 提问是一个耗时的 CPU / 网络任务。在 Go 中,我们通常会启动一个独立的 Goroutine(协程) 去调用 AI 接口,避免阻塞主服务的事件循环,保证系统的整体吞吐量。


八、总结:做 AI 应用和调 AI 模型是两回事

把 AI 能力真正落地成稳定的产品,工程落地能力往往比模型本身的能力更重要

整个 RAG 流水线的稳定性与体验,是由一个个具体的工程细节堆叠起来的:

遇到的工程难题 对应的解决方案
文件格式太多,解析成本高 借助云存储中转转换,统一导出为纯文本
文档过长,超出 AI 阅读上限 采用重叠切片算法,保留边缘语义
AI 无法直接读取字符语义 用 Embedding 转化为多维向量坐标
海量向量存取与业务状态同步 用 PostgreSQL + pgvector,单事务保证一致性
AI 容易产生幻觉、胡说八道 严格限定 Prompt 上下文范围,强制标注来源
生成等待时间长,用户易流失 采用流式(Stream)输出,打字机式逐步呈现
  • 稳定性,来自对网络超时、异常重试的精细处理。
  • 效率,来自对批量处理和并发的合理运用。
  • 可信度,来自对 AI 的精确指令约束和溯源要求。

最好的技术,是用最稳的工程,去承载最聪明的 AI。