embeddings_queries now records the embedding request's token count (the unit upstream providers bill on) instead of an operation count, and fires on the indexing path too. Previously only semantic search recorded it (value=1), so a re-indexing run produced no embeddings_queries events at all — only pages_chunks. - Provider layer: additive embed_with_usage / embed_batch_with_usage surface the per-request token count (Mistral/OpenAI usage.total_tokens, Bedrock Titan inputTextTokenCount, Ollama prompt_eval_count); a char-based estimate is the fallback (Simple, and any provider/response without a token field). Gateway and EmbeddingService forward through. The count travels as a return value / a per-request SearchAlgorithm attribute — never on the singleton — so concurrent indexing + search can't mis-attribute bills. - Indexing (vector/processor.py): records embeddings_queries (value=batch tokens) alongside the existing pages_chunks event. - Search (server/semantic.py): value is now the query embedding's token count, relayed from BM25HybridSearchAlgorithm via query_token_count. The astrolabe_embeddings_queries Stripe meter (sum aggregation) now sums tokens with no CP/Terraform change. The meter "queries"->tokens naming/unit clarification (homelab-terraform #254) + CP rollup/portal copy is a follow-up. Deck #67. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
4.5 KiB
Python
146 lines
4.5 KiB
Python
"""Embedding service with provider detection.
|
|
|
|
DEPRECATED: This module is maintained for backward compatibility.
|
|
New code should use nextcloud_mcp_server.providers.get_provider() directly.
|
|
"""
|
|
|
|
import logging
|
|
|
|
import anyio
|
|
|
|
from nextcloud_mcp_server.providers import get_provider
|
|
|
|
from .bm25_provider import BM25SparseEmbeddingProvider
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EmbeddingService:
|
|
"""
|
|
Unified embedding service with automatic provider detection.
|
|
|
|
DEPRECATED: This class wraps the new unified provider infrastructure
|
|
for backward compatibility. New code should use
|
|
nextcloud_mcp_server.providers.get_provider() directly.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize embedding service with auto-detected provider."""
|
|
self.provider = get_provider()
|
|
|
|
async def embed(self, text: str) -> list[float]:
|
|
"""
|
|
Generate embedding vector for text.
|
|
|
|
Args:
|
|
text: Input text to embed
|
|
|
|
Returns:
|
|
Vector embedding as list of floats
|
|
"""
|
|
return await self.provider.embed(text)
|
|
|
|
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
|
"""
|
|
Generate embeddings for multiple texts.
|
|
|
|
Args:
|
|
texts: List of texts to embed
|
|
|
|
Returns:
|
|
List of vector embeddings
|
|
"""
|
|
return await self.provider.embed_batch(texts)
|
|
|
|
async def embed_with_usage(self, text: str) -> tuple[list[float], int]:
|
|
"""Embed one text and report the request's token count.
|
|
|
|
Returns ``(embedding, token_count)`` for usage metering (Deck #67).
|
|
"""
|
|
return await self.provider.embed_with_usage(text)
|
|
|
|
async def embed_batch_with_usage(
|
|
self, texts: list[str]
|
|
) -> tuple[list[list[float]], int]:
|
|
"""Embed multiple texts and report the total token count.
|
|
|
|
Returns ``(embeddings, token_count)`` for usage metering (Deck #67).
|
|
"""
|
|
return await self.provider.embed_batch_with_usage(texts)
|
|
|
|
def get_dimension(self) -> int:
|
|
"""
|
|
Get embedding dimension.
|
|
|
|
Returns:
|
|
Vector dimension
|
|
"""
|
|
return self.provider.get_dimension()
|
|
|
|
async def close(self):
|
|
"""Close provider resources."""
|
|
if hasattr(self.provider, "close") and callable(
|
|
getattr(self.provider, "close")
|
|
):
|
|
close_method = getattr(self.provider, "close")
|
|
await close_method()
|
|
|
|
|
|
# Singleton instance
|
|
_embedding_service: EmbeddingService | None = None
|
|
|
|
|
|
def get_embedding_service() -> EmbeddingService:
|
|
"""
|
|
Get singleton embedding service instance.
|
|
|
|
Returns:
|
|
Global EmbeddingService instance
|
|
"""
|
|
global _embedding_service
|
|
if _embedding_service is None:
|
|
_embedding_service = EmbeddingService()
|
|
return _embedding_service
|
|
|
|
|
|
# BM25 sparse embedding singleton
|
|
_bm25_service: BM25SparseEmbeddingProvider | None = None
|
|
|
|
|
|
async def get_bm25_service() -> BM25SparseEmbeddingProvider:
|
|
"""
|
|
Get singleton BM25 sparse embedding service instance.
|
|
|
|
Lazily instantiates the singleton off the event loop. The
|
|
``BM25SparseEmbeddingProvider`` constructor calls
|
|
``fastembed.SparseTextEmbedding(model_name="Qdrant/bm25")`` which
|
|
downloads ~50 MB of model weights from HuggingFace and loads them
|
|
into memory — observed >5 s wall-clock in production, enough to
|
|
stall the calling thread. The encode methods on the provider
|
|
already offload via ``anyio.to_thread.run_sync``; this routes the
|
|
first-time init through the same path so the event loop stays
|
|
responsive (kubernetes ``/health/live`` httpGet probe in
|
|
particular).
|
|
|
|
The singleton is process-wide so the per-pod cost is paid once.
|
|
Subsequent calls hit the warm path and return after a single
|
|
non-blocking await.
|
|
|
|
Concurrent first callers race: two coroutines can both observe
|
|
``_bm25_service is None`` and both enter ``run_sync``. We accept
|
|
the duplicate model load over an ``asyncio.Lock``, which has its
|
|
own cross-loop hazards under anyio TaskGroups (see PR #799 for
|
|
that class of bug). The duplicate is bounded — FastEmbed caches
|
|
the downloaded weights on disk after the first call, so loser(s)
|
|
pick up cheaply.
|
|
|
|
Returns:
|
|
Global BM25SparseEmbeddingProvider instance
|
|
"""
|
|
global _bm25_service
|
|
if _bm25_service is None:
|
|
_bm25_service = await anyio.to_thread.run_sync( # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
|
|
BM25SparseEmbeddingProvider
|
|
)
|
|
return _bm25_service
|