Embedding 入门教程:语义检索原理、Go 实现与余弦相似度搜索实战
引言:关键词搜索的「智障」时刻
系列前两篇里,模型靠 Prompt 和工具理解世界。但还有一个基础能力我们没有:让程序理解「意思」。传统搜索是关键词匹配——用户搜「怎么让切片变大」,文档里写的是「切片扩容机制」,字面一个词都对不上,搜索直接扑空。
Embedding(嵌入向量)解决的就是这个:它把一段文字映射成一个高维浮点数组,语义相近的文字,向量在空间中的距离就近。「切片扩容」和「怎么让切片变大」的向量相似度,远高于它和「数据库连接池」的相似度。这就是语义检索(Semantic Search),也是 RAG 的底座——系列第 9 篇的问答机器人就建立在本篇的基础上。
一、原理:两个必须懂的概念
1. Embedding 是语义坐标。你可以把每个词、每句话想象成语义空间里的一个点。「猫」和「狗」离得近,「猫」和「汽车」离得远。Embedding 模型(如 OpenAI 的 text-embedding 系列)干的就是把文本算成坐标这件事,向量的每个分量本身没有人类可读的语义,但整体方向编码了语义。
2. 相似度用余弦计算。比较两个向量「方向差多少」而不是「长度差多少」——余弦相似度只关心夹角:
cosine(a, b) = (a·b) / (|a| × |b|) 结果范围 [-1, 1],越接近 1 越相似
用标准库 math 十几行就能实现:
// cosine 计算两个向量的余弦相似度
func cosine(a, b []float64) float64 {
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
注意一个前提:参与计算的两个向量必须来自同一个模型。不同模型训练出的坐标系完全不同,跨模型比相似度没有意义。
二、实战:30 行实现最小语义搜索引擎
2.1 环境与安装
mkdir semantic-search && cd semantic-search
go mod init semantic-search
go get github.com/openai/openai-go/v3@v3.91.1
export OPENAI_API_KEY=你的key
2.2 完整代码
package main
import (
"context"
"fmt"
"log"
"math"
"os"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
func cosine(a, b []float64) float64 {
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// embedTexts 把一组文本批量转向量,返回与输入顺序一致
func embedTexts(ctx context.Context, client *openai.Client, texts []string) ([][]float64, error) {
res, err := client.Embeddings.New(ctx, openai.EmbeddingNewParams{
Model: openai.EmbeddingModelTextEmbedding3Small,
Input: openai.EmbeddingNewParamsInputUnion{
OfArrayOfStrings: texts, // 一次调用批量处理,比逐条调用省一个数量级的开销
},
})
if err != nil {
return nil, err
}
vecs := make([][]float64, len(texts))
for _, e := range res.Data {
vecs[e.Index] = e.Embedding // Index 对应输入下标
}
return vecs, nil
}
func main() {
client := openai.NewClient(option.WithAPIKey(os.Getenv("OPENAI_API_KEY")))
ctx := context.Background()
// 1. 知识库文档(真实场景里来自数据库或 Markdown 文件)
docs := []string{
"Go 的切片是对底层数组的引用,append 触发扩容时会重新分配内存",
"GORM 批量插入应该用 CreateInBatches 分批,避免超过占位符上限",
"sync.Map 适合读多写少、或 goroutine 操作不相交 key 的场景",
}
// 2. 文档向量化(只需一次,结果应落库长期复用)
docVecs, err := embedTexts(ctx, client, docs)
if err != nil {
log.Fatal(err)
}
// 3. 用户问题向量化
q := "切片什么时候会重新申请内存?"
qv, err := embedTexts(ctx, client, []string{q})
if err != nil {
log.Fatal(err)
}
// 4. 余弦相似度排序,输出最相关文档
best, bestSim := -1, -math.MaxFloat64
for i := range docVecs {
if s := cosine(qv[0], docVecs[i]); s > bestSim {
best, bestSim = i, s
}
}
fmt.Printf("问题: %s\n最相关文档[%d]: %s\n相似度: %.3f\n", q, best, docs[best], bestSim)
}
运行效果类似:
问题: 切片什么时候会重新申请内存?
最相关文档[0]: Go 的切片是对底层数组的引用,append 触发扩容时会重新分配内存
相似度: 0.512
注意这里的关键词「重新申请内存」在原文里一个字都没出现,语义检索还是找到了正确文档——这就是 Embedding 的价值。想深入了解切片扩容机制,可以看站内 《深入理解 Go Slice》。
三、代码里的四个关键点
- 批量输入:
OfArrayOfStrings一次调用嵌入多篇文档。Embedding API 按 token 计费,批量调用和逐条调用的计费一样,但 HTTP 往返从 N 次降到 1 次,构建知识库时这是数量级的速度差异。 e.Index对应输入下标:返回的res.Data不一定严格按输入顺序排列,靠Index找回原文,别靠数组下标猜。- 向量要落库复用:真实系统里文档向量只算一次,存进数据库(PostgreSQL pgvector、MySQL 向量索引、或专用向量库),查询时只给新问题算向量。每来一个搜索请求就把全库重新 Embedding 一遍,账单会教你做人。
- dimensions 参数可降维:
EmbeddingNewParams有Dimensions字段(可选),text-embedding-3 系列支持输出更低维度的向量——存储省、计算快,语义损失通常可接受。先用默认维度验证效果,有性能压力再调。
四、生产环境的四个注意
- 文档切分决定检索质量:整本书一个向量等于没有语义。按段落/语义块切成 200~800 字的小段分别嵌入,检索命中后再把上下文喂给 LLM 总结——这正是系列后面 RAG 篇的主线。
- 相似度阈值要实测:不同模型、不同领域的「多近算近」差异很大,别拍脑袋定 0.8,用标注数据测出召回率和准确率的平衡点。
- 混合检索更稳:向量负责语义,关键词(BM25)负责精确匹配(型号、错误码、API 名)。生产系统常见做法是两边各取 Top-K 再合并排序,纯向量检索遇到专有名词容易翻车。
- 归一化 trick:如果先把向量归一化(除以自己的模长),余弦相似度就退化成点积,搜索时省掉每对的两次开方。数据量上了十万级再考虑这类优化。
五、速查清单
| 概念 | 一句话解释 |
|---|---|
| Embedding | 把文本映射成语义空间里的坐标向量 |
| 余弦相似度 | 比较两个向量方向的夹角,[-1,1] 越接近 1 越相似 |
| OfArrayOfStrings | 一次 API 调用批量嵌入多段文本 |
| Dimensions | text-embedding-3 系列支持的降维参数 |
| RAG | 检索(本篇)+ 生成(前两篇)拼起来的问答架构 |
版本说明
- 本文基于 openai-go v3.91.1(2026-11-03 核对,GitHub releases 最新 tag v3.91.1,发布于 2026-11-01);安装命令
go get github.com/openai/openai-go/v3@v3.91.1。 - 该版本要求 Go 1.26 或更高版本(官方 README 原文 “This version requires Go 1.26 or later”,2026-11-03 核对)。
- 文中所有 API(
client.Embeddings.New、EmbeddingNewParams、EmbeddingNewParamsInputUnion{OfString / OfArrayOfStrings}、EmbeddingModelTextEmbedding3Small、Data[i].Index / .Embedding)均对照 v3.91.1 源码(embedding.go、embedding_helpers.go、client.go)逐一核实。 - 官方文档:pkg.go.dev/github.com/openai/openai-go/v3 · platform.openai.com/docs/api-reference/embeddings
本文是「Go AI 应用开发」系列第 3 篇(共 18 篇规划)。上一篇:《Function Calling 教程:用 Go 实现大模型工具调用》;系列开篇:《大模型 API 基础》。下一篇预告:《流式输出(SSE)原理与 Go 实现》——让回答像打字机一样逐字出现。
- 《Go 程序设计语言》(Go 语言圣经,机械工业出版社) —— Go 官方团队出品,公认入门第一书
- 《Go 语言实战》(异步图书,人民邮电出版社) —— 实战导向,面试前巩固工程手感
- 《Go 语言编程之旅:一起用 Go 做项目》(博文视点,电子工业出版社) —— 项目驱动入门,从零写出完整应用
- Keychron K1Max 矮轴机械键盘(Mac/Win 双系统,QMK/VIA 开源改键) —— 轻薄矮轴 + 开源固件,程序员桌面常客
- 罗技 MX Master 3S 无线鼠标(Flow 跨屏,静音电磁滚轮) —— 人体工学设计,写代码一整天手不酸
以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。
每周更新 Go + AI 应用开发实战:AI 面试官完整项目、MCP、Agent 工程化,完整源码随文章首发。
微信扫码,或搜索「GO语言入门之路」关注,第一时间获取系列更新与源码,不再错过任何一篇。