Files
ai-ticket-platform/backend/apps/knowledge/embeddings.py
2026-07-27 19:01:19 +08:00

109 lines
3.6 KiB
Python

"""Embedding 模型选择、批量调用和受控测试回退。"""
import hashlib
import math
from dataclasses import dataclass
from django.conf import settings
from openai import OpenAI
from apps.modelhub.crypto import decrypt_secret
from apps.modelhub.models import ModelDeployment
class KnowledgeEmbeddingError(RuntimeError):
pass
@dataclass(frozen=True)
class EmbeddingBatch:
vectors: list[list[float]]
model_name: str
deployment_id: int | None
fallback: bool
def hash_embedding(content: str, dimensions: int | None = None) -> list[float]:
size = dimensions or settings.KNOWLEDGE_EMBEDDING_DIMENSIONS
values = [0.0] * size
encoded = content.encode("utf-8")
for index in range(size):
digest = hashlib.sha256(encoded + index.to_bytes(4, "big")).digest()
values[index] = (int.from_bytes(digest[:4], "big") / 2**32) * 2 - 1
length = math.sqrt(sum(value * value for value in values)) or 1.0
return [value / length for value in values]
def get_embedding_deployment() -> ModelDeployment | None:
queryset = ModelDeployment.objects.select_related("provider").filter(
capability="embedding",
enabled=True,
provider__enabled=True,
)
configured_id = settings.KNOWLEDGE_EMBEDDING_DEPLOYMENT_ID
if configured_id:
return queryset.filter(id=configured_id).first()
return queryset.order_by("id").first()
def _request_embeddings(
deployment: ModelDeployment,
contents: list[str],
dimensions: int,
) -> list[list[float]]:
provider = deployment.provider
client = OpenAI(
api_key=decrypt_secret(provider.api_key_encrypted),
base_url=provider.base_url,
timeout=min(max(provider.default_timeout_seconds, 1), 300),
max_retries=0,
)
response = client.embeddings.create(
model=deployment.model_name,
input=contents,
dimensions=dimensions,
encoding_format="float",
)
return [list(item.embedding) for item in sorted(response.data, key=lambda item: item.index)]
def embed_contents(
contents: list[str],
*,
allow_fallback: bool | None = None,
) -> EmbeddingBatch:
"""批量生成固定维度向量;哈希回退必须由配置或调用方显式允许。"""
if not contents:
return EmbeddingBatch([], "", None, False)
dimensions = settings.KNOWLEDGE_EMBEDDING_DIMENSIONS
deployment = get_embedding_deployment()
fallback_allowed = (
settings.KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK
if allow_fallback is None
else allow_fallback
)
if deployment is None:
if not fallback_allowed:
raise KnowledgeEmbeddingError("未配置可用的 Embedding 模型部署")
return EmbeddingBatch(
[hash_embedding(content, dimensions) for content in contents],
f"local-hash-v2-{dimensions}",
None,
True,
)
try:
vectors = _request_embeddings(deployment, contents, dimensions)
except Exception as error:
if not fallback_allowed:
raise KnowledgeEmbeddingError("Embedding 模型调用失败") from error
return EmbeddingBatch(
[hash_embedding(content, dimensions) for content in contents],
f"local-hash-v2-{dimensions}",
None,
True,
)
if len(vectors) != len(contents) or any(len(vector) != dimensions for vector in vectors):
raise KnowledgeEmbeddingError("Embedding 模型返回的向量数量或维度不符合配置")
return EmbeddingBatch(vectors, deployment.model_name, deployment.id, False)