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.
Files changed (176) hide show
  1. fasterrag/__init__.py +26 -0
  2. fasterrag/adapters/__init__.py +7 -0
  3. fasterrag/adapters/embeddings/__init__.py +36 -0
  4. fasterrag/adapters/embeddings/base.py +317 -0
  5. fasterrag/adapters/embeddings/cohere.py +153 -0
  6. fasterrag/adapters/embeddings/factory.py +83 -0
  7. fasterrag/adapters/embeddings/huggingface.py +174 -0
  8. fasterrag/adapters/embeddings/ollama.py +141 -0
  9. fasterrag/adapters/embeddings/openai.py +131 -0
  10. fasterrag/adapters/embeddings/sync.py +46 -0
  11. fasterrag/adapters/embeddings/tiering.py +135 -0
  12. fasterrag/adapters/llm/__init__.py +27 -0
  13. fasterrag/adapters/llm/anthropic.py +119 -0
  14. fasterrag/adapters/llm/base.py +233 -0
  15. fasterrag/adapters/llm/cohere.py +163 -0
  16. fasterrag/adapters/llm/factory.py +82 -0
  17. fasterrag/adapters/llm/ollama.py +126 -0
  18. fasterrag/adapters/llm/openai.py +136 -0
  19. fasterrag/adapters/plugin_api.py +66 -0
  20. fasterrag/adapters/vectordb/__init__.py +31 -0
  21. fasterrag/adapters/vectordb/base.py +339 -0
  22. fasterrag/adapters/vectordb/chroma.py +1169 -0
  23. fasterrag/adapters/vectordb/factory.py +110 -0
  24. fasterrag/adapters/vectordb/milvus.py +1251 -0
  25. fasterrag/adapters/vectordb/pgvector.py +1288 -0
  26. fasterrag/adapters/vectordb/qdrant.py +756 -0
  27. fasterrag/adapters/vectordb/weaviate.py +1409 -0
  28. fasterrag/api/__init__.py +6 -0
  29. fasterrag/api/admin.py +158 -0
  30. fasterrag/api/auth.py +695 -0
  31. fasterrag/api/collections.py +215 -0
  32. fasterrag/api/dependencies.py +129 -0
  33. fasterrag/api/health.py +110 -0
  34. fasterrag/api/idempotency.py +290 -0
  35. fasterrag/api/ingest.py +279 -0
  36. fasterrag/api/limits.py +106 -0
  37. fasterrag/api/main.py +393 -0
  38. fasterrag/api/metrics.py +95 -0
  39. fasterrag/api/problems.py +217 -0
  40. fasterrag/api/query.py +201 -0
  41. fasterrag/api/schemas.py +130 -0
  42. fasterrag/api/traces.py +133 -0
  43. fasterrag/chunking.py +34 -0
  44. fasterrag/cli/__init__.py +13 -0
  45. fasterrag/cli/commands/__init__.py +6 -0
  46. fasterrag/cli/commands/autopilot.py +141 -0
  47. fasterrag/cli/commands/backup.py +93 -0
  48. fasterrag/cli/commands/benchmark.py +229 -0
  49. fasterrag/cli/commands/diagnostics.py +360 -0
  50. fasterrag/cli/commands/documents.py +87 -0
  51. fasterrag/cli/commands/infrastructure.py +125 -0
  52. fasterrag/cli/commands/pipeline.py +865 -0
  53. fasterrag/cli/commands/portability.py +112 -0
  54. fasterrag/cli/commands/processes.py +159 -0
  55. fasterrag/cli/commands/replay.py +115 -0
  56. fasterrag/cli/commands/traces.py +55 -0
  57. fasterrag/cli/main.py +149 -0
  58. fasterrag/cli/output.py +82 -0
  59. fasterrag/cli/parser.py +508 -0
  60. fasterrag/cli/settings.py +39 -0
  61. fasterrag/cli/sources.py +96 -0
  62. fasterrag/config/__init__.py +13 -0
  63. fasterrag/config/loader.py +289 -0
  64. fasterrag/config/plugins.py +118 -0
  65. fasterrag/config/schema.py +1089 -0
  66. fasterrag/config/template.py +82 -0
  67. fasterrag/core/__init__.py +6 -0
  68. fasterrag/core/breaker.py +251 -0
  69. fasterrag/core/cache/__init__.py +127 -0
  70. fasterrag/core/cache/embedding.py +308 -0
  71. fasterrag/core/cache/redis.py +287 -0
  72. fasterrag/core/cache/semantic.py +235 -0
  73. fasterrag/core/cache/stats.py +71 -0
  74. fasterrag/core/cache/store.py +419 -0
  75. fasterrag/core/chunking/__init__.py +87 -0
  76. fasterrag/core/chunking/enrichment.py +157 -0
  77. fasterrag/core/chunking/fixed.py +63 -0
  78. fasterrag/core/chunking/late.py +56 -0
  79. fasterrag/core/chunking/layout.py +120 -0
  80. fasterrag/core/chunking/models.py +235 -0
  81. fasterrag/core/chunking/pooling.py +154 -0
  82. fasterrag/core/chunking/recursive.py +131 -0
  83. fasterrag/core/chunking/semantic.py +205 -0
  84. fasterrag/core/chunking/tokenizer.py +113 -0
  85. fasterrag/core/confinement.py +52 -0
  86. fasterrag/core/context.py +208 -0
  87. fasterrag/core/evals/__init__.py +33 -0
  88. fasterrag/core/evals/generator.py +241 -0
  89. fasterrag/core/evals/golden.py +138 -0
  90. fasterrag/core/evals/harness.py +149 -0
  91. fasterrag/core/evals/metrics.py +67 -0
  92. fasterrag/core/faithfulness.py +176 -0
  93. fasterrag/core/generation.py +175 -0
  94. fasterrag/core/identity.py +202 -0
  95. fasterrag/core/parsing/__init__.py +215 -0
  96. fasterrag/core/parsing/docx.py +210 -0
  97. fasterrag/core/parsing/html.py +118 -0
  98. fasterrag/core/parsing/markdown.py +120 -0
  99. fasterrag/core/parsing/models.py +211 -0
  100. fasterrag/core/parsing/options.py +55 -0
  101. fasterrag/core/parsing/pdf.py +242 -0
  102. fasterrag/core/parsing/plaintext.py +97 -0
  103. fasterrag/core/parsing/tabular.py +191 -0
  104. fasterrag/core/rate_limit.py +259 -0
  105. fasterrag/core/rerank/__init__.py +9 -0
  106. fasterrag/core/rerank/cross_encoder.py +226 -0
  107. fasterrag/core/retrieval/__init__.py +30 -0
  108. fasterrag/core/retrieval/bm25.py +175 -0
  109. fasterrag/core/retrieval/fusion.py +121 -0
  110. fasterrag/core/retrieval/models.py +51 -0
  111. fasterrag/core/tenancy.py +19 -0
  112. fasterrag/core/tracing.py +199 -0
  113. fasterrag/data/.env.example +34 -0
  114. fasterrag/data/config.local.yaml +30 -0
  115. fasterrag/data/config.yaml +215 -0
  116. fasterrag/errors.py +321 -0
  117. fasterrag/evals.py +31 -0
  118. fasterrag/facade.py +432 -0
  119. fasterrag/observability/__init__.py +6 -0
  120. fasterrag/observability/dashboard.py +195 -0
  121. fasterrag/observability/langfuse_export.py +220 -0
  122. fasterrag/observability/logging.py +263 -0
  123. fasterrag/observability/metrics.py +466 -0
  124. fasterrag/observability/otel_export.py +267 -0
  125. fasterrag/observability/otel_metrics.py +248 -0
  126. fasterrag/parsing.py +28 -0
  127. fasterrag/py.typed +0 -0
  128. fasterrag/rerank.py +9 -0
  129. fasterrag/retrieval.py +21 -0
  130. fasterrag/services/__init__.py +6 -0
  131. fasterrag/services/archive.py +385 -0
  132. fasterrag/services/archive_import.py +1077 -0
  133. fasterrag/services/autopilot.py +864 -0
  134. fasterrag/services/backfill.py +407 -0
  135. fasterrag/services/backup.py +331 -0
  136. fasterrag/services/benchmark.py +305 -0
  137. fasterrag/services/budget.py +250 -0
  138. fasterrag/services/ci_status.py +533 -0
  139. fasterrag/services/composition.py +133 -0
  140. fasterrag/services/degradation.py +135 -0
  141. fasterrag/services/doctor.py +708 -0
  142. fasterrag/services/documents.py +300 -0
  143. fasterrag/services/embedding_router.py +68 -0
  144. fasterrag/services/estimation.py +489 -0
  145. fasterrag/services/evaluation.py +206 -0
  146. fasterrag/services/generation.py +1299 -0
  147. fasterrag/services/golden_sets.py +96 -0
  148. fasterrag/services/grafana.py +542 -0
  149. fasterrag/services/idempotency.py +616 -0
  150. fasterrag/services/ingestion.py +534 -0
  151. fasterrag/services/jobs.py +358 -0
  152. fasterrag/services/journal.py +1217 -0
  153. fasterrag/services/langfuse.py +465 -0
  154. fasterrag/services/lockfile.py +443 -0
  155. fasterrag/services/provision_registry.py +111 -0
  156. fasterrag/services/provisioning.py +452 -0
  157. fasterrag/services/querying.py +387 -0
  158. fasterrag/services/regression.py +410 -0
  159. fasterrag/services/reindex.py +424 -0
  160. fasterrag/services/replay.py +191 -0
  161. fasterrag/services/sources.py +523 -0
  162. fasterrag/services/tenancy.py +98 -0
  163. fasterrag/services/traces.py +333 -0
  164. fasterrag/sync.py +322 -0
  165. fasterrag/workers/__init__.py +34 -0
  166. fasterrag/workers/cpu_pool.py +925 -0
  167. fasterrag/workers/embed_pool.py +484 -0
  168. fasterrag/workers/indexer.py +211 -0
  169. fasterrag/workers/ledger.py +179 -0
  170. fasterrag/workers/queues.py +197 -0
  171. fasterrag-0.1.0b1.dist-info/METADATA +513 -0
  172. fasterrag-0.1.0b1.dist-info/RECORD +176 -0
  173. fasterrag-0.1.0b1.dist-info/WHEEL +4 -0
  174. fasterrag-0.1.0b1.dist-info/entry_points.txt +2 -0
  175. fasterrag-0.1.0b1.dist-info/licenses/LICENSE +202 -0
  176. 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)