你有没有想过,当你在公司知识库里问“今年的年假政策是什么”,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 插件就足够了。原因有三:
- 少维护一个服务:本来项目就要用关系型数据库,不需要再额外搭一套向量数据库。
- 保证数据一致性(事务):能保证“写入 50 段向量”和“更新文档状态为已处理”要么全部成功,要么全部回滚。
- 备份简单:一条命令就能连同业务数据和向量数据一起备份。
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]
内容: 请假需提前三天申请...
【用户提问】: 公司年假政策是什么?
同时,我们在系统提示词中附带三条铁律:
- 只能基于参考资料回答,绝对不能自己编造。
- 关键结论必须标注来源(例如:“根据[员工手册]...”)。
- 找不到答案就直接说不知道,不要强行解释。
配合降低模型的温度值(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。