文档已经做成向量索引后,如果把查询时使用的 Embedding 模型换掉,可能出现两种情况:搜索报错,或者搜索没有报错,返回的内容却不相关。

原因是:索引里保存的是旧模型算出的文档向量,换掉查询模型,不会让这些旧向量跟着改变。 查询问题和文档必须使用相互兼容的编码方式,才能通过数字之间的距离寻找相关内容。

下面用“无线网络说明”和“会议室说明”两个片段,解释为什么维度相同还不够,以及重建索引究竟在重做哪一步。

1. 文档和问题都要经过 Embedding

Embedding 模型把一段文字转换成一组数字,这组数字就是向量。“768 维”表示一条向量有 768 个数字,不是文档有 768 个字。

假设文档中有两个片段:

无线网络说明:访客可以连接 Guest-WiFi,在认证页面填写手机号。
会议室说明:会议室通过办公日历预约,创建日程时添加会议室资源。

建库时,程序用模型 A 分别给它们生成向量,再把向量保存到索引中。到这里,模型计算已经结束,索引中留下的是计算结果。

后来有人问“来访的人怎样上网?”,程序还要给这个问题生成一条向量,再用它搜索已有的文档向量:

建库时:
无线网络说明 → 模型 A → 文档向量 → 保存到索引
会议室说明   → 模型 A → 文档向量 → 保存到索引
 
查询时:
来访的人怎样上网? → 模型 A → 问题向量
                                  ↓
                        与索引中的文档向量比较
                                  ↓
                           返回相关的原文

因此,Embedding 不只在建库时使用。查询时也需要它,区别在于这次计算的是问题,而不是重新计算全部文档。

语义检索依赖文档和问题被放进同一个可比较的向量空间;这里的“空间”可以理解为模型给不同文本安排数字位置的方式。Sentence Transformers 语义检索说明

2. 维度不同,搜索为什么会报错

假设模型 A 输出 768 维,模型 B 输出 1024 维。用 A 建库、用 B 查询,就变成:

索引中的每条文档向量:768 个数字
当前问题的向量:     1024 个数字

FAISS 的这类向量索引在创建时就确定了维度。它需要把问题向量和文档向量中相同位置的数进行比较,两边的数字个数不同,就不满足搜索接口的要求。

在 FAISS 的 Python 搜索接口中,可以看到这样的检查:

assert d == self.d

这里的 d 是本次传入向量的维度,self.d 是索引的维度。条件不成立时会抛出 AssertionError。FAISS Python 接口源码

如果已经有 LangChain 的 vector_store 对象和当前查询使用的 embeddings 对象,可以在搜索前打印两边的维度:

query_vector = embeddings.embed_query("来访的人怎样上网?")
 
print("问题向量维度:", len(query_vector))
print("索引向量维度:", vector_store.index.d)

这只能检查数字个数是否相同。它不能告诉我们旧索引用了哪个模型,也不能证明两个同维度模型可以混用。上面的 768 和 1024 是说明问题用的假设值,并不对应某份旧索引的已知配置。

3. 都是二维,为什么也会搜错

为了看清数字怎样影响搜索,暂时不用几百维的真实模型,改用两组人为设定的二维向量。每条向量只有两个数。

假设 A 和 B 对同样的文字给出了下面的结果:

文本A 的向量B 的向量
无线网络说明[1.0, 0.0][0.0, 1.0]
会议室说明[0.0, 1.0][1.0, 0.0]
来访的人怎样上网?[0.9, 0.1][0.1, 0.9]

这里的 B 只是把 A 的两个数字交换了位置,方便观察。这些数不是实际模型的输出,也不表示真实模型之间只差一个位置交换。 实际模型中,也不能简单把某一个维度命名为“上网程度”或“会议室程度”。

先看全部使用 A 的情况。问题向量是 [0.9, 0.1],它接近无线网络的 [1.0, 0.0],离会议室的 [0.0, 1.0] 更远,因此能找到无线网络说明。

如果文档保留 A 的向量,只把问题换成 B 算出的 [0.1, 0.9],情况就反过来了:

旧文档向量仍然是:
无线网络 → [1.0, 0.0]
会议室   → [0.0, 1.0]
 
新问题向量变成:
怎样上网 → [0.1, 0.9]

这个问题向量在数字上更接近旧的会议室向量。FAISS 按距离返回会议室,没有发生维度错误,却选错了我们想找的内容。

如果文档也用 B 重新计算,无线网络就变成 [0.0, 1.0],它又与 B 的问题向量接近了。

这说明,A 和 B 各自配套使用时都可以工作。问题出在把 A 保存的文档位置和 B 计算的问题位置混在了一起。维度相同只说明数字个数相同,不说明这些数字能放在一起比较语义。

用 FAISS 运行这三种情况

这个实验只搜索上面手写的数字,不下载 Embedding 模型。新建一个示例目录,在其中创建 Python 3.11 环境并安装依赖:

python3.11 -m venv .venv
.venv/bin/python -m pip install "faiss-cpu==1.15.0" "numpy==2.4.6"

命令适用于 macOS / Linux。把下面代码保存为 embedding_space_demo.py:

import faiss
import numpy as np
 
texts = ["无线网络说明", "会议室说明"]
 
# 每行是一条文档向量,行的顺序与 texts 对应。
documents_a = np.array([[1.0, 0.0], [0.0, 1.0]], dtype="float32")
documents_b = np.array([[0.0, 1.0], [1.0, 0.0]], dtype="float32")
 
# FAISS 接收一批问题向量;这里只放一行,表示一个问题。
query_a = np.array([[0.9, 0.1]], dtype="float32")
query_b = np.array([[0.1, 0.9]], dtype="float32")
 
index_a = faiss.IndexFlatL2(2)
index_a.add(documents_a)
 
index_b = faiss.IndexFlatL2(2)
index_b.add(documents_b)
 
 
def search(index, query):
    distances, positions = index.search(query, 1)
    # 第一个问题的第一个搜索结果,对应 texts 中的位置。
    return texts[int(positions[0, 0])]
 
 
print("A 文档 + A 问题:", search(index_a, query_a))
print("A 文档 + B 问题:", search(index_a, query_b))
print("B 文档 + B 问题:", search(index_b, query_b))

IndexFlatL2(2) 创建一个二维索引,逐条比较向量的距离。add() 放入文档向量,search(query, 1) 找出最近的一条,并返回距离及其位置。本例用位置从 texts 中取出说明名称。

在终端执行:

.venv/bin/python embedding_space_demo.py

输出是:

A 文档 + A 问题: 无线网络说明
A 文档 + B 问题: 会议室说明
B 文档 + B 问题: 无线网络说明

三次搜索都成功执行。第二次的错误来自配对方式,而不是搜索程序停止工作。这个实验说明“维度相同也可能不兼容”,不用于评价任何真实 Embedding 模型的检索质量。

4. 重新加载索引,不会重新计算旧向量

在 LangChain 的 FAISS 集成中,FAISS.load_local() 接收一个 embeddings 对象。这容易让人误以为:传入新模型,旧索引就会改用新模型重新处理。

它实际做的是读取已保存的索引、文档和对应关系,再把传入的 Embedding 对象关联到加载后的向量库,供后续文本查询等操作使用。加载过程中不会调用它重新计算旧文档的向量。LangChain FAISS 加载实现

所以,用 B 加载 A 保存的索引后,状态是:

已保存的文档向量:仍然来自 A
后续问题的向量:  由 B 计算

加载成功只说明文件被读进来了,不说明查询模型与索引匹配。文件中的向量不会记住“以后跟随当前模型一起更新”这样的关系,它们只是上次保存的一组数字。

5. 重建索引,重做的是哪一步

如果决定以后用 B 检索,就需要让文档也使用 B 的编码方式:

保留原来的文档片段
    ↓
用 B 给每个片段重新计算向量
    ↓
用这些新向量建立新的索引
    ↓
查询时也使用 B

这里不一定需要修改原始 Markdown,也不一定需要改变切片方式。如果文档和切片仍然适用,可以沿用原来的片段;改变的是每个片段对应的数字。

重建索引不是重新训练 Embedding 模型。 模型 B 已经存在,我们只是用它重新处理文档,再保存结果。

实际替换时,可以把新索引保存到不同目录,保留旧索引。用几条知道答案的问题检查新索引是否能找回对应原文,再把查询程序切到新目录,并使用配套的 B 模型配置。LangChain 保存的 index.faiss 和 index.pkl 要来自同一次建库,不能只替换其中一个。

从 Markdown 读取、切片、保存和重新加载的完整代码见 用 LangChain 和 FAISS 为 Markdown 建立语义检索。

如果旧模型的名称、版本和配置都清楚,也可以继续用原模型查询旧索引。如果只知道旧索引的维度,不能据此判断它由哪个模型生成;有原始文档时,可以用选定的模型重新建库。

6. “使用同一个模型”还要注意什么

对一般的单模型检索流程,建库和查询使用同一模型及配套配置,是避免混用向量的做法。但仅仅让模型名称字符串相同还不够:模型权重版本、输出维度,以及文本和向量的处理方式也可能影响结果。

这里的一致,不是要求文档和问题的所有输入处理都一模一样。例如,Multilingual E5 在检索时要求文档使用 passage: 前缀,问题使用 query: 前缀。二者虽然不同,却是模型训练时配套的两种输入方式。Multilingual E5 模型说明

Sentence Transformers 提供 encode_document() 和 encode_query(),可以按模型配置选择各自的提示或处理路径。应当遵循模型的文档编码和查询编码约定,而不是为了让配置看起来一样,删掉其中一侧需要的前缀。Sentence Transformers 编码接口说明

同样,如果模型明确提供相互兼容的查询编码器与文档编码器,也不能只凭“不是同一个对象”判定它们不兼容。这里讨论的是没有这种兼容保证、直接把一个 Embedding 模型换成另一个的情况。

最后要区分 Embedding 模型与生成答案的聊天模型:

问题 → Embedding 模型 → 搜索文档片段
                              ↓
                    片段和问题交给聊天模型
                              ↓
                          生成回答

只更换负责生成回答的聊天模型,而文档、Embedding 配置和检索方式不变,不需要因此重建向量索引。需要重新计算文档向量的是检索所用的编码方式发生了不兼容变化。