"""嵌入适配器 —— local/remote 双后端实现。 封装文本嵌入器,支持本地 sentence-transformers 和远程 OpenAI 兼容 API 两种后端。 提供统一的 ``embed()`` / ``embed_tensor()`` 接口,冻结不训练。 两个类均满足 ``app.ports.EmbeddingProvider`` Protocol。 """ from __future__ import annotations from typing import TYPE_CHECKING import numpy as np import torch from loguru import logger if TYPE_CHECKING: from numpy import ndarray from torch import Tensor class LocalEmbeddingProvider: """本地 sentence-transformers 嵌入器(冻结)。 使用 HuggingFace sentence-transformers 加载模型进行本地推理, 所有参数冻结,仅用于嵌入提取。 属性: dim: 嵌入维度 D。 """ def __init__(self, model_name: str, embed_dim: int, device: str = "cpu") -> None: """初始化本地嵌入模型。 参数: model_name: HuggingFace 模型名称(如 'BAAI/bge-base-zh-v1.5')。 embed_dim: 期望的嵌入维度。 device: 推理设备('cpu' / 'cuda' 等)。 异常: AssertionError: 模型实际维度与 embed_dim 不一致。 """ from sentence_transformers import SentenceTransformer self._dim = embed_dim self._model = SentenceTransformer(model_name, device=device) self._model.eval() # 冻结所有参数 for param in self._model.parameters(): param.requires_grad = False actual_dim = self._model.get_sentence_embedding_dimension() assert actual_dim == self._dim, ( f"模型实际维度 ({actual_dim}) 与配置 embed_dim ({self._dim}) 不一致" ) logger.info("本地嵌入模型初始化完成", model=model_name, device=device) # ------------------------------------------------------------------ # 公共接口 # ------------------------------------------------------------------ @property def dim(self) -> int: """嵌入维度 D。""" return self._dim def embed(self, texts: str | list[str]) -> ndarray: """文本 → 嵌入向量(L2 归一化)。 参数: texts: 单条文本或文本列表。 返回: [N, D] ndarray,每行 L2 范数为 1.0。单条文本时 N=1。 """ if isinstance(texts, str): texts = [texts] with torch.no_grad(): embeddings = self._model.encode( texts, normalize_embeddings=True, convert_to_numpy=True, ) # sentence-transformers encode 返回 ndarray [N, D] if embeddings.ndim == 1: embeddings = embeddings.reshape(1, -1) return embeddings def embed_tensor(self, texts: str | list[str]) -> Tensor: """文本 → 嵌入 Tensor(L2 归一化)。 参数: texts: 单条文本或文本列表。 返回: [N, D] torch.Tensor(float32)。 """ arr = self.embed(texts) return torch.from_numpy(arr).float() class RemoteEmbeddingProvider: """远程 OpenAI 兼容 API 嵌入器。 通过 OpenAI 兼容 API(如 GPUStack)调用远程嵌入模型。 属性: dim: 嵌入维度 D。 """ def __init__(self, model_name: str, embed_dim: int, api_key: str, api_url: str) -> None: """初始化远程嵌入客户端。 参数: model_name: 远程模型名称。 embed_dim: 期望的嵌入维度。 api_key: API 密钥。 api_url: API 基础 URL。 异常: ValueError: api_key 或 api_url 为空。 """ if not api_key: raise ValueError("远程模式必须提供 api_key") if not api_url: raise ValueError("远程模式必须提供 api_url") from openai import OpenAI self._dim = embed_dim self._model_name = model_name self._client = OpenAI(base_url=api_url, api_key=api_key) logger.info("远程嵌入客户端初始化完成", model=model_name, api_url=api_url) # ------------------------------------------------------------------ # 公共接口 # ------------------------------------------------------------------ @property def dim(self) -> int: """嵌入维度 D。""" return self._dim def embed(self, texts: str | list[str]) -> ndarray: """文本 → 嵌入向量(L2 归一化)。 参数: texts: 单条文本或文本列表。 返回: [N, D] ndarray,每行 L2 范数为 1.0。单条文本时 N=1。 """ if isinstance(texts, str): texts = [texts] response = self._client.embeddings.create( model=self._model_name, input=texts, ) # 按 index 排序,确保顺序一致 sorted_data = sorted(response.data, key=lambda x: x.index) embeddings = np.array([item.embedding for item in sorted_data], dtype=np.float32) # L2 归一化 norms = np.linalg.norm(embeddings, axis=1, keepdims=True) norms = np.maximum(norms, 1e-12) # 避免除零 embeddings = embeddings / norms return embeddings def embed_tensor(self, texts: str | list[str]) -> Tensor: """文本 → 嵌入 Tensor(L2 归一化)。 参数: texts: 单条文本或文本列表。 返回: [N, D] torch.Tensor(float32)。 """ arr = self.embed(texts) return torch.from_numpy(arr).float()