☰
LangChainGo 实战系列(四):Embedder 与 VectorStore——连接“你的数据“的第一块基石
2026/10/24 5:38:58 网站建设 项目流程

LangChainGo 实战系列(四):Embedder 与 VectorStore——连接"你的数据"的第一块基石

LLM 的知识止步于训练数据,它不知道你公司的政策文档、产品手册和内部 Wiki。要让模型"认识"你的数据,第一步不是微调,而是语义检索:把文档变成向量存起来,查询时找出语义最接近的片段。这一篇打通"写入文档 → 相似度搜索"的最小闭环。

本篇导读

前面几篇解决的是"怎么跟模型对话",从这一篇开始解决"模型怎么认识你的数据"。这一篇和上一篇没有直接的代码承接关系,但它和后篇(RAG)是上下集——本篇做好的检索能力,正是 RAG 的地基。

我会先讲清"文本变向量"到底是怎么回事,再拆解Embedder和VectorStore两个接口及其内部结构,说清检索选项怎么做业务过滤,最后跑通一个城市文档语义检索的完整例子。

读完这一篇,你将能:解释嵌入向量和相似度搜索的原理;独立完成文档写入和语义检索;用分数阈值和元数据过滤实现租户隔离这类业务需求。

两个接口撑起整个检索层

检索层延续了 LangChainGo 一贯的小接口风格,核心就两个接口。

embeddings.Embedder,负责文本向量化:

typeEmbedderinterface{EmbedDocuments(ctx context.Context,texts[]string)([][]float32,error)// 批量:写入时用EmbedQuery(ctx context.Context,textstring)([]float32,error)// 单条:查询时用}

vectorstores.VectorStore,负责向量的存取:

typeVectorStoreinterface{AddDocuments(ctx context.Context,docs[]schema.Document,options...Option)([]string,error)SimilaritySearch(ctx context.Context,querystring,numDocumentsint,options...Option)([]schema.Document,error)}

先解释一下"向量"在这里的含义,这是理解整篇的前提。嵌入模型(Embedding Model)做的事,是把一段文本映射成高维空间里的一个点(一串浮点数)。训练得好的模型有个性质:语义相近的文本,映射后的点在空间里的距离也近。打个比方,它像给每段话生成一个"语义坐标",意思越像的话坐标越靠近——“三亚"和"温暖"的坐标距离,比"三亚"和"冰雕"近得多。相似度搜索就是在找"离查询点最近的那些文档点”。

数据在两个接口间的流转:

查询路径

写入路径

schema.Document 列表

Embedder.EmbedDocuments
批量向量化

VectorStore
向量+原文+元数据

查询文本

Embedder.EmbedQuery
单条向量化

VectorStore.SimilaritySearch
近邻检索

带 Score 的 Document 列表

Embedder 的三层包装

embeddings包内部有个容易混淆的分层,说清楚能省不少困惑:

  • EmbedderClient:最底层,只有一个CreateEmbedding(ctx, texts)方法。各家 LLM 客户端自带实现——比如llms/ollama的LLM类型就有这个方法;
  • EmbedderImpl:embeddings.NewEmbedder(client)返回的包装器,在 client 之上加了两个横切能力:去除换行(StripNewLines,部分嵌入模型对换行敏感)和分批嵌入(BatchSize默认 512,避免一次请求过大);
  • Embedder接口:面向使用者的最终形态。

这个分层带来的直接好处是,任何 LLM 客户端都能零成本变成 Embedder:embeddings.NewEmbedder(ollamaLLM)一行搞定。

有一条使用约束必须记住:写入(索引)和查询必须用同一个 Embedder。原因要从上面"语义坐标"的类比往下推——每个嵌入模型都有自己的一套坐标系,同一个词在模型 A 的坐标和模型 B 的坐标完全对不上,就像一份用 GPS 坐标标注的藏宝图,拿北斗坐标去解,位置全错。混用之后相似度分数毫无意义,这是语义检索线上事故的头号来源。

VectorStore:16 种后端,一个接口

vectorstores/目录下有 Chroma、PGVector、Pinecone、Qdrant、Milvus、Weaviate、Redis、MongoDB、OpenSearch、Azure AI Search 等 16 种实现。它们能互换的机制,藏在每个实现文件顶部的一行代码里:

// vectorstores/chroma/chroma.go// 编译期接口断言:Store 必须完整实现 VectorStore,否则编译失败。// 这行代码不产生任何运行时开销,纯粹是给编译器看的。var_vectorstores.VectorStore=Store{}

这行赋值不产生任何运行时代码,作用是让编译器强制检查Store是否完整实现了接口——少实现一个方法,编译直接失败。接口稳定、实现可换:今天用 Chroma 开发,明天上生产换 PGVector,业务代码不用动。

检索选项:业务过滤能力的入口

vectorstores/options.go定义了所有后端共享的选项:

选项作用典型场景
WithScoreThreshold相似度分数阈值,低于阈值的结果被丢弃过滤不相关内容,防止"硬答"
WithNameSpace命名空间/集合隔离多业务线共用一套向量库
WithFilters元数据过滤(转换为底层库的 where 条件)多租户隔离、按类目/时间过滤
WithEmbedder指定本次操作用的 Embedder覆盖默认值
WithDeduplicater自定义去重函数重复文档写入防护

WithFilters值得展开说。它把map[string]any翻译成各向量库自己的过滤语法(Chroma 的 where、PGVector 的 SQL WHERE 等)。重点是过滤发生的位置:租户隔离、权限过滤都应该下推到向量库这一层做,而不是查出 Top-K 结果后再用 Go 代码过滤。后者有个隐蔽的坑——比如你要 10 条结果,向量库返回了 10 条,你在代码里过滤掉 6 条无权限的,最后只剩 4 条,数量语义就乱了;而过滤条件下推后,向量库会在符合条件的数据里找足 10 条。

动手:城市文档语义检索

场景:一批城市介绍文档,用户搜索"适合冬天旅游的温暖城市"。关键词匹配在这里完全失效(查询里没有"三亚"“曼谷”),语义检索才能命中。

// 第 1 步:准备 Embedder。// ollama 的 LLM 类型自带 CreateEmbedding 方法,// NewEmbedder 把它包装成带分批/去换行能力的 Embedder。ollamaLLM,_:=ollama.New(ollama.WithModel("nomic-embed-text"))embedder,_:=embeddings.NewEmbedder(ollamaLLM)// 第 2 步:连接 Chroma 向量库,并绑定 Embedder。// 之后写入和查询的向量化都由 store 内部自动完成。store,_:=chroma.New(chroma.WithChromaURL("http://localhost:8000"),chroma.WithEmbedder(embedder),chroma.WithNameSpace("cities"),// 集合隔离,相当于数据库里的表)// 第 3 步:写入文档。// AddDocuments 内部先 EmbedDocuments 批量向量化,再连同原文和元数据一起存。_,_=store.AddDocuments(ctx,[]schema.Document{{PageContent:"三亚:海南岛南端,冬季平均气温 20 度以上……",Metadata:map[string]any{"climate":"热带","region":"华南"}},{PageContent:"哈尔滨:冬季寒冷漫长,有冰雪大世界……",Metadata:map[string]any{"climate":"寒温带","region":"东北"}},// ...})// 第 4 步:语义检索。// 内部流程:查询文本 → EmbedQuery 向量化 → 近邻搜索 →// 丢弃分数低于 0.3 的结果 → 返回 Top 2。results,_:=store.SimilaritySearch(ctx,"适合冬天旅游的温暖城市",2,vectorstores.WithScoreThreshold(0.3))// 第 5 步:语义检索 + 元数据过滤。// region=华南 会被翻译成 Chroma 的 where 条件,先过滤再找相似。filtered,_:=store.SimilaritySearch(ctx,"温暖的海滨城市",2,vectorstores.WithFilters(map[string]any{"region":"华南"}))

预期效果:查询"温暖城市"时三亚、曼谷排在前面(尽管查询里没出现这两个词);加上region=华南过滤后只剩三亚。返回的Document.Score是相似度分数,下一篇做"检索不到就降级"时会用到它。

什么时候从开发环境升级到生产向量库?

信号建议
文档量 < 1 万,单机开发Chroma / SQLite-vec 足够
已有 PostgreSQL 基础设施PGVector,少维护一个组件
文档量百万级、高 QPSMilvus / Qdrant / OpenSearch
强托管需求Pinecone / Azure AI Search

接口统一的好处在这里兑现:升级只改构造函数那几行,检索逻辑原样保留。

本篇总结

这一篇的两个接口都不复杂,真正要带走的是三个工程结论:索引和查询共用同一个 Embedder;元数据过滤下推到向量库层;换后端时编译期断言保证了接口契约不被破坏。

学完这一篇,你应该已经能:向同事讲清"文本 → 向量 → 相似度"的原理;独立完成文档写入、语义查询、元数据过滤的完整检索程序;根据数据量和基础设施为团队选出合适的向量库。

下篇预告

有了检索能力就能构建 RAG 了。下一篇 《RAG 检索增强生成》把VectorStore通过ToRetriever适配成schema.Retriever,接入RetrievalQA链,做一个带来源引用和相似度降级的企业政策问答系统。

附:本篇核心代码速览

// 1. Embedder:LLM 客户端一行变 Embedder(索引与查询必须共用同一个)ollamaLLM,_:=ollama.New(ollama.WithModel("nomic-embed-text"))embedder,_:=embeddings.NewEmbedder(ollamaLLM)// 2. VectorStore:Chroma 后端 + 集合隔离store,_:=chroma.New(chroma.WithChromaURL("http://localhost:8000"),chroma.WithEmbedder(embedder),chroma.WithNameSpace("cities"))// 3. 写入带元数据的文档(内部自动 EmbedDocuments 批量向量化)_,_=store.AddDocuments(ctx,[]schema.Document{{PageContent:"三亚:海南岛南端,冬季平均气温 20 度以上……",Metadata:map[string]any{"climate":"热带","region":"华南"}},{PageContent:"哈尔滨:冬季寒冷漫长,有冰雪大世界……",Metadata:map[string]any{"climate":"寒温带","region":"东北"}},})// 4. 语义检索 + 分数阈值(查询中没有"三亚"这个词也能命中)results,_:=store.SimilaritySearch(ctx,"适合冬天旅游的温暖城市",2,vectorstores.WithScoreThreshold(0.3))// 5. 语义检索 + 元数据过滤(租户隔离/类目过滤在这一层做)filtered,_:=store.SimilaritySearch(ctx,"温暖的海滨城市",2,vectorstores.WithFilters(map[string]any{"region":"华南"}))

运行需要本地 Ollama(ollama pull nomic-embed-text)和 Chroma 容器(docker run -p 8000:8000 chromadb/chroma)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询