PAYATHON 2026

LangChain FAISS 搭配 CLIP 时余弦相似度偏低,如何排查?

AI 小顾

结论

0.184、0.227、0.249 这些数值本身不足以说明检索异常,也不代表匹配正确率只有 18%~25%。先确认图像和文本是否使用了 CLIP 训练时对应的特征提取流程,再检查 FAISS 返回的是否为同一组向量的内积。

当前代码值得检查的地方是 get_image_features()、get_text_features() 的返回类型,以及 pooling 和 projection 是否重复、遗漏或用错。缺少实际依赖版本和运行时输出,无法确定这些分数具体由哪一处代码造成。

先修正 CLIP 特征提取流程

CLIP 的图像、文本编码器输出需要分别经过 visual_projection 和 text_projection,才能进入用于图文比较的共同空间。对于这里使用的 CLIP ViT,图像侧使用模型定义的 pooled output;文本侧使用 EOT/EOS 位置的表示,不能照搬 BERT 的 CLS Token 取法。Hugging Face CLIP 实现

原代码有以下几处需要核对:

  • 在 Transformers v4.57.1 中,get_image_features() 和 get_text_features() 已经返回经过 projection 的 Tensor,不能再读取其 .last_hidden_state 或 .pooler_output。其他版本应核对对应实现,不能假定返回结构始终相同。
  • 如果实际使用的是底层文本编码器的 pooler_output,还需要经过 text_projection。
  • 对图像 token 直接取 mean pooling 会改变预训练模型原有的特征提取流程。没有相应训练或评估依据时,应先恢复模型默认流程。

注释写的是 openai/clip-vit-base-patch32,实际加载的是 openai/clip-vit-large-patch14,应以实际加载的 checkpoint 为准。

下面的实现显式调用底层编码器和 projection,避免混用 feature helper 的返回结构。它保留原有的 img_*.jpg 路由约定,并在 embedding 层完成 L2 归一化:

from pathlib import Path

import torch
import torch.nn.functional as F
from PIL import Image
from langchain_core.embeddings import Embeddings
from transformers import CLIPModel, CLIPProcessor, CLIPTokenizer


class CLIPEmbeddings(Embeddings):
    def __init__(self, image_dir: str, device: str = "cpu"):
        model_id = "openai/clip-vit-large-patch14"
        self.device = device
        self.image_dir = Path(image_dir)
        self.model = CLIPModel.from_pretrained(model_id).to(device)
        self.model.eval()
        self.tokenizer = CLIPTokenizer.from_pretrained(model_id)
        self.processor = CLIPProcessor.from_pretrained(model_id)

    @torch.inference_mode()
    def get_image_embedding(self, image: Image.Image) -> list[float]:
        inputs = self.processor(
            images=image.convert("RGB"),
            return_tensors="pt",
        ).to(self.device)
        outputs = self.model.vision_model(**inputs)
        features = self.model.visual_projection(outputs.pooler_output)
        features = F.normalize(features.float(), p=2, dim=-1)
        return features.squeeze(0).cpu().tolist()

    @torch.inference_mode()
    def get_text_embedding(self, text: str) -> list[float]:
        inputs = self.tokenizer(
            [text],
            padding=True,
            truncation=True,
            max_length=self.model.config.text_config.max_position_embeddings,
            return_tensors="pt",
        ).to(self.device)
        outputs = self.model.text_model(**inputs)
        features = self.model.text_projection(outputs.pooler_output)
        features = F.normalize(features.float(), p=2, dim=-1)
        return features.squeeze(0).cpu().tolist()

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        embeddings = []
        for text in texts:
            if text.startswith("img_") and text.endswith(".jpg"):
                with Image.open(self.image_dir / text) as image:
                    embedding = self.get_image_embedding(image)
            else:
                embedding = self.get_text_embedding(text)
            embeddings.append(embedding)
        return embeddings

    def embed_query(self, text: str) -> list[float]:
        return self.get_text_embedding(text)

这里单个 embedding 返回一维列表,因此 embed_documents() 使用 append()。替换原代码中的 extend() 时,还要检查返回形状:embed_documents() 应返回二维列表,embed_query() 应返回一维列表。

示例使用的是标准 Hugging Face CLIP 的编码器与投影属性。使用其他封装或修改过的模型时,需要核对对应接口。

让 FAISS 配置与向量含义保持一致

对于非零向量:

[ \hat{x}=\frac{x}{|x|_2},\qquad \hat{y}=\frac{y}{|y|_2},\qquad \cos(x,y)=\hat{x}^{\mathsf T}\hat{y} ]

数据库向量和查询向量都完成 L2 归一化后,IndexFlatIP 的内积就是余弦相似度,分数越大越相似。仅归一化一侧不够。FAISS 度量说明

上面的类已经对两侧做了归一化,FAISS 封装可以按如下方式初始化。将 ./images 替换为实际图片目录:

import faiss
from langchain_community.docstore.in_memory import InMemoryDocstore
from langchain_community.vectorstores import FAISS
from langchain_community.vectorstores.utils import DistanceStrategy

clip_embeddings = CLIPEmbeddings(image_dir="./images")

vectorstore = FAISS(
    embedding_function=clip_embeddings,
    index=faiss.IndexFlatIP(clip_embeddings.model.config.projection_dim),
    docstore=InMemoryDocstore({}),
    index_to_docstore_id={},
    normalize_L2=False,
    distance_strategy=DistanceStrategy.MAX_INNER_PRODUCT,
)

image_names = sorted(
    path.name for path in clip_embeddings.image_dir.glob("img_*.jpg")
)
if not image_names:
    raise ValueError("No matching images found")

vectorstore.add_texts(image_names)

query = "A sunrise or sunset in the sky with orange shades"
results = vectorstore.similarity_search_with_score(
    query, k=min(3, len(image_names))
)
for doc, score in results:
    print(doc.page_content, float(score))

normalize_L2=False 的前提是所有写入和查询都经过上述归一化方法。修改模型、pooling、projection 或预处理后,必须重新生成全部图片向量并重建索引,不能继续使用旧向量。

LangChain 的 FAISS 封装接收 Embeddings 接口提供的数值向量,可以通过自定义类把图片标识转换成图片向量。这里存储的文档内容是图片文件名,参与检索的是该文件对应的图像 embedding。

显式设置 MAX_INNER_PRODUCT 可以让原始分数阈值采用“越大越相关”的比较方向。它不会自动补齐 projection,也不会改变已经创建的 FAISS 索引类型。similarity_search_with_score() 返回底层索引分数;similarity_search_with_relevance_scores() 还涉及分数转换,排查阶段应先使用前者。LangChain FAISS 源码

用手算内积检查评分链路

图片向量写入新索引后,可以读取实际存储的向量,检查范数,并比较手算结果、FAISS 原始结果和封装结果:

import numpy as np

q = np.asarray(clip_embeddings.embed_query(query), dtype=np.float32)
vectors = np.stack([
    vectorstore.index.reconstruct(i)
    for i in range(vectorstore.index.ntotal)
])

print("query shape:", q.shape)
print("query norm:", np.linalg.norm(q))
print("stored norms:", np.linalg.norm(vectors, axis=1))

manual_scores = vectors @ q
k = min(3, vectorstore.index.ntotal)
scores, positions = vectorstore.index.search(q[None, :], k)

for position, score in zip(positions[0], scores[0]):
    print(
        "position:", int(position),
        "manual:", float(manual_scores[position]),
        "faiss:", float(score),
    )

print("wrapper:", [float(score) for _, score in results])

在相同查询、相同索引、没有额外过滤的条件下,手算内积应与 FAISS 分数在浮点误差范围内一致,封装返回的分数也应一致。如果不一致,再检查旧索引、归一化路径、查询编码方式及返回结果映射。

这个检查只能确认分数计算链路是否一致。即使维度、范数和内积都正确,也不能证明图文表示具有良好的语义匹配能力。

如何判断分数偏低是否需要处理

检索结果正确,说明相关图片在当前候选集合里排名靠前,并不意味着图文向量必须接近平行。也不能把图像与自身比较时接近 1 的分数当成文本与图像匹配的目标值。

还要区分原始 cosine similarity、CLIP 缩放后的 logits,以及对候选集合计算 softmax 后的相对概率。这些数值含义不同,不能直接比较;softmax 的结果还会随候选集合变化。CLIP 官方使用说明

完成实现修正后,建议按以下顺序评估:

  1. 收集一组有明确相关图片的查询,同时加入无关图片和容易混淆的图片,观察正负样本分数分布。
  2. 检查 Recall@k、首位命中情况以及误匹配,依据实际分布选择阈值。现有三个查询不足以确定通用阈值。
  3. 比较简洁、准确的图片描述与原查询。添加图片中没有的颜色、背景或细节,可能使查询偏离目标;是否改善应以同一组查询的评估结果为准。
  4. 如果实现正确但仍无法满足需求,再考虑领域数据微调或其他图文模型,并用相同评估集比较。

给分数乘常数或映射到 [0, 1] 只能改变展示尺度,不能改善匹配排序。也没有依据保证修正代码后,这几组查询的余弦相似度一定会上升。

备注:内容仅供参考。