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》。

三、代码里的四个关键点

  1. 批量输入:OfArrayOfStrings 一次调用嵌入多篇文档。Embedding API 按 token 计费,批量调用和逐条调用的计费一样,但 HTTP 往返从 N 次降到 1 次,构建知识库时这是数量级的速度差异。
  2. e.Index 对应输入下标:返回的 res.Data 不一定严格按输入顺序排列,靠 Index 找回原文,别靠数组下标猜。
  3. 向量要落库复用:真实系统里文档向量只算一次,存进数据库(PostgreSQL pgvector、MySQL 向量索引、或专用向量库),查询时只给新问题算向量。每来一个搜索请求就把全库重新 Embedding 一遍,账单会教你做人。
  4. dimensions 参数可降维:EmbeddingNewParams 有 Dimensions 字段(可选),text-embedding-3 系列支持输出更低维度的向量——存储省、计算快,语义损失通常可接受。先用默认维度验证效果,有性能压力再调。

四、生产环境的四个注意

  1. 文档切分决定检索质量:整本书一个向量等于没有语义。按段落/语义块切成 200~800 字的小段分别嵌入,检索命中后再把上下文喂给 LLM 总结——这正是系列后面 RAG 篇的主线。
  2. 相似度阈值要实测:不同模型、不同领域的「多近算近」差异很大,别拍脑袋定 0.8,用标注数据测出召回率和准确率的平衡点。
  3. 混合检索更稳:向量负责语义,关键词(BM25)负责精确匹配(型号、错误码、API 名)。生产系统常见做法是两边各取 Top-K 再合并排序,纯向量检索遇到专有名词容易翻车。
  4. 归一化 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 + AI 应用开发实战:AI 面试官完整项目、MCP、Agent 工程化,完整源码随文章首发。

微信扫码,或搜索「GO语言入门之路」关注,第一时间获取系列更新与源码,不再错过任何一篇。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策