aid-runtime-python 0.1.39__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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Ingesting a corpus and retrieving from it.
|
|
2
|
+
|
|
3
|
+
This is the module a later layer's generated code calls: ``ingest`` once per
|
|
4
|
+
refresh, ``retrieve`` per query. It is deliberately not a command-line entry point
|
|
5
|
+
-- the generated module is the intended surface, and a runtime that also shipped a
|
|
6
|
+
``__main__`` would grow a second, competing one.
|
|
7
|
+
|
|
8
|
+
Both halves are orchestration over injected seams. Nothing here chooses a model by
|
|
9
|
+
name, because the binding already carries the resolved model id, the vector width and
|
|
10
|
+
the store kind; resolving any of those at run time is exactly what the pinning rule
|
|
11
|
+
forbids.
|
|
12
|
+
|
|
13
|
+
Cost is charged to the existing ledger using the existing profile. The IR quotes
|
|
14
|
+
embedding prices per MTok and :class:`~aid_runtime.types.CostProfile` quotes per 1k
|
|
15
|
+
tokens; that conversion happens once, in
|
|
16
|
+
:func:`~aid_runtime.retrieval.types.pinned_embedding`, so no call site repeats it.
|
|
17
|
+
|
|
18
|
+
Candidates: the retriever fetches more than ``topK`` candidates from each ranking
|
|
19
|
+
before fusing, because fusion and reranking both reorder, and a fused list built from
|
|
20
|
+
only ``topK`` inputs has nothing to reorder. ``topK`` is applied to the *result*,
|
|
21
|
+
which is what the IR field means.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
29
|
+
|
|
30
|
+
from aid_runtime.agents.guardrails import (
|
|
31
|
+
ACTION_BLOCK,
|
|
32
|
+
ACTION_ESCALATE,
|
|
33
|
+
ACTION_REDACT,
|
|
34
|
+
GuardrailEngine,
|
|
35
|
+
GuardrailRecord,
|
|
36
|
+
)
|
|
37
|
+
from aid_runtime.agents.ir import GuardrailDefinition
|
|
38
|
+
from aid_runtime.cost import Budget, CostLedger, LedgerEntry
|
|
39
|
+
from aid_runtime.errors import ConfigurationError, FeatureDeferredError, GuardrailBlockedError
|
|
40
|
+
from aid_runtime.retrieval.chunking import chunker_for
|
|
41
|
+
from aid_runtime.retrieval.citations import RetrievalResult, RetrievedItem, cite
|
|
42
|
+
from aid_runtime.retrieval.connectors import Connector, SourceDocument, open_connector
|
|
43
|
+
from aid_runtime.retrieval.embedding import Embedder, EmbeddingBatch, embedder_for
|
|
44
|
+
from aid_runtime.retrieval.hybrid import fuse, lexical_ranking
|
|
45
|
+
from aid_runtime.retrieval.rerank import Reranker, reranker_for
|
|
46
|
+
from aid_runtime.retrieval.stores import SearchHit, StoreRecord, VectorStore, create_store
|
|
47
|
+
from aid_runtime.retrieval.types import RefreshMode, RetrieverBinding, ScoredChunk
|
|
48
|
+
from aid_runtime.types import TokenUsage
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DEFAULT_CANDIDATE_FACTOR",
|
|
52
|
+
"IngestReport",
|
|
53
|
+
"Retriever",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: How many candidates per ranking are fetched before fusion. Four is a round number
|
|
57
|
+
#: with a defensible property: it lets a passage ranked 4th lexically and 1st by
|
|
58
|
+
#: vector similarity reach the top of the fused list, which is the case fusion exists
|
|
59
|
+
#: to fix.
|
|
60
|
+
DEFAULT_CANDIDATE_FACTOR = 4
|
|
61
|
+
|
|
62
|
+
#: Texts per embedding request. Batching bounds one request's payload, which is what
|
|
63
|
+
#: matters; a fixed size keeps the request count a function of the corpus.
|
|
64
|
+
_BATCH_SIZE = 64
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class IngestReport(BaseModel):
|
|
68
|
+
"""What one ingest wrote.
|
|
69
|
+
|
|
70
|
+
``chunk_ids`` is the load-bearing field for idempotence: it is sorted, so two
|
|
71
|
+
ingests of identical input compare equal without depending on the order the
|
|
72
|
+
filesystem happened to yield. ``cost_usd`` is what *this* ingest charged, not the
|
|
73
|
+
ledger's running total, so ingesting twice reads as two separate spends.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
77
|
+
|
|
78
|
+
retriever: str = Field(min_length=1)
|
|
79
|
+
documents: int = Field(ge=0)
|
|
80
|
+
chunks: int = Field(ge=0)
|
|
81
|
+
written: int = Field(ge=0)
|
|
82
|
+
tokens: int = Field(ge=0)
|
|
83
|
+
cost_usd: float = Field(ge=0.0)
|
|
84
|
+
chunk_ids: tuple[str, ...]
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_empty(self) -> bool:
|
|
88
|
+
return self.chunks == 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Retriever:
|
|
92
|
+
"""A bound retriever: its source, its chunker, its embedder, its store.
|
|
93
|
+
|
|
94
|
+
Seams are injectable and default from the binding. Tests inject the fake
|
|
95
|
+
embedder and an in-memory store; generated code passes the real ones.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
binding: RetrieverBinding,
|
|
101
|
+
*,
|
|
102
|
+
embedder: Embedder | None = None,
|
|
103
|
+
store: VectorStore | None = None,
|
|
104
|
+
reranker: Reranker | None = None,
|
|
105
|
+
connector: Connector | None = None,
|
|
106
|
+
ledger: CostLedger | None = None,
|
|
107
|
+
budget: Budget | None = None,
|
|
108
|
+
base_dir: str | None = None,
|
|
109
|
+
candidate_factor: int = DEFAULT_CANDIDATE_FACTOR,
|
|
110
|
+
guardrails: Sequence[GuardrailDefinition] = (),
|
|
111
|
+
) -> None:
|
|
112
|
+
if binding.refresh_mode is not RefreshMode.FULL:
|
|
113
|
+
raise FeatureDeferredError(
|
|
114
|
+
f"refresh mode {binding.refresh_mode.value!r} is declared but not implemented: "
|
|
115
|
+
"spec v0.1 reindexes the whole source, and incremental refresh needs change "
|
|
116
|
+
"detection the runtime does not have"
|
|
117
|
+
)
|
|
118
|
+
if candidate_factor < 1:
|
|
119
|
+
raise ConfigurationError("candidate_factor must be at least 1")
|
|
120
|
+
self._binding = binding
|
|
121
|
+
self._embedder = embedder or embedder_for(binding.embedding)
|
|
122
|
+
self._store = store or create_store(
|
|
123
|
+
binding.store_kind,
|
|
124
|
+
uri=binding.store_uri,
|
|
125
|
+
dimensions=binding.embedding.dimensions,
|
|
126
|
+
)
|
|
127
|
+
self._reranker = reranker or reranker_for(binding.rerank)
|
|
128
|
+
self._connector = connector
|
|
129
|
+
self._base_dir = base_dir
|
|
130
|
+
self._ledger = ledger
|
|
131
|
+
self._budget = budget
|
|
132
|
+
self._candidate_factor = candidate_factor
|
|
133
|
+
self._guardrails = tuple(guardrails)
|
|
134
|
+
self._guardrail_engine = GuardrailEngine()
|
|
135
|
+
self._closed = False
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def binding(self) -> RetrieverBinding:
|
|
139
|
+
return self._binding
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def store(self) -> VectorStore:
|
|
143
|
+
return self._store
|
|
144
|
+
|
|
145
|
+
async def ingest(self, documents: Sequence[SourceDocument] | None = None) -> IngestReport:
|
|
146
|
+
"""Chunk a source, embed it, and write it.
|
|
147
|
+
|
|
148
|
+
Idempotent by construction: chunk ids are content hashes and the store
|
|
149
|
+
upserts on them, so ingesting the same input twice replaces rather than
|
|
150
|
+
duplicates. Re-ingesting is therefore a legitimate way to refresh a corpus.
|
|
151
|
+
"""
|
|
152
|
+
source_documents = tuple(documents) if documents is not None else await self._read_source()
|
|
153
|
+
chunker = chunker_for(self._binding.chunking)
|
|
154
|
+
chunks = [chunk for document in source_documents for chunk in chunker.chunk(document)]
|
|
155
|
+
|
|
156
|
+
records: list[StoreRecord] = []
|
|
157
|
+
tokens = 0
|
|
158
|
+
cost = 0.0
|
|
159
|
+
for start in range(0, len(chunks), _BATCH_SIZE):
|
|
160
|
+
batch = chunks[start : start + _BATCH_SIZE]
|
|
161
|
+
embedded, batch_cost = await self._embed(tuple(chunk.text for chunk in batch))
|
|
162
|
+
tokens += embedded.usage.input_tokens
|
|
163
|
+
cost += batch_cost
|
|
164
|
+
records.extend(
|
|
165
|
+
StoreRecord(chunk=chunk, vector=vector)
|
|
166
|
+
for chunk, vector in zip(batch, embedded.vectors, strict=True)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
written = await self._store.upsert(records)
|
|
170
|
+
return IngestReport(
|
|
171
|
+
retriever=self._binding.retriever_id,
|
|
172
|
+
documents=len(source_documents),
|
|
173
|
+
chunks=len(chunks),
|
|
174
|
+
written=written,
|
|
175
|
+
tokens=tokens,
|
|
176
|
+
cost_usd=cost,
|
|
177
|
+
chunk_ids=tuple(sorted(chunk.chunk_id for chunk in chunks)),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def retrieve(self, query: str, *, top_k: int | None = None) -> RetrievalResult:
|
|
181
|
+
"""Search the store for ``query`` and return ranked, cited passages."""
|
|
182
|
+
wanted = top_k if top_k is not None else self._binding.top_k
|
|
183
|
+
if wanted < 1:
|
|
184
|
+
raise ConfigurationError("top_k must be at least 1")
|
|
185
|
+
|
|
186
|
+
batch, _ = await self._embed((query,))
|
|
187
|
+
vector = batch.vectors[0]
|
|
188
|
+
candidates = wanted * self._candidate_factor
|
|
189
|
+
hits = await self._store.search(vector, top_k=candidates, metric=self._binding.index.metric)
|
|
190
|
+
ranked = await self._rank(query, hits, candidates)
|
|
191
|
+
items = _to_items(ranked)
|
|
192
|
+
if self._reranker is not None:
|
|
193
|
+
top_n = self._binding.rerank.top_n if self._binding.rerank is not None else wanted
|
|
194
|
+
items = await self._reranker.rerank(query, items, top_n=top_n)
|
|
195
|
+
guarded, records = self._guard_retrieval(items[:wanted])
|
|
196
|
+
return RetrievalResult(
|
|
197
|
+
retriever=self._binding.retriever_id,
|
|
198
|
+
query=query,
|
|
199
|
+
items=guarded,
|
|
200
|
+
guardrails=records,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _guard_retrieval(
|
|
204
|
+
self, items: Sequence[RetrievedItem]
|
|
205
|
+
) -> tuple[tuple[RetrievedItem, ...], tuple[GuardrailRecord, ...]]:
|
|
206
|
+
"""Run the ``retrieval`` stage over each returned passage.
|
|
207
|
+
|
|
208
|
+
Per item, not over the concatenation: a redaction has to rewrite the one
|
|
209
|
+
passage it matched, and a joined string cannot be split back into passages
|
|
210
|
+
without guessing where each ended.
|
|
211
|
+
|
|
212
|
+
A retriever has no channel to ask a person on, so ``escalate`` blocks here
|
|
213
|
+
instead of pausing. Turning it into a silent pass would be the exact failure
|
|
214
|
+
mode the stage exists to prevent, and the agent loop -- which does have a
|
|
215
|
+
channel -- is where an escalation can pause a run.
|
|
216
|
+
"""
|
|
217
|
+
if not self._guardrails:
|
|
218
|
+
return tuple(items), ()
|
|
219
|
+
guarded: list[RetrievedItem] = []
|
|
220
|
+
records: list[GuardrailRecord] = []
|
|
221
|
+
for item in items:
|
|
222
|
+
verdict = self._guardrail_engine.check_retrieval(self._guardrails, (item.chunk.text,))
|
|
223
|
+
records.extend(verdict.records)
|
|
224
|
+
deciding = verdict.deciding
|
|
225
|
+
if deciding is None:
|
|
226
|
+
guarded.append(item)
|
|
227
|
+
continue
|
|
228
|
+
if deciding.action == ACTION_BLOCK:
|
|
229
|
+
raise GuardrailBlockedError(deciding.guardrail, "retrieval", deciding.message)
|
|
230
|
+
if deciding.action == ACTION_ESCALATE:
|
|
231
|
+
raise GuardrailBlockedError(
|
|
232
|
+
deciding.guardrail,
|
|
233
|
+
"retrieval",
|
|
234
|
+
f"{deciding.message} (a retriever has no human channel, so an "
|
|
235
|
+
"escalation here blocks rather than passing unchecked)",
|
|
236
|
+
)
|
|
237
|
+
if deciding.action == ACTION_REDACT:
|
|
238
|
+
item = item.model_copy(
|
|
239
|
+
update={"chunk": item.chunk.model_copy(update={"text": verdict.text})}
|
|
240
|
+
)
|
|
241
|
+
guarded.append(item)
|
|
242
|
+
return tuple(guarded), tuple(records)
|
|
243
|
+
|
|
244
|
+
async def aclose(self) -> None:
|
|
245
|
+
if self._closed:
|
|
246
|
+
return
|
|
247
|
+
self._closed = True
|
|
248
|
+
await self._store.aclose()
|
|
249
|
+
await self._embedder.aclose()
|
|
250
|
+
if self._connector is not None:
|
|
251
|
+
await self._connector.aclose()
|
|
252
|
+
|
|
253
|
+
async def _read_source(self) -> tuple[SourceDocument, ...]:
|
|
254
|
+
if self._connector is None:
|
|
255
|
+
self._connector = open_connector(self._binding.source, base_dir=self._base_dir)
|
|
256
|
+
return await self._connector.documents()
|
|
257
|
+
|
|
258
|
+
async def _embed(self, texts: Sequence[str]) -> tuple[EmbeddingBatch, float]:
|
|
259
|
+
"""Embed a batch, charge it to the ledger, and report what it cost.
|
|
260
|
+
|
|
261
|
+
The cost is returned as well as charged so a caller can report what one
|
|
262
|
+
operation spent: the ledger's total is cumulative across every call the
|
|
263
|
+
process made, and reporting that as this ingest's cost would double-count.
|
|
264
|
+
"""
|
|
265
|
+
batch = await self._embedder.embed(texts, model=self._binding.embedding)
|
|
266
|
+
usage = TokenUsage(
|
|
267
|
+
input_tokens=batch.usage.input_tokens,
|
|
268
|
+
output_tokens=batch.usage.output_tokens,
|
|
269
|
+
)
|
|
270
|
+
# Priced through CostProfile rather than a ModelSpec: a ModelSpec requires a
|
|
271
|
+
# context window and an output ceiling, and fabricating those to reuse a
|
|
272
|
+
# pricing helper would put invented numbers in a generated artifact.
|
|
273
|
+
cost = self._binding.embedding.cost.cost_usd(usage)
|
|
274
|
+
if self._ledger is not None:
|
|
275
|
+
self._ledger.charge(
|
|
276
|
+
self._budget,
|
|
277
|
+
LedgerEntry(
|
|
278
|
+
alias=self._binding.embed_model,
|
|
279
|
+
model=self._binding.embedding.model_id,
|
|
280
|
+
provider=self._binding.embedding.provider,
|
|
281
|
+
usage=usage,
|
|
282
|
+
cost_usd=cost,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
return batch, cost
|
|
286
|
+
|
|
287
|
+
async def _rank(
|
|
288
|
+
self,
|
|
289
|
+
query: str,
|
|
290
|
+
hits: Sequence[SearchHit],
|
|
291
|
+
candidates: int,
|
|
292
|
+
) -> tuple[ScoredChunk, ...]:
|
|
293
|
+
"""The candidate ranking: vector only, or fused with a lexical ranking.
|
|
294
|
+
|
|
295
|
+
Fusion is skipped when ``index.hybrid`` is false, and that is the whole
|
|
296
|
+
meaning of the flag -- not "use a different metric", but "do not consult the
|
|
297
|
+
lexical ranking at all".
|
|
298
|
+
"""
|
|
299
|
+
if not self._binding.index.hybrid:
|
|
300
|
+
return tuple(ScoredChunk(chunk=hit.record.chunk, score=hit.score) for hit in hits)
|
|
301
|
+
|
|
302
|
+
corpus = await self._store.corpus()
|
|
303
|
+
by_id: dict[str, StoreRecord] = {record.chunk.chunk_id: record for record in corpus}
|
|
304
|
+
for hit in hits:
|
|
305
|
+
by_id.setdefault(hit.record.chunk.chunk_id, hit.record)
|
|
306
|
+
lexical = lexical_ranking(
|
|
307
|
+
[record.chunk.text for record in corpus],
|
|
308
|
+
[record.chunk.chunk_id for record in corpus],
|
|
309
|
+
query,
|
|
310
|
+
top_k=candidates,
|
|
311
|
+
)
|
|
312
|
+
fused = fuse(
|
|
313
|
+
[
|
|
314
|
+
[hit.record.chunk.chunk_id for hit in hits],
|
|
315
|
+
[entry.chunk_id for entry in lexical],
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
return tuple(
|
|
319
|
+
ScoredChunk(chunk=by_id[entry.chunk_id].chunk, score=entry.score)
|
|
320
|
+
for entry in fused
|
|
321
|
+
if entry.chunk_id in by_id
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _to_items(ranked: Sequence[ScoredChunk]) -> tuple[RetrievedItem, ...]:
|
|
326
|
+
"""Attach provenance and assign contiguous 1-based ranks."""
|
|
327
|
+
return tuple(
|
|
328
|
+
RetrievedItem(
|
|
329
|
+
chunk=scored.chunk,
|
|
330
|
+
score=scored.score,
|
|
331
|
+
rank=rank,
|
|
332
|
+
citation=cite(scored.chunk),
|
|
333
|
+
)
|
|
334
|
+
for rank, scored in enumerate(ranked, start=1)
|
|
335
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Where vectors live and how they are searched.
|
|
2
|
+
|
|
3
|
+
Two adapters behind one protocol: in-memory and pgvector. The in-memory store is not
|
|
4
|
+
a stand-in for a "real" one -- it is the adapter every gate runs against, so it is
|
|
5
|
+
the one whose behaviour is the contract. The pgvector adapter is covered by the same
|
|
6
|
+
expectations and by nothing else: it is locally provisioned, and no required gate
|
|
7
|
+
touches a database.
|
|
8
|
+
|
|
9
|
+
Similarity is always reported as *higher is better*, including for L2, where the raw
|
|
10
|
+
quantity is a distance. A store that inverted its sign for one metric would make
|
|
11
|
+
every caller responsible for remembering which metric it asked for, and fusion and
|
|
12
|
+
reranking downstream would silently rank backwards.
|
|
13
|
+
|
|
14
|
+
Lexical search is deliberately absent from the store. The store owns the vector
|
|
15
|
+
index; BM25 lives in :mod:`aid_runtime.retrieval.hybrid` and reads the corpus through
|
|
16
|
+
:meth:`VectorStore.corpus`. That keeps one lexical implementation instead of one per
|
|
17
|
+
backend, at the cost of fetching the corpus per query -- a Phase 1 simplification,
|
|
18
|
+
correct at test scale and worth revisiting when a corpus outgrows memory.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from typing import Protocol, runtime_checkable
|
|
26
|
+
|
|
27
|
+
from aid_runtime.errors import ConfigurationError, FeatureDeferredError
|
|
28
|
+
from aid_runtime.retrieval.types import DistanceMetric, SearchHit, StoreKind, StoreRecord
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"InMemoryVectorStore",
|
|
32
|
+
"SearchHit",
|
|
33
|
+
"StoreRecord",
|
|
34
|
+
"VectorStore",
|
|
35
|
+
"create_store",
|
|
36
|
+
"similarity",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def similarity(
|
|
41
|
+
left: Sequence[float],
|
|
42
|
+
right: Sequence[float],
|
|
43
|
+
*,
|
|
44
|
+
metric: DistanceMetric,
|
|
45
|
+
) -> float:
|
|
46
|
+
"""Similarity of two equal-length vectors under ``metric``.
|
|
47
|
+
|
|
48
|
+
Always oriented so that a larger number means a better match.
|
|
49
|
+
"""
|
|
50
|
+
if len(left) != len(right):
|
|
51
|
+
raise ConfigurationError(
|
|
52
|
+
f"cannot compare vectors of {len(left)} and {len(right)} dimensions; the query and "
|
|
53
|
+
"the stored vectors must share the embedding's pinned width"
|
|
54
|
+
)
|
|
55
|
+
dot = sum(a * b for a, b in zip(left, right, strict=True))
|
|
56
|
+
if metric is DistanceMetric.DOT:
|
|
57
|
+
return dot
|
|
58
|
+
if metric is DistanceMetric.L2:
|
|
59
|
+
return -math.sqrt(sum((a - b) ** 2 for a, b in zip(left, right, strict=True)))
|
|
60
|
+
left_norm = math.sqrt(sum(a * a for a in left))
|
|
61
|
+
right_norm = math.sqrt(sum(b * b for b in right))
|
|
62
|
+
if left_norm == 0.0 or right_norm == 0.0:
|
|
63
|
+
# A zero vector has no direction, so cosine similarity is undefined rather
|
|
64
|
+
# than zero; returning 0.0 keeps it last without inventing a ranking.
|
|
65
|
+
return 0.0
|
|
66
|
+
return dot / (left_norm * right_norm)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@runtime_checkable
|
|
70
|
+
class VectorStore(Protocol):
|
|
71
|
+
"""A vector index the retriever can ingest into and search.
|
|
72
|
+
|
|
73
|
+
``aclose`` must be idempotent, matching the other seams in this runtime.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def kind(self) -> StoreKind: ...
|
|
78
|
+
|
|
79
|
+
async def upsert(self, records: Sequence[StoreRecord]) -> int:
|
|
80
|
+
"""Write records, keyed by ``chunk_id``. Returns the number written."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
async def search(
|
|
84
|
+
self, vector: Sequence[float], *, top_k: int, metric: DistanceMetric
|
|
85
|
+
) -> tuple[SearchHit, ...]: ...
|
|
86
|
+
|
|
87
|
+
async def corpus(self) -> tuple[StoreRecord, ...]:
|
|
88
|
+
"""Every stored record.
|
|
89
|
+
|
|
90
|
+
Exists for the lexical half of hybrid search, and for assertions about a
|
|
91
|
+
store's contents after re-ingest.
|
|
92
|
+
"""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
async def aclose(self) -> None: ...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class InMemoryVectorStore:
|
|
99
|
+
"""A dict keyed by ``chunk_id``.
|
|
100
|
+
|
|
101
|
+
The key choice is what makes ingest idempotent: re-ingesting identical input
|
|
102
|
+
recomputes identical ids and overwrites in place, so the store's contents are a
|
|
103
|
+
function of the input rather than of how many times it was ingested.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, *, kind: StoreKind = StoreKind.MEMORY) -> None:
|
|
107
|
+
self._kind = kind
|
|
108
|
+
self._records: dict[str, StoreRecord] = {}
|
|
109
|
+
self._closed = False
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def kind(self) -> StoreKind:
|
|
113
|
+
return self._kind
|
|
114
|
+
|
|
115
|
+
async def upsert(self, records: Sequence[StoreRecord]) -> int:
|
|
116
|
+
for record in records:
|
|
117
|
+
self._records[record.chunk.chunk_id] = record
|
|
118
|
+
return len(records)
|
|
119
|
+
|
|
120
|
+
async def search(
|
|
121
|
+
self, vector: Sequence[float], *, top_k: int, metric: DistanceMetric
|
|
122
|
+
) -> tuple[SearchHit, ...]:
|
|
123
|
+
if top_k < 1:
|
|
124
|
+
raise ConfigurationError("top_k must be at least 1")
|
|
125
|
+
hits = [
|
|
126
|
+
SearchHit(record=record, score=similarity(vector, record.vector, metric=metric))
|
|
127
|
+
for record in self._records.values()
|
|
128
|
+
]
|
|
129
|
+
# Ties break on chunk id, never on insertion order: two runs that ingest the
|
|
130
|
+
# same corpus in a different order must produce the same ranking.
|
|
131
|
+
hits.sort(key=lambda hit: (-hit.score, hit.record.chunk.chunk_id))
|
|
132
|
+
return tuple(hits[:top_k])
|
|
133
|
+
|
|
134
|
+
async def corpus(self) -> tuple[StoreRecord, ...]:
|
|
135
|
+
return tuple(self._records[chunk_id] for chunk_id in sorted(self._records))
|
|
136
|
+
|
|
137
|
+
async def aclose(self) -> None:
|
|
138
|
+
self._closed = True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def create_store(
|
|
142
|
+
kind: StoreKind,
|
|
143
|
+
*,
|
|
144
|
+
uri: str | None = None,
|
|
145
|
+
dimensions: int | None = None,
|
|
146
|
+
) -> VectorStore:
|
|
147
|
+
"""Select a store adapter.
|
|
148
|
+
|
|
149
|
+
Imported lazily so that importing this package never pulls in a database driver:
|
|
150
|
+
the driver is an optional extra, and CI installs only the dev extra.
|
|
151
|
+
"""
|
|
152
|
+
if kind is StoreKind.MEMORY:
|
|
153
|
+
return InMemoryVectorStore()
|
|
154
|
+
if kind is StoreKind.PGVECTOR:
|
|
155
|
+
from aid_runtime.retrieval.pgvector import PgVectorStore
|
|
156
|
+
|
|
157
|
+
return PgVectorStore(uri=uri, dimensions=dimensions)
|
|
158
|
+
raise FeatureDeferredError(
|
|
159
|
+
f"vector store kind {kind.value!r} is declared in the IR but has no adapter in this "
|
|
160
|
+
"runtime; Phase 1 ships 'memory' and 'pgvector'"
|
|
161
|
+
)
|