Files
iomgaa 6bdb802f01 chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.)
- Add claude hooks (pre-commit-guard, post-edit-quality)
- Add research templates (experiment plan, research brief, etc.)
- Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa)
- Add TRM4 reference implementation as algorithm fidelity baseline
- Add research-wiki content (plans, index, graph, query_pack)
- Update .gitignore to exclude .graphify_version runtime state
2026-07-06 20:59:03 -04:00

191 lines
5.8 KiB
Python
Raw Permalink 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.
"""
嵌入服务模块
============
封装文本嵌入器,支持本地 sentence-transformers 和远程 OpenAI 兼容 API 两种后端。
提供统一的 ``embed()`` / ``embed_tensor()`` 接口,冻结不训练。
使用方式::
from video_tree_trm.embeddings import EmbeddingModel
from video_tree_trm.config import Config
cfg = Config.load("config/default.yaml")
model = EmbeddingModel(cfg.embed)
vecs = model.embed(["你好世界"]) # ndarray [1, D]
tens = model.embed_tensor(["你好"]) # Tensor [1, D]
"""
from __future__ import annotations
from typing import List, Union
import numpy as np
import torch
from numpy import ndarray
from torch import Tensor
from utils.logger_system import ensure, log_msg
from video_tree_trm.config import EmbedConfig
class EmbeddingModel:
"""文本嵌入器封装(冻结),支持本地和远程双后端。
本地模式: 使用 sentence-transformers 加载 HuggingFace 模型,本地推理。
远程模式: 调用 OpenAI 兼容 API(如 GPUStack 上的 qwen3-embedding)。
属性:
dim: 嵌入维度 D。
"""
def __init__(self, config: EmbedConfig) -> None:
"""初始化嵌入模型。
参数:
config: 嵌入配置,包含 backend、model_name、embed_dim 等。
异常:
ValueError: backend 不是 "local" 或 "remote"。
ValueError: 远程模式缺少 api_key 或 api_url。
"""
ensure(
config.backend in ("local", "remote"),
f"embed.backend 必须为 'local' 或 'remote',实际为 '{config.backend}'",
)
self._backend = config.backend
self._dim = config.embed_dim
if self._backend == "local":
self._init_local(config)
else:
self._init_remote(config)
log_msg(
"INFO", "嵌入模型初始化完成", backend=self._backend, model=config.model_name
)
# ------------------------------------------------------------------
# 初始化
# ------------------------------------------------------------------
def _init_local(self, config: EmbedConfig) -> None:
"""初始化本地 sentence-transformers 模型。
参数:
config: 嵌入配置。
"""
from sentence_transformers import SentenceTransformer
self._model = SentenceTransformer(config.model_name, device=config.device)
self._model.eval()
# 冻结所有参数
for param in self._model.parameters():
param.requires_grad = False
actual_dim = self._model.get_sentence_embedding_dimension()
ensure(
actual_dim == self._dim,
f"模型实际维度 ({actual_dim}) 与配置 embed_dim ({self._dim}) 不一致",
)
def _init_remote(self, config: EmbedConfig) -> None:
"""初始化远程 OpenAI 兼容 API 客户端。
参数:
config: 嵌入配置。
"""
ensure(bool(config.api_key), "远程模式必须提供 embed.api_key")
ensure(bool(config.api_url), "远程模式必须提供 embed.api_url")
from openai import OpenAI
self._client = OpenAI(base_url=config.api_url, api_key=config.api_key)
self._model_name = config.model_name
# ------------------------------------------------------------------
# 公共接口
# ------------------------------------------------------------------
@property
def dim(self) -> int:
"""嵌入维度 D。"""
return self._dim
def embed(self, texts: Union[str, List[str]]) -> ndarray:
"""文本 → 嵌入向量(L2 归一化)。
参数:
texts: 单条文本或文本列表。
返回:
[N, D] ndarray,每行 L2 范数为 1.0。单条文本时 N=1。
"""
if isinstance(texts, str):
texts = [texts]
if self._backend == "local":
return self._embed_local(texts)
return self._embed_remote(texts)
def embed_tensor(self, texts: Union[str, List[str]]) -> Tensor:
"""文本 → 嵌入 TensorL2 归一化)。
参数:
texts: 单条文本或文本列表。
返回:
[N, D] torch.Tensorfloat32)。
"""
arr = self.embed(texts)
return torch.from_numpy(arr).float()
# ------------------------------------------------------------------
# 后端实现
# ------------------------------------------------------------------
def _embed_local(self, texts: List[str]) -> ndarray:
"""本地 sentence-transformers 推理。
参数:
texts: 文本列表。
返回:
[N, D] ndarrayL2 归一化。
"""
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_remote(self, texts: List[str]) -> ndarray:
"""远程 OpenAI 兼容 API 调用。
参数:
texts: 文本列表。
返回:
[N, D] ndarrayL2 归一化。
"""
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