Files
md-vector-db/src/core/embedder.py
T

232 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""嵌入模型抽象层 — 策略模式.
支持的 Provider:
openai — OpenAI / 硅基流动 / 智谱 / DeepSeek / 月之暗面 等 OpenAI 兼容服务
dashscope — 阿里云 DashScope (通义千问)
使用方式:
from src.core.config import EmbedConfig
from src.core.embedder import create_embedder, batch_embed
# 本地模型
embedder = create_embedder(EmbedConfig(mode="local"))
# OpenAI 兼容 API
embedder = create_embedder(EmbedConfig(mode="api", provider="openai"))
# 阿里云 DashScope
embedder = create_embedder(EmbedConfig(mode="api", provider="dashscope"))
vectors = batch_embed(embedder, long_text_list)
"""
import logging
import os
import threading
from typing import Protocol
from src.core.config import EmbedConfig
logger = logging.getLogger("md-vector-db")
_HF_MIRROR = os.environ.get("HF_MIRROR", "https://hf-mirror.com")
_HF_ENV_LOCK = threading.Lock()
# -- Provider 默认配置 --
_PROVIDER_DEFAULTS: dict[str, dict[str, str | int]] = {
"openai": {
"api_base": "https://api.openai.com/v1",
"model": "text-embedding-3-small",
"dimension": 1536,
},
"dashscope": {
"api_base": "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding",
"model": "text-embedding-v4",
"dimension": 1536,
},
}
# -- 接口 --
class Embedder(Protocol):
"""嵌入器接口."""
@property
def dimension(self) -> int:
"""返回嵌入向量的维度."""
...
def embed(self, texts: list[str]) -> list[list[float]]:
"""对文本列表进行嵌入.
Args:
texts: 待嵌入的文本列表
Returns:
嵌入向量列表,每个向量为 float 列表
"""
...
# -- 本地模型 --
class LocalEmbedder:
"""sentence-transformers 本地模型嵌入器. 自动检测 GPU."""
def __init__(self, config: EmbedConfig):
from sentence_transformers import SentenceTransformer
# 自动检测 GPU
try:
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
if device == "cuda":
logger.info("检测到 GPU: %s", torch.cuda.get_device_name(0))
except ImportError:
device = "cpu"
self._config = config
try:
self._model = SentenceTransformer(
config.local_model, local_files_only=True, device=device
)
except Exception:
logger.info("模型未缓存, 通过镜像下载 %s", config.local_model)
# 通过 HF_ENDPOINT 环境变量设置镜像(sentence-transformers 依赖 huggingface_hub
# 临时设置仅用于模型下载,下载完成后还原
old_endpoint = os.environ.get("HF_ENDPOINT")
with _HF_ENV_LOCK:
os.environ["HF_ENDPOINT"] = _HF_MIRROR
try:
self._model = SentenceTransformer(
config.local_model, device=device
)
finally:
with _HF_ENV_LOCK:
if old_endpoint is not None:
os.environ["HF_ENDPOINT"] = old_endpoint
else:
os.environ.pop("HF_ENDPOINT", None)
@property
def dimension(self) -> int:
return self._model.get_embedding_dimension()
def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
raise ValueError("文本列表不能为空")
embeddings = self._model.encode(texts, normalize_embeddings=True)
return embeddings.tolist()
# -- API Provider 基类 --
class _BaseAPIEmbedder:
"""API 嵌入器基类: 统一 api_base/model 解析逻辑."""
def __init__(self, config: EmbedConfig, provider: str):
defaults = _PROVIDER_DEFAULTS.get(provider, {})
self._api_base = config.api_base or str(defaults.get("api_base", ""))
self._model = config.model or str(defaults.get("model", ""))
self._api_key = config.api_key
self._dimension = int(defaults.get("dimension", 1536))
@property
def dimension(self) -> int:
return self._dimension
def embed(self, texts: list[str]) -> list[list[float]]:
raise NotImplementedError
# -- OpenAI 兼容 API --
class OpenAIEmbedder(_BaseAPIEmbedder):
"""OpenAI / 硅基流动 / 智谱 / DeepSeek / 月之暗面 等."""
def __init__(self, config: EmbedConfig):
super().__init__(config, "openai")
self._client = None # 懒初始化,首次 embed() 时创建
def _ensure_client(self):
"""懒创建 OpenAI 客户端(避免 import 时依赖 openai 包)."""
if self._client is None:
from openai import OpenAI
self._client = OpenAI(base_url=self._api_base, api_key=self._api_key)
def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
raise ValueError("文本列表不能为空")
self._ensure_client()
response = self._client.embeddings.create(model=self._model, input=texts)
return [d.embedding for d in response.data]
# -- 阿里云 DashScope --
class DashscopeEmbedder(_BaseAPIEmbedder):
"""阿里云 DashScope 嵌入 (自定义 HTTP API, 非 OpenAI 兼容)."""
def __init__(self, config: EmbedConfig):
super().__init__(config, "dashscope")
def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
raise ValueError("文本列表不能为空")
import requests # 惰性导入(仅 DashScope 使用)
resp = requests.post(
self._api_base,
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json={
"model": self._model,
"input": {"texts": texts},
},
timeout=60,
)
resp.raise_for_status()
data = resp.json()
# DashScope 返回: {"output": {"embeddings": [{"text_index": 0, "embedding": [...]}, ...]}}
output = data.get("output")
if output is None:
raise ValueError(f"DashScope 响应缺少 output 字段: {data}")
embeddings_raw = output.get("embeddings")
if not isinstance(embeddings_raw, list):
raise ValueError(f"DashScope embeddings 不是列表: {type(embeddings_raw)}")
# 按 text_index 排序确保顺序
embeddings_raw_sorted = sorted(embeddings_raw, key=lambda x: x.get("text_index", 0))
return [e["embedding"] for e in embeddings_raw_sorted]
# -- 工厂函数 --
_PROVIDER_CLASSES: dict[str, type[_BaseAPIEmbedder]] = {
"openai": OpenAIEmbedder,
"dashscope": DashscopeEmbedder,
}
SUPPORTED_PROVIDERS = list(_PROVIDER_CLASSES.keys())
def create_embedder(config: EmbedConfig) -> Embedder:
"""工厂函数: 根据配置创建嵌入器."""
if config.mode == "local":
return LocalEmbedder(config)
if config.mode == "api":
provider = config.provider or "openai"
cls = _PROVIDER_CLASSES.get(provider)
if cls is None:
raise ValueError(
f"不支持的 provider: {provider}, 可选: {SUPPORTED_PROVIDERS}"
)
return cls(config)
raise ValueError(f"不支持的嵌入模式: {config.mode}")
def batch_embed(
embedder: Embedder, texts: list[str], batch_size: int = 32
) -> list[list[float]]:
"""分批嵌入, 避免一次性传入过多文本导致 OOM."""
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
all_embeddings.extend(embedder.embed(batch))
return all_embeddings