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
259 lines
9.9 KiB
Python
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
|