fasterrag 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fasterrag/__init__.py +26 -0
- fasterrag/adapters/__init__.py +7 -0
- fasterrag/adapters/embeddings/__init__.py +36 -0
- fasterrag/adapters/embeddings/base.py +317 -0
- fasterrag/adapters/embeddings/cohere.py +153 -0
- fasterrag/adapters/embeddings/factory.py +83 -0
- fasterrag/adapters/embeddings/huggingface.py +174 -0
- fasterrag/adapters/embeddings/ollama.py +141 -0
- fasterrag/adapters/embeddings/openai.py +131 -0
- fasterrag/adapters/embeddings/sync.py +46 -0
- fasterrag/adapters/embeddings/tiering.py +135 -0
- fasterrag/adapters/llm/__init__.py +27 -0
- fasterrag/adapters/llm/anthropic.py +119 -0
- fasterrag/adapters/llm/base.py +233 -0
- fasterrag/adapters/llm/cohere.py +163 -0
- fasterrag/adapters/llm/factory.py +82 -0
- fasterrag/adapters/llm/ollama.py +126 -0
- fasterrag/adapters/llm/openai.py +136 -0
- fasterrag/adapters/plugin_api.py +66 -0
- fasterrag/adapters/vectordb/__init__.py +31 -0
- fasterrag/adapters/vectordb/base.py +339 -0
- fasterrag/adapters/vectordb/chroma.py +1169 -0
- fasterrag/adapters/vectordb/factory.py +110 -0
- fasterrag/adapters/vectordb/milvus.py +1251 -0
- fasterrag/adapters/vectordb/pgvector.py +1288 -0
- fasterrag/adapters/vectordb/qdrant.py +756 -0
- fasterrag/adapters/vectordb/weaviate.py +1409 -0
- fasterrag/api/__init__.py +6 -0
- fasterrag/api/admin.py +158 -0
- fasterrag/api/auth.py +695 -0
- fasterrag/api/collections.py +215 -0
- fasterrag/api/dependencies.py +129 -0
- fasterrag/api/health.py +110 -0
- fasterrag/api/idempotency.py +290 -0
- fasterrag/api/ingest.py +279 -0
- fasterrag/api/limits.py +106 -0
- fasterrag/api/main.py +393 -0
- fasterrag/api/metrics.py +95 -0
- fasterrag/api/problems.py +217 -0
- fasterrag/api/query.py +201 -0
- fasterrag/api/schemas.py +130 -0
- fasterrag/api/traces.py +133 -0
- fasterrag/chunking.py +34 -0
- fasterrag/cli/__init__.py +13 -0
- fasterrag/cli/commands/__init__.py +6 -0
- fasterrag/cli/commands/autopilot.py +141 -0
- fasterrag/cli/commands/backup.py +93 -0
- fasterrag/cli/commands/benchmark.py +229 -0
- fasterrag/cli/commands/diagnostics.py +360 -0
- fasterrag/cli/commands/documents.py +87 -0
- fasterrag/cli/commands/infrastructure.py +125 -0
- fasterrag/cli/commands/pipeline.py +865 -0
- fasterrag/cli/commands/portability.py +112 -0
- fasterrag/cli/commands/processes.py +159 -0
- fasterrag/cli/commands/replay.py +115 -0
- fasterrag/cli/commands/traces.py +55 -0
- fasterrag/cli/main.py +149 -0
- fasterrag/cli/output.py +82 -0
- fasterrag/cli/parser.py +508 -0
- fasterrag/cli/settings.py +39 -0
- fasterrag/cli/sources.py +96 -0
- fasterrag/config/__init__.py +13 -0
- fasterrag/config/loader.py +289 -0
- fasterrag/config/plugins.py +118 -0
- fasterrag/config/schema.py +1089 -0
- fasterrag/config/template.py +82 -0
- fasterrag/core/__init__.py +6 -0
- fasterrag/core/breaker.py +251 -0
- fasterrag/core/cache/__init__.py +127 -0
- fasterrag/core/cache/embedding.py +308 -0
- fasterrag/core/cache/redis.py +287 -0
- fasterrag/core/cache/semantic.py +235 -0
- fasterrag/core/cache/stats.py +71 -0
- fasterrag/core/cache/store.py +419 -0
- fasterrag/core/chunking/__init__.py +87 -0
- fasterrag/core/chunking/enrichment.py +157 -0
- fasterrag/core/chunking/fixed.py +63 -0
- fasterrag/core/chunking/late.py +56 -0
- fasterrag/core/chunking/layout.py +120 -0
- fasterrag/core/chunking/models.py +235 -0
- fasterrag/core/chunking/pooling.py +154 -0
- fasterrag/core/chunking/recursive.py +131 -0
- fasterrag/core/chunking/semantic.py +205 -0
- fasterrag/core/chunking/tokenizer.py +113 -0
- fasterrag/core/confinement.py +52 -0
- fasterrag/core/context.py +208 -0
- fasterrag/core/evals/__init__.py +33 -0
- fasterrag/core/evals/generator.py +241 -0
- fasterrag/core/evals/golden.py +138 -0
- fasterrag/core/evals/harness.py +149 -0
- fasterrag/core/evals/metrics.py +67 -0
- fasterrag/core/faithfulness.py +176 -0
- fasterrag/core/generation.py +175 -0
- fasterrag/core/identity.py +202 -0
- fasterrag/core/parsing/__init__.py +215 -0
- fasterrag/core/parsing/docx.py +210 -0
- fasterrag/core/parsing/html.py +118 -0
- fasterrag/core/parsing/markdown.py +120 -0
- fasterrag/core/parsing/models.py +211 -0
- fasterrag/core/parsing/options.py +55 -0
- fasterrag/core/parsing/pdf.py +242 -0
- fasterrag/core/parsing/plaintext.py +97 -0
- fasterrag/core/parsing/tabular.py +191 -0
- fasterrag/core/rate_limit.py +259 -0
- fasterrag/core/rerank/__init__.py +9 -0
- fasterrag/core/rerank/cross_encoder.py +226 -0
- fasterrag/core/retrieval/__init__.py +30 -0
- fasterrag/core/retrieval/bm25.py +175 -0
- fasterrag/core/retrieval/fusion.py +121 -0
- fasterrag/core/retrieval/models.py +51 -0
- fasterrag/core/tenancy.py +19 -0
- fasterrag/core/tracing.py +199 -0
- fasterrag/data/.env.example +34 -0
- fasterrag/data/config.local.yaml +30 -0
- fasterrag/data/config.yaml +215 -0
- fasterrag/errors.py +321 -0
- fasterrag/evals.py +31 -0
- fasterrag/facade.py +432 -0
- fasterrag/observability/__init__.py +6 -0
- fasterrag/observability/dashboard.py +195 -0
- fasterrag/observability/langfuse_export.py +220 -0
- fasterrag/observability/logging.py +263 -0
- fasterrag/observability/metrics.py +466 -0
- fasterrag/observability/otel_export.py +267 -0
- fasterrag/observability/otel_metrics.py +248 -0
- fasterrag/parsing.py +28 -0
- fasterrag/py.typed +0 -0
- fasterrag/rerank.py +9 -0
- fasterrag/retrieval.py +21 -0
- fasterrag/services/__init__.py +6 -0
- fasterrag/services/archive.py +385 -0
- fasterrag/services/archive_import.py +1077 -0
- fasterrag/services/autopilot.py +864 -0
- fasterrag/services/backfill.py +407 -0
- fasterrag/services/backup.py +331 -0
- fasterrag/services/benchmark.py +305 -0
- fasterrag/services/budget.py +250 -0
- fasterrag/services/ci_status.py +533 -0
- fasterrag/services/composition.py +133 -0
- fasterrag/services/degradation.py +135 -0
- fasterrag/services/doctor.py +708 -0
- fasterrag/services/documents.py +300 -0
- fasterrag/services/embedding_router.py +68 -0
- fasterrag/services/estimation.py +489 -0
- fasterrag/services/evaluation.py +206 -0
- fasterrag/services/generation.py +1299 -0
- fasterrag/services/golden_sets.py +96 -0
- fasterrag/services/grafana.py +542 -0
- fasterrag/services/idempotency.py +616 -0
- fasterrag/services/ingestion.py +534 -0
- fasterrag/services/jobs.py +358 -0
- fasterrag/services/journal.py +1217 -0
- fasterrag/services/langfuse.py +465 -0
- fasterrag/services/lockfile.py +443 -0
- fasterrag/services/provision_registry.py +111 -0
- fasterrag/services/provisioning.py +452 -0
- fasterrag/services/querying.py +387 -0
- fasterrag/services/regression.py +410 -0
- fasterrag/services/reindex.py +424 -0
- fasterrag/services/replay.py +191 -0
- fasterrag/services/sources.py +523 -0
- fasterrag/services/tenancy.py +98 -0
- fasterrag/services/traces.py +333 -0
- fasterrag/sync.py +322 -0
- fasterrag/workers/__init__.py +34 -0
- fasterrag/workers/cpu_pool.py +925 -0
- fasterrag/workers/embed_pool.py +484 -0
- fasterrag/workers/indexer.py +211 -0
- fasterrag/workers/ledger.py +179 -0
- fasterrag/workers/queues.py +197 -0
- fasterrag-0.1.0b1.dist-info/METADATA +513 -0
- fasterrag-0.1.0b1.dist-info/RECORD +176 -0
- fasterrag-0.1.0b1.dist-info/WHEEL +4 -0
- fasterrag-0.1.0b1.dist-info/entry_points.txt +2 -0
- fasterrag-0.1.0b1.dist-info/licenses/LICENSE +202 -0
- fasterrag-0.1.0b1.dist-info/licenses/NOTICE +4 -0
fasterrag/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""fasterRag — a backend-only Retrieval-Augmented Generation framework.
|
|
2
|
+
|
|
3
|
+
The importable package is one of three control surfaces (REST API, CLI, library) that
|
|
4
|
+
all call the same service layer, so behavior, configuration, and errors are identical
|
|
5
|
+
everywhere. The public compatibility contract is specified in ``docs/python-api.md``:
|
|
6
|
+
names exported here and from documented submodules are stable under SemVer; anything
|
|
7
|
+
under ``fasterrag._internal`` or absent from ``__all__`` is private.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0b1"
|
|
11
|
+
|
|
12
|
+
# CRITICAL: imported lazily through __getattr__, not at module import. Importing the facade
|
|
13
|
+
# eagerly would pull the adapter factories, the chunking stack, and pydantic-settings into
|
|
14
|
+
# every `import fasterrag` — including `fasterrag.__version__` in a packaging script, and
|
|
15
|
+
# the CLI's own startup. The facade costs roughly a second to import; a version read should
|
|
16
|
+
# not.
|
|
17
|
+
__all__ = ["FasterRag", "__version__"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str) -> object:
|
|
21
|
+
"""Resolve the documented lazy exports on first access."""
|
|
22
|
+
if name == "FasterRag":
|
|
23
|
+
from fasterrag.facade import FasterRag
|
|
24
|
+
|
|
25
|
+
return FasterRag
|
|
26
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Vendor isolation boundary.
|
|
2
|
+
|
|
3
|
+
Every third-party client lives behind an adapter interface, and no vendor type ever
|
|
4
|
+
escapes this package: core code imports ``VectorDBAdapter``, never ``qdrant_client``.
|
|
5
|
+
That is what makes ``vector_db.provider`` a one-line config change rather than a
|
|
6
|
+
refactor (``docs/adr/ADR-0002``).
|
|
7
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Embedding provider adapters, the factory, and the tiered-embedding router."""
|
|
2
|
+
|
|
3
|
+
from fasterrag.adapters.embeddings.base import EmbeddingAdapter, EmbeddingResult
|
|
4
|
+
from fasterrag.adapters.embeddings.cohere import CohereEmbedder
|
|
5
|
+
from fasterrag.adapters.embeddings.factory import (
|
|
6
|
+
ENTRY_POINT_GROUP,
|
|
7
|
+
available_providers,
|
|
8
|
+
create_embedding_adapter,
|
|
9
|
+
resolve_adapter_class,
|
|
10
|
+
)
|
|
11
|
+
from fasterrag.adapters.embeddings.huggingface import HuggingFaceEmbedder
|
|
12
|
+
from fasterrag.adapters.embeddings.ollama import OllamaEmbedder
|
|
13
|
+
from fasterrag.adapters.embeddings.openai import OpenAIEmbedder
|
|
14
|
+
from fasterrag.adapters.embeddings.sync import SentenceEmbedderBridge
|
|
15
|
+
from fasterrag.adapters.embeddings.tiering import (
|
|
16
|
+
TieringRouter,
|
|
17
|
+
create_embedding_router,
|
|
18
|
+
matches,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ENTRY_POINT_GROUP",
|
|
23
|
+
"CohereEmbedder",
|
|
24
|
+
"EmbeddingAdapter",
|
|
25
|
+
"EmbeddingResult",
|
|
26
|
+
"HuggingFaceEmbedder",
|
|
27
|
+
"OllamaEmbedder",
|
|
28
|
+
"OpenAIEmbedder",
|
|
29
|
+
"SentenceEmbedderBridge",
|
|
30
|
+
"TieringRouter",
|
|
31
|
+
"available_providers",
|
|
32
|
+
"create_embedding_adapter",
|
|
33
|
+
"create_embedding_router",
|
|
34
|
+
"matches",
|
|
35
|
+
"resolve_adapter_class",
|
|
36
|
+
]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Vendor-neutral embedding provider contract.
|
|
2
|
+
|
|
3
|
+
Two calls, deliberately separated: documents and queries embed through different paths
|
|
4
|
+
because several models require different instruction prefixes for each, and conflating
|
|
5
|
+
them quietly costs retrieval quality.
|
|
6
|
+
|
|
7
|
+
Adapters report ``model`` and ``model_version``. Those two values are recorded on every
|
|
8
|
+
chunk and in the index lockfile, and they are the anchor drift detection compares against
|
|
9
|
+
(D1, ``docs/data-model.md``) — an adapter that reports a vague version makes drift
|
|
10
|
+
undetectable.
|
|
11
|
+
|
|
12
|
+
Adapters report billed usage on ``EmbeddingResult.total_tokens`` when the provider states
|
|
13
|
+
it, mirroring ``Completion.prompt_tokens`` on the generation side. It is the *provider's*
|
|
14
|
+
figure, not ours: the embedding pool prices it through ``services.estimation`` so a bill can
|
|
15
|
+
be reconciled against an invoice rather than against our own tokenizer's guess. A provider
|
|
16
|
+
that states nothing reports ``None``, never ``0``.
|
|
17
|
+
|
|
18
|
+
Adapters do not retry. They classify failures — setting ``retryable`` from what the
|
|
19
|
+
provider actually said — and the embedding worker pool owns backoff, so retry policy
|
|
20
|
+
lives in exactly one place (``docs/reliability.md`` §2).
|
|
21
|
+
|
|
22
|
+
Two refusals say more than "retry" or "do not" (TASK-0478). A request refused for its size —
|
|
23
|
+
``413``, or a token or batch limit named in the message — is an :class:`EmbedTooLargeError`:
|
|
24
|
+
the same request will fail forever, but a smaller one may not, so the pool halves the batch
|
|
25
|
+
instead of retrying it. A retryable refusal that names a pause in ``Retry-After`` (or
|
|
26
|
+
OpenAI's ``retry-after-ms``) is an :class:`EmbedThrottledError` carrying it, so the pool can
|
|
27
|
+
wait as long as it was asked rather than as long as its own backoff guesses. A plugin
|
|
28
|
+
adapter raises either one to get the same treatment.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import math
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import time
|
|
37
|
+
from abc import ABC, abstractmethod
|
|
38
|
+
from collections.abc import Mapping, Sequence
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from email.utils import mktime_tz, parsedate_tz
|
|
41
|
+
from typing import ClassVar, Final
|
|
42
|
+
|
|
43
|
+
from fasterrag.adapters.vectordb.base import HealthStatus
|
|
44
|
+
from fasterrag.config.schema import Settings
|
|
45
|
+
from fasterrag.errors import ConfigError, EmbedError, ErrorCode
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"ADAPTER_API_VERSION",
|
|
49
|
+
"EmbedThrottledError",
|
|
50
|
+
"EmbedTooLargeError",
|
|
51
|
+
"EmbeddingAdapter",
|
|
52
|
+
"EmbeddingResult",
|
|
53
|
+
"classify_provider_failure",
|
|
54
|
+
"require_extra",
|
|
55
|
+
"require_key",
|
|
56
|
+
"retry_after_seconds",
|
|
57
|
+
"sum_reported",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
ADAPTER_API_VERSION: Final = "1.0"
|
|
61
|
+
|
|
62
|
+
_AUTH_STATUSES: Final[frozenset[int]] = frozenset({401, 403})
|
|
63
|
+
_TIMEOUT_STATUSES: Final[frozenset[int]] = frozenset({408, 504})
|
|
64
|
+
_SERVER_ERROR_THRESHOLD: Final = 500
|
|
65
|
+
_RATE_LIMITED: Final = 429
|
|
66
|
+
_PROGRAMMING_ERRORS: Final[tuple[type[BaseException], ...]] = (TypeError, AttributeError)
|
|
67
|
+
_PAYLOAD_TOO_LARGE: Final = 413
|
|
68
|
+
_SIZE_STATUSES: Final[frozenset[int]] = frozenset({400, 413, 422, 500})
|
|
69
|
+
_SIZE_MESSAGE: Final = re.compile(
|
|
70
|
+
r"context length|too many tokens|token limit|tokens per request|too large|too long"
|
|
71
|
+
r"|number of (?:texts|inputs)|too many (?:texts|inputs)|max(?:imum)? input",
|
|
72
|
+
re.IGNORECASE,
|
|
73
|
+
)
|
|
74
|
+
_SECONDS: Final = re.compile(r"^\s*\d+(?:\.\d+)?\s*$")
|
|
75
|
+
_MILLISECONDS: Final = 1000.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EmbedTooLargeError(EmbedError):
|
|
79
|
+
"""The provider refused a request for its size: send less, not the same again."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, detail: str, *, trace_id: str | None = None) -> None:
|
|
82
|
+
"""Build the error; it is never retryable as sent."""
|
|
83
|
+
super().__init__(detail, trace_id=trace_id, retryable=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class EmbedThrottledError(EmbedError):
|
|
87
|
+
"""A retryable refusal that named how long to wait before the next attempt."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self, detail: str, *, retry_after_seconds: float, trace_id: str | None = None
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Build the error with the pause the provider asked for, in seconds."""
|
|
93
|
+
super().__init__(detail, trace_id=trace_id, retryable=True)
|
|
94
|
+
self.retry_after_seconds = retry_after_seconds
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def require_extra(provider: str, package: str, extra: str) -> ConfigError:
|
|
98
|
+
"""Return the error explaining which optional install a provider needs."""
|
|
99
|
+
return ConfigError(
|
|
100
|
+
f"embeddings.provider is {provider!r}, which needs the {package} package; "
|
|
101
|
+
f"install it with 'pip install \"fasterrag[{extra}]\"'"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def require_key(name: str | None, provider: str) -> str:
|
|
106
|
+
"""Return the credential a provider needs, or explain what is missing."""
|
|
107
|
+
if not name:
|
|
108
|
+
raise ConfigError(f"embeddings.api_key_env must name a variable for provider {provider!r}")
|
|
109
|
+
|
|
110
|
+
value = os.environ.get(name)
|
|
111
|
+
if not value or not value.strip():
|
|
112
|
+
raise ConfigError(f"the {name} environment variable is not set")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _headers(exc: BaseException) -> Mapping[str, str] | None:
|
|
117
|
+
"""Return the response headers a vendor error carries, wherever its SDK put them."""
|
|
118
|
+
headers = getattr(exc, "headers", None)
|
|
119
|
+
if not isinstance(headers, Mapping):
|
|
120
|
+
headers = getattr(getattr(exc, "response", None), "headers", None)
|
|
121
|
+
if not isinstance(headers, Mapping):
|
|
122
|
+
return None
|
|
123
|
+
return {str(name).lower(): str(value) for name, value in headers.items()}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _number(value: str | None) -> float | None:
|
|
127
|
+
"""Return a non-negative decimal header value, or ``None`` when it is not one."""
|
|
128
|
+
if value is None or not _SECONDS.match(value):
|
|
129
|
+
return None
|
|
130
|
+
number = float(value)
|
|
131
|
+
return number if math.isfinite(number) else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def retry_after_seconds(exc: BaseException) -> float | None:
|
|
135
|
+
"""Return the pause a refusal asked for, from ``retry-after-ms`` or ``Retry-After``.
|
|
136
|
+
|
|
137
|
+
Seconds or an HTTP date; anything unreadable is ``None`` and a date already past is 0.
|
|
138
|
+
"""
|
|
139
|
+
headers = _headers(exc)
|
|
140
|
+
if headers is None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
milliseconds = _number(headers.get("retry-after-ms"))
|
|
144
|
+
if milliseconds is not None:
|
|
145
|
+
return milliseconds / _MILLISECONDS
|
|
146
|
+
|
|
147
|
+
raw = headers.get("retry-after")
|
|
148
|
+
seconds = _number(raw)
|
|
149
|
+
if seconds is not None or raw is None:
|
|
150
|
+
return seconds
|
|
151
|
+
|
|
152
|
+
parsed = parsedate_tz(raw)
|
|
153
|
+
if parsed is None:
|
|
154
|
+
return None
|
|
155
|
+
return max(float(mktime_tz(parsed)) - time.time(), 0.0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _refused_for_size(exc: BaseException, status: int | None) -> bool:
|
|
159
|
+
"""Return whether the provider refused the request because of how much it carried."""
|
|
160
|
+
if status == _PAYLOAD_TOO_LARGE:
|
|
161
|
+
return True
|
|
162
|
+
if status not in _SIZE_STATUSES:
|
|
163
|
+
return False
|
|
164
|
+
message = f"{exc} {getattr(exc, 'body', '')}"
|
|
165
|
+
return _SIZE_MESSAGE.search(message) is not None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def classify_provider_failure(
|
|
169
|
+
exc: BaseException,
|
|
170
|
+
*,
|
|
171
|
+
provider: str,
|
|
172
|
+
operation: str,
|
|
173
|
+
key_env: str | None,
|
|
174
|
+
) -> EmbedError:
|
|
175
|
+
"""Translate a provider failure into a typed, correctly-classified error."""
|
|
176
|
+
status = getattr(exc, "status_code", None)
|
|
177
|
+
if not isinstance(status, int):
|
|
178
|
+
status = None
|
|
179
|
+
|
|
180
|
+
if status is None and isinstance(exc, _PROGRAMMING_ERRORS):
|
|
181
|
+
return EmbedError(
|
|
182
|
+
f"the {provider} adapter raised {type(exc).__name__} during {operation}; this is "
|
|
183
|
+
"a fault in the adapter or a client library version it was not written against, "
|
|
184
|
+
"not a provider outage",
|
|
185
|
+
code=ErrorCode.INTERNAL,
|
|
186
|
+
retryable=False,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if status in _AUTH_STATUSES:
|
|
190
|
+
named = key_env or "embeddings.api_key_env"
|
|
191
|
+
return EmbedError(
|
|
192
|
+
f"{provider} rejected the credentials during {operation}; check the key in the "
|
|
193
|
+
f"{named} environment variable",
|
|
194
|
+
retryable=False,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if _refused_for_size(exc, status):
|
|
198
|
+
return EmbedTooLargeError(
|
|
199
|
+
f"{provider} refused the request as too large during {operation} (status {status}); "
|
|
200
|
+
"the batch will be split, and an input refused on its own exceeds the model's limit"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if status in _TIMEOUT_STATUSES:
|
|
204
|
+
return EmbedError(
|
|
205
|
+
f"{provider} timed out during {operation}",
|
|
206
|
+
code=ErrorCode.EMBED_PROVIDER_TIMEOUT,
|
|
207
|
+
retryable=True,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if status == _RATE_LIMITED or (status is not None and status >= _SERVER_ERROR_THRESHOLD):
|
|
211
|
+
pause = retry_after_seconds(exc)
|
|
212
|
+
if pause is not None:
|
|
213
|
+
return EmbedThrottledError(
|
|
214
|
+
f"{provider} returned status {status} during {operation} and asked for a "
|
|
215
|
+
f"{pause:.1f}s pause",
|
|
216
|
+
retry_after_seconds=pause,
|
|
217
|
+
)
|
|
218
|
+
return EmbedError(
|
|
219
|
+
f"{provider} returned status {status} during {operation}",
|
|
220
|
+
retryable=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if status is not None:
|
|
224
|
+
return EmbedError(
|
|
225
|
+
f"{provider} returned status {status} during {operation}",
|
|
226
|
+
retryable=False,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if isinstance(exc, TimeoutError):
|
|
230
|
+
return EmbedError(
|
|
231
|
+
f"{provider} timed out during {operation}",
|
|
232
|
+
code=ErrorCode.EMBED_PROVIDER_TIMEOUT,
|
|
233
|
+
retryable=True,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return EmbedError(
|
|
237
|
+
f"{provider} was unreachable during {operation}: {type(exc).__name__}",
|
|
238
|
+
retryable=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def sum_reported(values: Sequence[int | None]) -> int | None:
|
|
243
|
+
"""Return the batch total, or ``None`` when any batch reported no usage.
|
|
244
|
+
|
|
245
|
+
All-or-nothing on purpose: a partial sum is an under-count, and an under-count
|
|
246
|
+
reconciles wrong against an invoice while looking like a receipt.
|
|
247
|
+
"""
|
|
248
|
+
if any(value is None for value in values):
|
|
249
|
+
return None
|
|
250
|
+
return sum(value for value in values if value is not None)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True, slots=True)
|
|
254
|
+
class EmbeddingResult:
|
|
255
|
+
"""Vectors for a batch, with the model that produced them."""
|
|
256
|
+
|
|
257
|
+
vectors: list[list[float]]
|
|
258
|
+
model: str
|
|
259
|
+
model_version: str
|
|
260
|
+
total_tokens: int | None = None
|
|
261
|
+
"""Tokens the provider said it billed, or ``None`` when it reported none.
|
|
262
|
+
|
|
263
|
+
``None`` and ``0`` are different facts and must stay different: ``0`` means nothing was
|
|
264
|
+
billed (a fully cached batch), while ``None`` means there is no receipt to reconcile
|
|
265
|
+
against an invoice. Collapsing the two prices unreported traffic as free.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class EmbeddingAdapter(ABC):
|
|
270
|
+
"""Vendor-neutral embedding contract."""
|
|
271
|
+
|
|
272
|
+
provider: ClassVar[str]
|
|
273
|
+
|
|
274
|
+
def __init__(self, settings: Settings) -> None:
|
|
275
|
+
"""Build the adapter from validated configuration."""
|
|
276
|
+
self.settings = settings
|
|
277
|
+
self.config = settings.embeddings
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
@abstractmethod
|
|
281
|
+
def model(self) -> str:
|
|
282
|
+
"""Return the model identifier recorded on every chunk."""
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
@abstractmethod
|
|
286
|
+
def model_version(self) -> str:
|
|
287
|
+
"""Return the model version, the anchor drift detection compares against."""
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
@abstractmethod
|
|
291
|
+
def dimensions(self) -> int | None:
|
|
292
|
+
"""Return the vector size, or None until it is known."""
|
|
293
|
+
|
|
294
|
+
@abstractmethod
|
|
295
|
+
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
|
|
296
|
+
"""Embed passages for indexing, batched per ``embeddings.batch_size``."""
|
|
297
|
+
|
|
298
|
+
@abstractmethod
|
|
299
|
+
async def embed_query(self, text: str) -> list[float]:
|
|
300
|
+
"""Embed a single query."""
|
|
301
|
+
|
|
302
|
+
@abstractmethod
|
|
303
|
+
async def health(self) -> HealthStatus:
|
|
304
|
+
"""Report whether the provider is usable, without raising."""
|
|
305
|
+
|
|
306
|
+
@abstractmethod
|
|
307
|
+
async def close(self) -> None:
|
|
308
|
+
"""Release any client or model the adapter holds."""
|
|
309
|
+
|
|
310
|
+
def batches(self, texts: Sequence[str]) -> list[Sequence[str]]:
|
|
311
|
+
"""Split ``texts`` into request-sized batches.
|
|
312
|
+
|
|
313
|
+
Batched embedding is far cheaper than one call per text, so every adapter
|
|
314
|
+
batches; doing it here means no adapter can forget.
|
|
315
|
+
"""
|
|
316
|
+
size = self.config.batch_size
|
|
317
|
+
return [texts[start : start + size] for start in range(0, len(texts), size)]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Cohere embedding provider.
|
|
2
|
+
|
|
3
|
+
Cohere requires an ``input_type`` naming what is being embedded, and it genuinely changes
|
|
4
|
+
the vectors: passages must be embedded as ``search_document`` and queries as
|
|
5
|
+
``search_query``. Embedding both the same way is a silent retrieval-quality loss, which is
|
|
6
|
+
why the contract separates the two calls.
|
|
7
|
+
|
|
8
|
+
Cohere states what it billed on every embed response, and the adapter surfaces it as
|
|
9
|
+
``EmbeddingResult.total_tokens`` so spend is reconciled against the provider's own figure
|
|
10
|
+
rather than a local token estimate. Per the API reference for ``/v1/embed`` the response
|
|
11
|
+
carries an optional ``meta.billed_units`` whose ``input_tokens`` counts the billable text
|
|
12
|
+
units; the adapter reads that rather than ``meta.tokens``, because the invoice is charged
|
|
13
|
+
on the billed units and the two are allowed to differ.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Any, Final
|
|
20
|
+
|
|
21
|
+
from fasterrag.adapters.embeddings.base import (
|
|
22
|
+
EmbeddingAdapter,
|
|
23
|
+
EmbeddingResult,
|
|
24
|
+
classify_provider_failure,
|
|
25
|
+
require_extra,
|
|
26
|
+
require_key,
|
|
27
|
+
sum_reported,
|
|
28
|
+
)
|
|
29
|
+
from fasterrag.adapters.vectordb.base import HealthStatus
|
|
30
|
+
from fasterrag.config.schema import Settings
|
|
31
|
+
from fasterrag.errors import ConfigError, EmbedError
|
|
32
|
+
|
|
33
|
+
__all__ = ["CohereEmbedder"]
|
|
34
|
+
|
|
35
|
+
_DOCUMENT_INPUT: Final = "search_document"
|
|
36
|
+
_QUERY_INPUT: Final = "search_query"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CohereEmbedder(EmbeddingAdapter):
|
|
40
|
+
"""Embeds through Cohere's embed API."""
|
|
41
|
+
|
|
42
|
+
provider = "cohere"
|
|
43
|
+
|
|
44
|
+
def __init__(self, settings: Settings) -> None:
|
|
45
|
+
"""Build the adapter without opening a connection."""
|
|
46
|
+
super().__init__(settings)
|
|
47
|
+
self._client: Any | None = None
|
|
48
|
+
self._timeout = settings.reliability.timeouts.embeddings_ms / 1000
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def model(self) -> str:
|
|
52
|
+
"""Return the configured model id."""
|
|
53
|
+
return self.config.model
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def model_version(self) -> str:
|
|
57
|
+
"""Return the model version; the pinned model id is the most specific available."""
|
|
58
|
+
return self.config.model
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def dimensions(self) -> int | None:
|
|
62
|
+
"""Return the configured dimensionality override, if any."""
|
|
63
|
+
return self.config.dimensions
|
|
64
|
+
|
|
65
|
+
def _connected(self) -> Any:
|
|
66
|
+
"""Return the client, building it on first use."""
|
|
67
|
+
if self._client is None:
|
|
68
|
+
try:
|
|
69
|
+
from cohere import AsyncClient
|
|
70
|
+
except ImportError as exc:
|
|
71
|
+
raise require_extra(self.provider, "cohere", "cohere") from exc
|
|
72
|
+
|
|
73
|
+
self._client = AsyncClient(
|
|
74
|
+
api_key=require_key(self.config.api_key_env, self.provider),
|
|
75
|
+
timeout=self._timeout,
|
|
76
|
+
)
|
|
77
|
+
return self._client
|
|
78
|
+
|
|
79
|
+
async def _embed(
|
|
80
|
+
self, texts: Sequence[str], input_type: str
|
|
81
|
+
) -> tuple[list[list[float]], int | None]:
|
|
82
|
+
"""Embed one batch, returning vectors and the tokens Cohere says it billed."""
|
|
83
|
+
try:
|
|
84
|
+
response = await self._connected().embed(
|
|
85
|
+
texts=list(texts),
|
|
86
|
+
model=self.config.model,
|
|
87
|
+
input_type=input_type,
|
|
88
|
+
embedding_types=["float"],
|
|
89
|
+
)
|
|
90
|
+
except ConfigError:
|
|
91
|
+
raise
|
|
92
|
+
# CRITICAL: the catch stays broad because the SDK raises a deep hierarchy of
|
|
93
|
+
# transport and API errors. Every one must become a typed EmbedError so the worker
|
|
94
|
+
# pool can decide whether to retry instead of a vendor exception escaping.
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise classify_provider_failure(
|
|
97
|
+
exc,
|
|
98
|
+
provider=self.provider,
|
|
99
|
+
operation="embed",
|
|
100
|
+
key_env=self.config.api_key_env,
|
|
101
|
+
) from exc
|
|
102
|
+
|
|
103
|
+
vectors = getattr(response.embeddings, "float_", None) or getattr(
|
|
104
|
+
response.embeddings, "float", None
|
|
105
|
+
)
|
|
106
|
+
if vectors is None:
|
|
107
|
+
raise EmbedError("cohere returned no float embeddings", retryable=False)
|
|
108
|
+
return [list(vector) for vector in vectors], _billed_tokens(response)
|
|
109
|
+
|
|
110
|
+
async def embed_documents(self, texts: Sequence[str]) -> EmbeddingResult:
|
|
111
|
+
"""Embed passages as search documents."""
|
|
112
|
+
vectors: list[list[float]] = []
|
|
113
|
+
billed: list[int | None] = []
|
|
114
|
+
for batch in self.batches(texts):
|
|
115
|
+
batch_vectors, tokens = await self._embed(batch, _DOCUMENT_INPUT)
|
|
116
|
+
vectors.extend(batch_vectors)
|
|
117
|
+
billed.append(tokens)
|
|
118
|
+
|
|
119
|
+
return EmbeddingResult(
|
|
120
|
+
vectors=vectors,
|
|
121
|
+
model=self.model,
|
|
122
|
+
model_version=self.model_version,
|
|
123
|
+
total_tokens=sum_reported(billed),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def embed_query(self, text: str) -> list[float]:
|
|
127
|
+
"""Embed a single query as a search query."""
|
|
128
|
+
vectors, _ = await self._embed([text], _QUERY_INPUT)
|
|
129
|
+
return vectors[0]
|
|
130
|
+
|
|
131
|
+
async def health(self) -> HealthStatus:
|
|
132
|
+
"""Report reachability with a minimal, non-destructive call."""
|
|
133
|
+
try:
|
|
134
|
+
await self._embed(["health check"], _QUERY_INPUT)
|
|
135
|
+
except (ConfigError, EmbedError) as exc:
|
|
136
|
+
return HealthStatus(healthy=False, detail=exc.detail)
|
|
137
|
+
return HealthStatus(healthy=True, detail=f"{self.provider} reachable")
|
|
138
|
+
|
|
139
|
+
async def close(self) -> None:
|
|
140
|
+
"""Close the client."""
|
|
141
|
+
self._client = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _billed_tokens(response: Any) -> int | None:
|
|
145
|
+
"""Return the input tokens Cohere billed, or None when it stated none.
|
|
146
|
+
|
|
147
|
+
Every level of ``meta`` is optional in the reference, so a missing one means "no
|
|
148
|
+
receipt" rather than "free".
|
|
149
|
+
"""
|
|
150
|
+
meta = getattr(response, "meta", None)
|
|
151
|
+
billed = getattr(meta, "billed_units", None) if meta is not None else None
|
|
152
|
+
tokens = getattr(billed, "input_tokens", None) if billed is not None else None
|
|
153
|
+
return None if tokens is None else int(tokens)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Selects the concrete embedding adapter from configuration.
|
|
2
|
+
|
|
3
|
+
Mirrors the vector database factory: ``embeddings.provider`` is the whole selection
|
|
4
|
+
surface, third parties register providers through the ``fasterrag.embeddings`` entry
|
|
5
|
+
point, and built-in names always win so an installed package cannot take over
|
|
6
|
+
``huggingface`` and silently reroute a deployment's vectors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
from fasterrag.adapters.embeddings.base import ADAPTER_API_VERSION, EmbeddingAdapter
|
|
16
|
+
from fasterrag.adapters.plugin_api import require_compatible_api
|
|
17
|
+
from fasterrag.config.schema import Settings
|
|
18
|
+
from fasterrag.errors import ConfigError
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ENTRY_POINT_GROUP",
|
|
22
|
+
"available_providers",
|
|
23
|
+
"create_embedding_adapter",
|
|
24
|
+
"resolve_adapter_class",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
ENTRY_POINT_GROUP: Final = "fasterrag.embeddings"
|
|
28
|
+
|
|
29
|
+
_BUILTIN_ADAPTERS: Final[dict[str, str]] = {
|
|
30
|
+
"huggingface": "fasterrag.adapters.embeddings.huggingface:HuggingFaceEmbedder",
|
|
31
|
+
"openai": "fasterrag.adapters.embeddings.openai:OpenAIEmbedder",
|
|
32
|
+
"cohere": "fasterrag.adapters.embeddings.cohere:CohereEmbedder",
|
|
33
|
+
"ollama": "fasterrag.adapters.embeddings.ollama:OllamaEmbedder",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _plugin_entry_points() -> dict[str, EntryPoint]:
|
|
38
|
+
"""Return third-party embedding providers registered under the entry-point group."""
|
|
39
|
+
return {entry.name: entry for entry in entry_points(group=ENTRY_POINT_GROUP)}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def available_providers() -> dict[str, str]:
|
|
43
|
+
"""Return every selectable provider name mapped to where it came from."""
|
|
44
|
+
providers = dict.fromkeys(_BUILTIN_ADAPTERS, "built-in")
|
|
45
|
+
for name, entry in _plugin_entry_points().items():
|
|
46
|
+
if name not in providers:
|
|
47
|
+
providers[name] = f"plugin ({entry.value})"
|
|
48
|
+
return providers
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _require_adapter_class(loaded: object, source: str) -> type[EmbeddingAdapter]:
|
|
52
|
+
"""Confirm a resolved object really implements the embedding contract."""
|
|
53
|
+
if not (isinstance(loaded, type) and issubclass(loaded, EmbeddingAdapter)):
|
|
54
|
+
raise ConfigError(
|
|
55
|
+
f"{source} is not an EmbeddingAdapter subclass; a registered provider must "
|
|
56
|
+
"implement the adapter contract"
|
|
57
|
+
)
|
|
58
|
+
return loaded
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_adapter_class(provider: str) -> type[EmbeddingAdapter]:
|
|
62
|
+
"""Return the adapter class registered for ``provider``."""
|
|
63
|
+
builtin = _BUILTIN_ADAPTERS.get(provider)
|
|
64
|
+
if builtin is not None:
|
|
65
|
+
module_name, _, attribute = builtin.partition(":")
|
|
66
|
+
return _require_adapter_class(getattr(import_module(module_name), attribute), builtin)
|
|
67
|
+
|
|
68
|
+
plugin = _plugin_entry_points().get(provider)
|
|
69
|
+
if plugin is not None:
|
|
70
|
+
source = f"{ENTRY_POINT_GROUP} entry point {provider!r}"
|
|
71
|
+
return require_compatible_api(
|
|
72
|
+
_require_adapter_class(plugin.load(), source), host=ADAPTER_API_VERSION, source=source
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
known = ", ".join(sorted(available_providers()))
|
|
76
|
+
raise ConfigError(
|
|
77
|
+
f"embeddings.provider {provider!r} is not registered; available providers: {known}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_embedding_adapter(settings: Settings) -> EmbeddingAdapter:
|
|
82
|
+
"""Build the adapter named by ``embeddings.provider``."""
|
|
83
|
+
return resolve_adapter_class(settings.embeddings.provider)(settings)
|