chore: combine chunk indexes and embedding evaluation

This commit is contained in:
2026-10-16 09:33:36 +08:00
11 changed files with 643 additions and 82 deletions
+165
View File
@@ -0,0 +1,165 @@
"""Optional local embeddings; importing this module never loads model weights or PyTorch."""
import hashlib
import json
import math
import re
from dataclasses import asdict, dataclass
from typing import Protocol
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
class EmbeddingError(ValueError):
pass
@dataclass(frozen=True)
class EmbeddingSpec:
model: str
revision: str
dimension: int
token_limit: int = 512
query_instruction: str = QUERY_INSTRUCTION
implementation: str = "hf-cls-l2-cpu-fp32-v1"
checkpoint_format: str = "safetensors"
def __post_init__(self):
if not isinstance(self.revision, str) or not re.fullmatch(r"[0-9a-f]{40}", self.revision):
raise EmbeddingError("模型必须固定到完整 revision SHA")
if (
type(self.dimension) is not int
or type(self.token_limit) is not int
or self.dimension < 1
or self.token_limit < 2
):
raise EmbeddingError("向量维度和 token 上限无效")
if (
not isinstance(self.model, str)
or not self.model
or not isinstance(self.query_instruction, str)
):
raise EmbeddingError("模型名称或查询指令无效")
if self.checkpoint_format not in {"safetensors", "pytorch-weights-only"}:
raise EmbeddingError("不支持的权重格式")
@property
def space_id(self) -> str:
return hashlib.sha256(
json.dumps(asdict(self), sort_keys=True, ensure_ascii=False).encode()
).hexdigest()
SMALL_ZH = EmbeddingSpec("BAAI/bge-small-zh-v1.5", "7999e1d3359715c523056ef9478215996d62a620", 512)
BASE_ZH = EmbeddingSpec(
"BAAI/bge-base-zh-v1.5",
"f03589ceff5aac7111bd60cfc7d497ca17ecac65",
768,
checkpoint_format="pytorch-weights-only",
)
LOCAL_MODELS = {"small": SMALL_ZH, "base": BASE_ZH}
class EmbeddingProvider(Protocol):
spec: EmbeddingSpec
def encode(self, texts: list[str], *, query: bool = False) -> list[list[float]]: ...
def validate_vectors(vectors: list[list[float]], count: int, spec: EmbeddingSpec):
if len(vectors) != count:
raise EmbeddingError("模型返回数量与输入不一致")
for vector in vectors:
try:
finite = all(type(x) in {float, int} and math.isfinite(x) for x in vector)
except (TypeError, OverflowError):
finite = False
if len(vector) != spec.dimension or not finite:
raise EmbeddingError("模型返回维度错误或非有限数值")
if not math.isclose(sum(x * x for x in vector), 1, abs_tol=1e-4):
raise EmbeddingError("模型输出没有正确 L2 归一化")
class LocalEmbeddingProvider:
def __init__(
self,
spec: EmbeddingSpec,
*,
batch_size: int = 8,
threads: int = 2,
cache_dir=None,
offline: bool = True,
):
if (
type(batch_size) is not int
or type(threads) is not int
or not 1 <= batch_size <= 32
or not 1 <= threads <= 4
):
raise EmbeddingError("CPU batch_size 必须为 1–32,threads 必须为 1–4")
import torch
from huggingface_hub import snapshot_download
from transformers import AutoModel, AutoTokenizer
torch.set_num_threads(threads)
self.spec, self.batch_size, self.torch = spec, batch_size, torch
path = snapshot_download(
repo_id=spec.model,
revision=spec.revision,
cache_dir=cache_dir,
local_files_only=offline,
token=False,
allow_patterns=[
"config.json",
"model.safetensors"
if spec.checkpoint_format == "safetensors"
else "pytorch_model.bin",
"tokenizer.json",
"tokenizer_config.json",
"special_tokens_map.json",
"vocab.txt",
"added_tokens.json",
],
)
self.tokenizer = AutoTokenizer.from_pretrained(
path, local_files_only=True, trust_remote_code=False
)
self.model = (
AutoModel.from_pretrained(
path,
local_files_only=True,
trust_remote_code=False,
use_safetensors=spec.checkpoint_format == "safetensors",
weights_only=True,
dtype=torch.float32,
)
.to("cpu")
.eval()
)
if self.model.config.hidden_size != spec.dimension:
raise EmbeddingError("模型实际维度与配置不一致")
if self.tokenizer.model_max_length != spec.token_limit:
raise EmbeddingError("模型 tokenizer 上限与配置不一致")
def encode(self, texts: list[str], *, query: bool = False) -> list[list[float]]:
if not isinstance(texts, list) or any(
not isinstance(text, str) or not text.strip() or len(text) > 10_000 for text in texts
):
raise EmbeddingError("每条输入须为非空文本且最多 10000 字符")
torch = self.torch
vectors = []
for offset in range(0, len(texts), self.batch_size):
batch = texts[offset : offset + self.batch_size]
if query:
batch = [self.spec.query_instruction + text for text in batch]
encoded = self.tokenizer(
batch, padding=True, truncation=False, return_tensors="pt", verbose=False
)
if encoded["input_ids"].shape[1] > self.spec.token_limit:
raise EmbeddingError("输入超出模型 token 上限;请重新切分,禁止静默截断")
with torch.inference_mode():
features = self.model(**encoded).last_hidden_state[:, 0]
normalized = torch.nn.functional.normalize(features, p=2, dim=1)
vectors.extend(normalized.tolist())
validate_vectors(vectors, len(texts), self.spec)
return vectors