跳到主要内容

嵌入层:一套接口收编 11 种向量库

30 秒导读: 上一章(02)把文档切成了一颗颗 block 存进文本库。这一章讲 LLMware 怎么把这些 block 嵌入成向量、塞进你选的任意向量数据库(Milvus / FAISS / Pinecone / …),并且始终能凭一个 block_id 把命中的向量倒查回原始文本。核心只有一个类:EmbeddingHandler

本章聚焦 RAG 的「语义索引」这一环:如何把文本变成向量、如何统一管理十来种向量后端。不讲查询编排(那是 04 检索引擎)——这里只把 search_index 的返回约定交代清楚,作为 04 的输入。


1. 这是什么(零基础也能懂)

  • 一句话定义: EmbeddingHandler 是一个向量后端的统一遥控器——你告诉它"用哪个模型、存到哪个向量库",它就把库里的文本块逐批变成向量、写进去,查询时再帮你从向量库找回最相近的文本块。

  • 它解决什么问题: 向量数据库有一大堆(Milvus、FAISS、Pinecone、Redis、Qdrant、PGVector、Chroma…),每家 SDK 的建表、插入、检索 API 都长得不一样。如果每换一个库就要重写一遍嵌入逻辑,RAG 应用会被后端绑死。LLMware 把这层差异焊死在 10 个适配器类里,对上只暴露三个动作:建嵌入、搜、删。

  • 给谁用: 想搭 RAG 但不想被某个向量库锁死的工程师。今天用本地 FAISS 做原型,明天换 Pinecone 上生产,业务代码一行不用改——只改一个 vector_db="..." 字符串。

  • 用起来什么样: 用户几乎从不直接碰 EmbeddingHandler,而是调 Library.install_new_embedding(见 01):

    # 示意,非源码:一行给整个库装上语义索引
    library.install_new_embedding(
    embedding_model_name="mini-lm-sbert", # 用哪个嵌入模型
    vector_db="milvus", # 存到哪个向量后端
    batch_size=500,
    )
    # 之后就能做语义检索了(见第 04 章)
  • 一句话直觉: 把它想成快递分拣中心。包裹(block)都长一样,分拣中心(EmbeddingHandler)按你写的地址(vector_db)把它送进对应的仓库(Milvus/FAISS…);每个包裹贴着一张回执单号(block_id),将来凭单号就能从仓库把原件调回来。


2. 顶层全景(它大概怎么转)

2.1 三层结构

嵌入层就三层:一个门面、一堆共用工具、十个适配器

Library.install_new_embedding() ← 业务侧调用(library.py:783)
│ (vector_db="milvus", model=...)

┌─────────────────────────────┐
│ EmbeddingHandler │ 门面 / 路由器 (embeddings.py:78)
│ create_new_embedding :104 │
│ search_index :129 │
│ delete_index :143 │
└─────────────┬───────────────┘
│ _load_embedding_db :155
│ 查 VectorDBRegistry,动态 import 对应类
┌──────────────┼───────────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ …十个同构适配器… ┌───────────┐
│EmbedMilvus│ │EmbedFAISS │ │EmbedChroma│
│ :346 │ │ :656 │ │ :2544 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ 每个适配器都用同一套 _EmbeddingUtils
└──────────────┴──────────────┬─────────────────┘

┌──────────────────────────┐
│ _EmbeddingUtils │ 共用逻辑 (:198)
│ 取块游标 get_blocks_cursor│
│ 倒查文本 lookup_text_index│
│ 汇总 generate_..summary│
└────────────┬─────────────┘

文本集合库(Mongo/SQLite/Postgres,见 01)

怎么读这张图: 从上往下是一次"建嵌入"的调用;门面只做路由,真正干活的是某个适配器,而所有适配器把重复逻辑(取哪些块、怎么倒查文本、怎么汇总)都甩给中间那层 _EmbeddingUtils

2.2 部件一句话职责

部件干什么位置
EmbeddingHandler门面:接收 vector_db 名,路由到对应适配器embeddings.py:78
_load_embedding_db查注册表、动态 import 并实例化适配器embeddings.py:155
VectorDBRegistry名字 → {module, class} 的路由表configs.py:474
_EmbeddingUtils所有适配器共用:命名、取块、倒查文本、汇总embeddings.py:198
Embedding{Milvus…Chroma}10 个适配器,把统一契约翻译成各家 SDKembeddings.py:346
文本集合库存原始 block 文本 + 嵌入标记01

2.3 一句话主线

一次 install_new_embedding:业务侧传入模型 + 向量库名 → 门面据名加载对应适配器 → 适配器分批从文本库拉出 block、调模型算向量、连同 block_id 写进向量库,同时在文本库给每个 block 打上"已嵌入"标记 → 返回一份汇总字典(建了多少条、维度、时间戳)。查询时反过来:向量库返回命中的 block_id_EmbeddingUtils 凭 id 从文本库取回整块文本 → 打包成 (block, distance) 交给上层。


3. 核心原理

3.1 路由:一个字符串决定用哪个后端

要解决的小问题: 上层只想说一句 vector_db="milvus",不想知道 Milvus 的类叫什么、在哪个模块。

思路: 用一张注册表把名字映射到"模块 + 类名",门面拿到名字后动态 import——所以没装 pymilvus 的用户也能正常 import embeddings 模块,只有真正用到 Milvus 时才去加载它的驱动。

注册表就是一个字典(configs.py:478,VectorDBRegistry.vector_db_list):

# 真实源码 configs.py:478 VectorDBRegistry.vector_db_list
vector_db_list = {"milvus": {"module": "llmware.embeddings", "class": "EmbeddingMilvus"},
"chromadb":{"module": "llmware.embeddings", "class": "EmbeddingChromaDB"},
...
"postgres":{"module": "llmware.embeddings", "class": "EmbeddingPGVector"},
"pg_vector":{"module": "llmware.embeddings", "class": "EmbeddingPGVector"},
...}

这里有个诚实的小账:表里有 11 个名字,但 postgrespg_vector 指向同一个 EmbeddingPGVector 类——所以是 10 个适配器类、11 个可选后端名。"收编 11 种向量库"说的是这 11 个可选项。

门面的路由发生在 _load_embedding_db(embeddings.py:155):它查表拿到 module/class,importlib.import_modulegetattr 取出类,直接实例化返回。找不到就抛 LLMWareException。三个公开方法(create_new_embedding:104search_index:129delete_index:143)全靠它拿到具体适配器,自己不含任何后端逻辑

一个巧妙细节 —— query_vector 归一化(search_index:129,行 137-138):Transformer 出来的向量有时是 [[...]](多一层),有时是 [...]。门面在分发前统一剥成一维,让每个适配器不必各自处理这个坑。

3.2 共用逻辑:适配器为什么都这么"薄"

要解决的小问题: 10 个适配器如果各写各的"取哪些块、怎么命名、怎么倒查",就会有 10 份重复且易出 bug 的代码。

思路: 把与后端无关的公共动作全抽进 _EmbeddingUtils(embeddings.py:198)。每个适配器在 __init__ 里 new 一个 self.utils,把脏活外包出去。

_EmbeddingUtils 的公共动作:

方法干什么位置
create_safe_collection_name账号_库名_模型 的安全集合名:241
create_db_specific_key生成 embedding_<db>_<model> 这个嵌入标记键:264
get_blocks_cursor从文本库取"还没嵌入的块"的游标 + 数量:274
update_text_index给每个 block 写入"已嵌入 + 序号"标记:304
lookup_text_index_id 从文本库取回整块(倒查主力):317
lookup_embedding_flag凭嵌入标记值取回块(FAISS 专用倒查):326
generate_embedding_summary汇总"建了几条/共几块/维度/时间戳":287
unset_text_index删嵌入时清掉块上的标记:336

关键在 get_blocks_cursor(:274):它调 CollectionRetrieval.embedding_job_cursor(collection_key, ...),只捞尚未打上该 collection_key 标记的块。这就是增量嵌入的实现——重复调 install_new_embedding 只会嵌入新增的块,不会重算旧块。

3.3 block_id ↔ vector 的双向映射(全章的命门)

要解决的小问题: 向量库里只有一堆浮点数组。命中一个向量后,怎么知道它对应哪一段原文?

思路: 嵌入时,把每个 block 的 _id 当作载荷和向量一起写进向量库;检索时向量库连载荷一起还回来,拿这个 id 去文本库 lookup_text_index 取回整块。向量库只管"哪个 id 最像",文本永远住在文本库里。

写入侧(以 Milvus 为例,create_new_embedding:490,行 522-536):每条记录带三样——block_mongo_id(即 str(block["_id"]))、block_doc_idembedding_vector。Redis 更直白(:1661),载荷里同时塞 block_mongo_id / block_doc_id / block_id / text

读取侧(Milvus search_index:562,行 613-615):从命中里取出 block_mongo_id,交给 self.utils.lookup_text_index(_id) 换回原始 block。

用一张图看这条回路:

嵌入时 检索时
block["_id"] ──┐ 向量库命中 ──→ block_mongo_id
▼ │
向量库记录: {block_mongo_id, vector} ──────────────────┘

lookup_text_index(_id) ──→ 原始 block
(CollectionRetrieval.lookup "_id")

FAISS 是唯一的例外,值得单讲。faiss.IndexFlatL2 只按插入顺序给向量编号(0,1,2…),存不下任意字符串 id。于是 FAISS 走一条"反向"路子:

  • 嵌入时(:791),用 update_text_index当前 FAISS 位置序号(self.index.ntotal,行 767)当作嵌入标记写回文本库每个 block 上。
  • 检索时(search_index:811,行 828),FAISS 返回的是位置序号,于是改用 lookup_embedding_flag(collection_key, index_int) —— 拿序号当键去文本库倒查。注释(行 825-826)明说:"FAISS is unique in that it requires a 'reverse lookup'"。

所以 _EmbeddingUtils 才需要两个倒查函数:lookup_text_index(按 _id,九家用)和 lookup_embedding_flag(按序号,仅 FAISS)。


4. 十个适配器的统一对比

每个适配器都实现同一组方法(create_new_embedding / search_index / delete_index),差异只在"用哪家 SDK、载荷字段叫什么、相似度是距离还是分数"。下面不逐行抄代码,只做契约对比。

4.1 类与关键行号

适配器类建嵌入检索后端类型
EmbeddingMilvus:346:490:562独立/嵌入式(lite)
EmbeddingFAISS:656:733:811本地文件索引
EmbeddingLanceDB:850:959:1019嵌入式文件
EmbeddingPinecone:1049:1154:1221托管云
EmbeddingMongoAtlas:1246:1360:1471托管云(Atlas)
EmbeddingRedis:1515:1626:1705服务端
EmbeddingQdrant:1747:1843:1914服务端/嵌入式
EmbeddingPGVector:1942:2099:2239Postgres 扩展
EmbeddingNeo4j:2300:2427:2502图库
EmbeddingChromaDB:2544:2650:2708嵌入式/服务端

4.2 create_new_embedding 的统一契约

签名一致: create_new_embedding(self, doc_ids=None, batch_size=...),返回 _EmbeddingUtils.generate_embedding_summary(...) 出来的汇总字典。所有适配器的骨架是同一套(以 Milvus :490 / FAISS :733 为范本):

1. self.utils.get_blocks_cursor() → 拿"未嵌入块"游标 + 总数
2. Status(...).new_embedding_status() → 起进度条
3. while 未取完:
攒一批 block_ids + text_search
vectors = self.model.embedding(sentences) ← 调嵌入模型
<各家 SDK 的 insert/upsert,把 block_id 当载荷>
self.utils.update_text_index(block_ids, ...) ← 回写"已嵌入"标记
4. self.utils.generate_embedding_summary(...) → 返回汇总

差异只在第 3 步的写入:

适配器默认 batch写入方式(要点)
Milvus500collection.insert([ids, doc_ids, vectors]);lite 版逐行 dict
FAISS100index.add(np.array(vectors)),id 靠位置序号回写文本库
Pinecone100分块 upsert,vector 带 metadata 载荷
Redis500pipe.hset 每条哈希含 vector bytes + 4 个 id 字段
其余500各家 upsert/add,载荷含 block_mongo_id

共性: 向量永远由 self.model.embedding(sentences) 现算;文本永不进向量库(只进载荷的 id);增量性由 get_blocks_cursor + update_text_index 这对操作保证。

4.3 search_index 的统一契约(04 章的输入)

这是本章要交给 04 的关键约定。 每个适配器的 search_index(query_embedding_vector, sample_count=10) 都返回同一种形状:

# 所有适配器的 search_index 返回值
block_list = [ (block, distance), (block, distance), ... ] # 长度 ≤ sample_count
# block : 从文本库倒查回来的完整 block 字典(含 text_search、doc_ID…)
# distance : 第二个元素,相似度标量(越小越近 / 或越大越像,见下方警告)

统一流程(对照各家 block_list.append 行):向量库检索 → 取出每条命中的 block_idself.utils.lookup_text_index(id) 换回 block → block_list.append((block, 分数))

⚠ 一个必须交代的语义坑 —— 第二个元素不全是"距离"。 元组第二位的含义随后端而变:有的是 L2 距离(越小越近),有的是相似度分数(越大越像)。04 章在排序/取阈值时必须知道这点。

适配器第二元素来源语义(越小/越大更相关)
Milvushit.distance (:624)L2 距离,越小越近
FAISSdistance_list[0][i] (:833)L2 距离,越小越近
PGVectordistance (:2267)距离,越小越近
Neo4jresult["score"] (:2517)相似度分数(queryNodes 的 score),越大越像
LanceDBscore (:1032)距离/分数(按建索引度量)
MongoAtlas1 - score (:1501)已转成距离,越小越近
Redisfloat(res["score"]) (:1732)KNN 分数
Qdrantres.score (:1927)相似度分数,越大越像
Pineconematch["score"] (:1232)相似度分数,越大越像
ChromaDBresults['distances'][0][idx] (:2722)距离,越小越近

Neo4j 的分数来自 db.index.vector.queryNodes(...) YIELD node, score(search_index:2502,append 在 :2517),是未归一的相似度分数,越大越像,和 Milvus/FAISS 的 L2 距离方向恰好相反。MongoAtlas 则特意做了 distance = 1 - score(:1501,注释"Atlas returns a score from 0 to 1.0"),想把语义拉回"距离"这一侧,但 Neo4j / Qdrant / Pinecone / Redis 这些"分数型"后端并未统一。这就是为什么框架把这一位统称 (block, distance) 却在不同后端语义不齐——04 章消费时要留意。

4.4 delete_index 的共性

每个 delete_index 都做两件事:删掉向量库里的集合/索引,再调 self.utils.unset_text_index()(:336)清掉文本库里 block 上的嵌入标记。注意:只删向量,不删原文——文本集合库里的 block 原封不动(门面 delete_index:143 的 docstring 明说 "does not delete the underlying text collection")。这样同一个库可以挂多个不同模型/后端的嵌入,互不干扰(EmbeddingHandler 文档串,:84-85)。


5. 联动:调用侧从哪进来

业务几乎总是从 Library.install_new_embedding(library.py:783)进入,而不是直接 new EmbeddingHandler。那个方法负责前置装配:

  1. embedding_model_nameModelCatalog 把嵌入模型加载好(HF / SentenceTransformer / 目录内置,行 833-852)。
  2. 校验 vector_db 在支持列表内(行 863-865),没传就用配置默认。
  3. 加载好的模型交给门面:EmbeddingHandler(self).create_new_embedding(vector_db, my_model, batch_size=batch_size)(library.py:871)。

也就是说:Library 负责"选模型",EmbeddingHandler 负责"选后端并落地",职责干净分开。门面拿到的是一个已加载的 model 对象,适配器内部只管调 model.embedding(sentences),不关心模型怎么来的。


6. 巧妙之处(值得带走的设计)

  • 延迟导入驱动:模块顶部把每个后端驱动都设成 None + GLOBAL_*_IMPORT=False(embeddings.py:38-71),只有实例化对应适配器时才 importlib.import_module。好处:装了 llmware 不必装齐 10 个向量库的 SDK,用哪个装哪个。

  • 注册表驱动的路由:加后端只需往 VectorDBRegistry(configs.py:474)加一行,门面代码零改动。VectorDBRegistry.add_vector_db(configs.py:496)甚至允许运行时注册第三方后端。

  • 公共逻辑单点化:命名、取块、倒查、汇总全在 _EmbeddingUtils,让 10 个适配器薄到"只剩 SDK 翻译"。改倒查策略只改一处。

  • 增量嵌入天然成立:get_blocks_cursor 只捞未打标记的块 + update_text_index 嵌完即打标,使"重复安装嵌入"变成幂等的"只补新块"。

  • FAISS 反向查找:面对"存不下任意 id"的索引,用文本库里的嵌入标记序号搭了一条桥(:791/:828),让它也能塞进统一的 (block, distance) 契约里。

7. 边界与局限(诚实说)

  • search_index 第二元素语义不统一(见 4.3):距离型 vs 分数型混在一起,只有 MongoAtlas 做了归一。跨后端比较绝对分数没有意义。

  • 文本与向量分居两库:倒查每命中一条就查一次文本库(lookup_text_index),海量结果时是 N 次单点查询,没有批量倒查。

  • FAISS 位置序号的脆弱性:FAISS 的 id 是插入顺序位置,依赖文本库标记与索引文件严格同步;若索引文件被外部重建/顺序变动,倒查会错位(源码未见对此的一致性校验)(inferred)。

  • 门面不校验维度一致性:换嵌入模型若维度不同,需要新的 collection;是否冲突由各后端 SDK 自行报错,门面层不预检(inferred)。

8. 横向对比(同章导航)

9. 代码地图(导航索引)

主题文件路径符号名
门面 / 路由llmware/embeddings.py:78EmbeddingHandler
建嵌入分发llmware/embeddings.py:104EmbeddingHandler.create_new_embedding
检索分发 + 向量归一化llmware/embeddings.py:129EmbeddingHandler.search_index
删嵌入(不删原文)llmware/embeddings.py:143EmbeddingHandler.delete_index
动态加载适配器llmware/embeddings.py:155EmbeddingHandler._load_embedding_db
索引名生成llmware/embeddings.py:179EmbeddingHandler.generate_index_name
共用工具llmware/embeddings.py:198_EmbeddingUtils
取未嵌入块游标(增量)llmware/embeddings.py:274_EmbeddingUtils.get_blocks_cursor
回写已嵌入标记llmware/embeddings.py:304_EmbeddingUtils.update_text_index
倒查文本(按 _id)llmware/embeddings.py:317_EmbeddingUtils.lookup_text_index
倒查文本(FAISS 序号)llmware/embeddings.py:326_EmbeddingUtils.lookup_embedding_flag
嵌入汇总字典llmware/embeddings.py:287_EmbeddingUtils.generate_embedding_summary
Milvus 适配器llmware/embeddings.py:346EmbeddingMilvus
FAISS 适配器(反向查找)llmware/embeddings.py:656EmbeddingFAISS
LanceDB 适配器llmware/embeddings.py:850EmbeddingLanceDB
Pinecone 适配器llmware/embeddings.py:1049EmbeddingPinecone
MongoAtlas 适配器(1-score)llmware/embeddings.py:1246EmbeddingMongoAtlas
Redis 适配器llmware/embeddings.py:1515EmbeddingRedis
Qdrant 适配器llmware/embeddings.py:1747EmbeddingQdrant
PGVector 适配器llmware/embeddings.py:1942EmbeddingPGVector
Neo4j 适配器(score,越大越像)llmware/embeddings.py:2300EmbeddingNeo4j
ChromaDB 适配器llmware/embeddings.py:2544EmbeddingChromaDB
向量库路由表llmware/configs.py:474VectorDBRegistry
运行时注册第三方后端llmware/configs.py:496VectorDBRegistry.add_vector_db
调用侧入口llmware/library.py:783Library.install_new_embedding