Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

259 lines
9.9 KiB
Python

"""Unified embedding client backed by normalized provider runtime config."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from deeptutor.services.config.provider_runtime import (
EMBEDDING_PROVIDERS,
embedding_endpoint_validation_error,
)
from .adapters import ADAPTER_BACKENDS, BaseEmbeddingAdapter, EmbeddingRequest
from .config import EmbeddingConfig, get_embedding_config
from .validation import validate_embedding_batch
def _resolve_adapter_class(binding: str) -> type[BaseEmbeddingAdapter]:
provider = (binding or "").strip().lower()
spec = EMBEDDING_PROVIDERS.get(provider)
if spec is None:
supported = sorted(EMBEDDING_PROVIDERS.keys())
raise ValueError(
f"Unknown embedding binding: '{binding}'. Supported: {', '.join(supported)}"
)
cls = ADAPTER_BACKENDS.get(spec.adapter)
if cls is None:
raise ValueError(
f"No adapter registered for backend '{spec.adapter}' (binding='{binding}')"
)
return cls
class EmbeddingClient:
"""Unified embedding client for RAG and retrieval services."""
def __init__(self, config: Optional[EmbeddingConfig] = None):
self.config = config or get_embedding_config()
self.logger = logging.getLogger(__name__)
endpoint = self.config.effective_url or self.config.base_url
problem = embedding_endpoint_validation_error(self.config.binding, endpoint)
if problem:
raise ValueError(
f"{problem} Current Settings endpoint is {endpoint!r}. "
"DeepTutor sends embedding requests to the Settings URL exactly; "
"update the visible Endpoint URL instead of relying on hidden path appending."
)
adapter_class = _resolve_adapter_class(self.config.binding)
self.adapter = adapter_class(
{
"api_key": self.config.api_key,
"base_url": self.config.effective_url or self.config.base_url,
"api_version": self.config.api_version,
"model": self.config.model,
"dimensions": self.config.dim,
"send_dimensions": self.config.send_dimensions,
"request_timeout": self.config.request_timeout,
"extra_headers": self.config.extra_headers or {},
}
)
self.logger.info(
f"Initialized embedding client with {self.config.binding} adapter "
f"(model: {self.config.model}, dimensions: {self.config.dim})"
)
async def embed(self, texts: List[str], progress_callback=None) -> List[List[float]]:
if not texts:
return []
import asyncio
# Clamp configured batch size against the provider's per-request item
# cap. SiliconFlow Qwen3 family caps at 32; DashScope at 20; others
# have generous defaults. Without this clamp, indexing a doc with many
# chunks fails on the second batch even when "Test connection" passes.
spec = EMBEDDING_PROVIDERS.get(self.config.binding)
provider_max = spec.max_batch_items if spec else 256
batch_size = max(1, min(self.config.batch_size, provider_max))
if batch_size < self.config.batch_size:
self.logger.info(
f"Clamped batch_size {self.config.batch_size} -> {batch_size} "
f"(provider '{self.config.binding}' max={provider_max})"
)
all_embeddings: List[List[float]] = []
batch_delay = self.config.batch_delay
expected_dim: int | None = None
total_batches = (len(texts) + batch_size - 1) // batch_size
for i, start in enumerate(range(0, len(texts), batch_size)):
batch = texts[start : start + batch_size]
request = EmbeddingRequest(
texts=batch,
model=self.config.model,
dimensions=self.config.dim or None,
)
try:
response = await self.adapter.embed(request)
except Exception as exc:
# Capture batch context so the task log stream / KB diagnostics
# show actionable info instead of a bare exception string.
import traceback
first_chunk_chars = len(batch[0]) if batch else 0
longest_chunk_chars = max((len(t) for t in batch), default=0)
self.logger.error(
f"Embedding batch failed "
f"(binding={self.config.binding}, model={self.config.model}, "
f"batch_index={i + 1}/{total_batches}, batch_items={len(batch)}, "
f"first_chunk_chars={first_chunk_chars}, "
f"longest_chunk_chars={longest_chunk_chars}): {exc}\n"
f"{traceback.format_exc()}"
)
raise
validated = validate_embedding_batch(
response.embeddings,
expected_count=len(batch),
binding=self.config.binding,
model=self.config.model,
batch_index=i + 1,
total_batches=total_batches,
start_index=start,
)
batch_dim = len(validated[0]) if validated else 0
if expected_dim is None:
expected_dim = batch_dim
elif batch_dim != expected_dim:
raise ValueError(
"Embedding provider returned inconsistent vector dimensions "
f"across batches (binding={self.config.binding}, "
f"model={self.config.model}): expected {expected_dim}, "
f"got {batch_dim} in batch {i + 1}/{total_batches}. "
"Use a single embedding model/dimension and re-index the knowledge base."
)
all_embeddings.extend(validated)
# Report progress after each batch
if progress_callback:
try:
progress_callback(i + 1, total_batches)
except Exception:
pass
# Delay between batches to avoid rate limiting
if i < total_batches - 1 and batch_delay > 0:
await asyncio.sleep(batch_delay)
self.logger.debug(
f"Generated {len(all_embeddings)} embeddings using "
f"{self.config.binding} (batch_size={batch_size})"
)
return all_embeddings
def supports_multimodal_contents(self) -> bool:
"""Return whether the configured adapter/model accepts multimodal contents."""
try:
info = self.adapter.get_model_info()
if "multimodal" in info:
return bool(info.get("multimodal"))
except Exception:
pass
spec = EMBEDDING_PROVIDERS.get(self.config.binding)
return bool(spec and spec.multimodal)
async def embed_contents(
self,
contents: List[Dict[str, Any]],
*,
progress_callback=None,
) -> List[List[float]]:
"""Embed provider-agnostic multimodal content items.
``contents`` uses the same simple contract as ``EmbeddingRequest``:
``[{"text": "..."}, {"image": "data:...|url"}, {"video": "..."}]``.
"""
if not contents:
return []
if not self.supports_multimodal_contents():
raise ValueError(
"Configured embedding provider/model does not support multimodal contents."
)
import asyncio
spec = EMBEDDING_PROVIDERS.get(self.config.binding)
provider_max = spec.max_batch_items if spec else 256
batch_size = max(1, min(self.config.batch_size, provider_max))
all_embeddings: List[List[float]] = []
total_batches = (len(contents) + batch_size - 1) // batch_size
for i, start in enumerate(range(0, len(contents), batch_size)):
batch = contents[start : start + batch_size]
request = EmbeddingRequest(
texts=[],
model=self.config.model,
dimensions=self.config.dim or None,
contents=batch,
enable_fusion=False,
)
response = await self.adapter.embed(request)
validated = validate_embedding_batch(
response.embeddings,
expected_count=len(batch),
binding=self.config.binding,
model=self.config.model,
batch_index=i + 1,
total_batches=total_batches,
start_index=start,
)
all_embeddings.extend(validated)
if progress_callback:
try:
progress_callback(i + 1, total_batches)
except Exception:
pass
if i < total_batches - 1 and self.config.batch_delay > 0:
await asyncio.sleep(self.config.batch_delay)
return all_embeddings
def embed_sync(self, texts: List[str]) -> List[List[float]]:
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(self.embed(texts))
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.embed(texts))
return future.result()
def get_embedding_func(self):
async def embedding_wrapper(texts: List[str]) -> List[List[float]]:
return await self.embed(texts)
return embedding_wrapper
_client: Optional[EmbeddingClient] = None
def get_embedding_client(config: Optional[EmbeddingConfig] = None) -> EmbeddingClient:
global _client
resolved_config = config or get_embedding_config()
if _client is None or _client.config != resolved_config:
_client = EmbeddingClient(resolved_config)
return _client
def reset_embedding_client() -> None:
global _client
_client = None