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,213 @@
|
|
|
1
|
+
"""Metrics over a retrieved set.
|
|
2
|
+
|
|
3
|
+
The shipped :class:`~aid_runtime.evals.metrics.Metric` scores ``(case, prediction:
|
|
4
|
+
str)``. That signature cannot express these metrics: recall@k and MRR score a *ranked
|
|
5
|
+
set of passages with provenance*, and a citation-grounding score has to look at where
|
|
6
|
+
each passage came from. Encoding a retrieved set into one string would make every
|
|
7
|
+
metric re-parse it, and the parse would become the thing under test.
|
|
8
|
+
|
|
9
|
+
So the retrieval metrics get their own protocol here rather than widening the shipped
|
|
10
|
+
one. Widening ``Metric`` would change the meaning of an interface the eval gate
|
|
11
|
+
already threshold-checks, and would do so for every existing implementation. The new
|
|
12
|
+
protocol is additive and reuses :class:`~aid_runtime.evals.metrics.MetricResult`.
|
|
13
|
+
|
|
14
|
+
The two protocols share the part the gate actually consumes -- ``name`` and
|
|
15
|
+
``deterministic`` -- which is what lets retrieval metrics be handed to the shipped
|
|
16
|
+
gate's ``metrics=`` parameter. See :mod:`aid_runtime.retrieval.gate` for why that
|
|
17
|
+
hand-off needs a cast and why the cast is sound.
|
|
18
|
+
|
|
19
|
+
``deterministic`` is ``True`` for all three, and that is a claim the gate enforces:
|
|
20
|
+
none of these metrics calls a model, and every tie is broken by chunk id, so the same
|
|
21
|
+
retrieved set always scores the same.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Protocol, runtime_checkable
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
29
|
+
|
|
30
|
+
from aid_runtime.evals.metrics import MetricResult
|
|
31
|
+
from aid_runtime.retrieval.citations import RetrievalResult
|
|
32
|
+
from aid_runtime.retrieval.types import RetrievalMetricSpec
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CitationGrounding",
|
|
36
|
+
"MeanReciprocalRank",
|
|
37
|
+
"RecallAtK",
|
|
38
|
+
"RetrievalCase",
|
|
39
|
+
"RetrievalMetric",
|
|
40
|
+
"retrieval_metrics",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RetrievalCase(BaseModel):
|
|
45
|
+
"""One retrieval question and the documents that should answer it.
|
|
46
|
+
|
|
47
|
+
This mirrors the IR's ``retrieval`` metric node field for field, and it exists
|
|
48
|
+
because :class:`~aid_runtime.evals.dataset.EvalCase` carries ``metadata:
|
|
49
|
+
dict[str, str]`` -- a string map, which cannot hold ``expectDocs`` without
|
|
50
|
+
inventing an encoding. The expected documents, and ``k``, are what a retrieval
|
|
51
|
+
metric needs, so they are typed here instead of smuggled through a string.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
55
|
+
|
|
56
|
+
id: str = Field(min_length=1)
|
|
57
|
+
query: str
|
|
58
|
+
expect_docs: tuple[str, ...] = Field(min_length=1)
|
|
59
|
+
k: int = Field(gt=0)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_spec(cls, *, case_id: str, query: str, spec: RetrievalMetricSpec) -> RetrievalCase:
|
|
63
|
+
"""Build a case from the IR's ``{kind, expectDocs, k}`` node."""
|
|
64
|
+
return cls(id=case_id, query=query, expect_docs=spec.expect_docs, k=spec.k)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@runtime_checkable
|
|
68
|
+
class RetrievalMetric(Protocol):
|
|
69
|
+
"""Scores a ranked, cited retrieved set.
|
|
70
|
+
|
|
71
|
+
``name`` and ``deterministic`` are part of the contract because the shipped gate
|
|
72
|
+
reads exactly those two properties when it checks that a thresholded metric is
|
|
73
|
+
allowed to gate CI.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def name(self) -> str: ...
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def deterministic(self) -> bool: ...
|
|
81
|
+
|
|
82
|
+
async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult: ...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RecallAtK:
|
|
86
|
+
"""Fraction of expected documents present in the top ``k``.
|
|
87
|
+
|
|
88
|
+
Duplicated passages do not inflate the score: :attr:`RetrievalResult.doc_ids` is
|
|
89
|
+
de-duplicated, so a document that contributed three passages counts once. Recall
|
|
90
|
+
is about which documents were found.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, *, name: str = "recall@k", min_score: float = 1.0) -> None:
|
|
94
|
+
self._name = name
|
|
95
|
+
self._min_score = min_score
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def name(self) -> str:
|
|
99
|
+
return self._name
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def deterministic(self) -> bool:
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult:
|
|
106
|
+
expected = set(case.expect_docs)
|
|
107
|
+
found = expected & set(retrieved.doc_ids[: case.k])
|
|
108
|
+
score = len(found) / len(expected)
|
|
109
|
+
missing = sorted(expected - found)
|
|
110
|
+
return MetricResult(
|
|
111
|
+
metric=self.name,
|
|
112
|
+
score=score,
|
|
113
|
+
passed=score >= self._min_score,
|
|
114
|
+
detail="" if not missing else f"missing: {', '.join(missing)}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MeanReciprocalRank:
|
|
119
|
+
"""Reciprocal rank of the first expected document, averaged over cases.
|
|
120
|
+
|
|
121
|
+
Scored per case, so the "mean" is taken by the gate across cases and runs. One
|
|
122
|
+
number per case keeps the metric a drop-in for the gate's per-case arithmetic.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, *, name: str = "mrr", min_score: float = 0.5) -> None:
|
|
126
|
+
self._name = name
|
|
127
|
+
self._min_score = min_score
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def name(self) -> str:
|
|
131
|
+
return self._name
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def deterministic(self) -> bool:
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult:
|
|
138
|
+
expected = set(case.expect_docs)
|
|
139
|
+
for rank, doc_id in enumerate(retrieved.doc_ids[: case.k], start=1):
|
|
140
|
+
if doc_id in expected:
|
|
141
|
+
score = 1.0 / rank
|
|
142
|
+
return MetricResult(
|
|
143
|
+
metric=self.name,
|
|
144
|
+
score=score,
|
|
145
|
+
passed=score >= self._min_score,
|
|
146
|
+
detail=f"first expected document {doc_id!r} at rank {rank}",
|
|
147
|
+
)
|
|
148
|
+
return MetricResult(
|
|
149
|
+
metric=self.name,
|
|
150
|
+
score=0.0,
|
|
151
|
+
passed=self._min_score <= 0.0,
|
|
152
|
+
detail=f"none of {', '.join(sorted(expected))} were retrieved within k={case.k}",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CitationGrounding:
|
|
157
|
+
"""Fraction of expected documents that a returned item cites *correctly*.
|
|
158
|
+
|
|
159
|
+
Distinct from recall because it scores citations rather than document ids, and
|
|
160
|
+
because it checks integrity first: a citation whose ``chunk_id`` or ``doc_id``
|
|
161
|
+
disagrees with the passage it is attached to is worse than a missing citation,
|
|
162
|
+
since it sends a reader to the wrong place while looking authoritative. Any
|
|
163
|
+
such mismatch scores 0 regardless of what was retrieved.
|
|
164
|
+
|
|
165
|
+
This definition is authored here: the IR has no citation metric kind, so there is
|
|
166
|
+
no upstream semantics to match. It is stated rather than derived.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, *, name: str = "citation-grounding", min_score: float = 1.0) -> None:
|
|
170
|
+
self._name = name
|
|
171
|
+
self._min_score = min_score
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def name(self) -> str:
|
|
175
|
+
return self._name
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def deterministic(self) -> bool:
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
async def score(self, case: RetrievalCase, retrieved: RetrievalResult) -> MetricResult:
|
|
182
|
+
broken = [
|
|
183
|
+
item
|
|
184
|
+
for item in retrieved.items
|
|
185
|
+
if item.citation.chunk_id != item.chunk.chunk_id
|
|
186
|
+
or item.citation.doc_id != item.chunk.doc_id
|
|
187
|
+
]
|
|
188
|
+
if broken:
|
|
189
|
+
return MetricResult(
|
|
190
|
+
metric=self.name,
|
|
191
|
+
score=0.0,
|
|
192
|
+
passed=False,
|
|
193
|
+
detail=(
|
|
194
|
+
f"{len(broken)} citation(s) disagree with the passage they name, "
|
|
195
|
+
f"first: {broken[0].citation} vs chunk {broken[0].chunk.chunk_id}"
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
expected = set(case.expect_docs)
|
|
199
|
+
cited = {item.citation.doc_id for item in retrieved.items}
|
|
200
|
+
grounded = expected & cited
|
|
201
|
+
score = len(grounded) / len(expected)
|
|
202
|
+
missing = sorted(expected - grounded)
|
|
203
|
+
return MetricResult(
|
|
204
|
+
metric=self.name,
|
|
205
|
+
score=score,
|
|
206
|
+
passed=score >= self._min_score,
|
|
207
|
+
detail="" if not missing else f"uncited: {', '.join(missing)}",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def retrieval_metrics() -> tuple[RetrievalMetric, ...]:
|
|
212
|
+
"""The retrieval metrics this runtime ships, in a stable order."""
|
|
213
|
+
return (RecallAtK(), MeanReciprocalRank(), CitationGrounding())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""A pgvector-backed store, provisioned locally and never touched by CI.
|
|
2
|
+
|
|
3
|
+
The driver is an optional extra, so this module must be importable without it.
|
|
4
|
+
Nothing at module scope imports ``psycopg``; the import happens inside
|
|
5
|
+
:func:`_load_driver` on first use, and a missing driver becomes a
|
|
6
|
+
:class:`~aid_runtime.errors.ConfigurationError` that names the extra to install.
|
|
7
|
+
Importing :mod:`aid_runtime.retrieval` therefore cannot fail because a database
|
|
8
|
+
extra is absent, which is what lets the default dependency set stay unchanged.
|
|
9
|
+
|
|
10
|
+
Two consequences of that choice are deliberate and visible:
|
|
11
|
+
|
|
12
|
+
* The driver is loaded as an untyped module, so ``Any`` reaches this file. It is
|
|
13
|
+
confined to this one adapter rather than spreading a suppressed import across the
|
|
14
|
+
retrieval package.
|
|
15
|
+
* Every statement is a fixed string. The embedding column is declared
|
|
16
|
+
dimension-less rather than ``vector(1536)``, so no computed identifier is ever
|
|
17
|
+
interpolated into SQL, and the pinned width is enforced where the pin actually
|
|
18
|
+
lives: the caller's :class:`~aid_runtime.retrieval.types.PinnedEmbedding`.
|
|
19
|
+
|
|
20
|
+
Correctness here is expected to match the in-memory adapter, but nothing checks
|
|
21
|
+
that: this adapter is not gate-covered, and its SQL path runs only on a machine where
|
|
22
|
+
someone brought the database up themselves. See ``compose.pgvector.yml``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from aid_runtime.errors import ConfigurationError, ProviderError
|
|
33
|
+
from aid_runtime.retrieval.types import (
|
|
34
|
+
Chunk,
|
|
35
|
+
DistanceMetric,
|
|
36
|
+
SearchHit,
|
|
37
|
+
StoreKind,
|
|
38
|
+
StoreRecord,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = ["PgVectorStore", "resolve_uri"]
|
|
42
|
+
|
|
43
|
+
_ENV_PREFIX = "env:"
|
|
44
|
+
|
|
45
|
+
_CREATE_EXTENSION = "CREATE EXTENSION IF NOT EXISTS vector"
|
|
46
|
+
_CREATE_TABLE = """
|
|
47
|
+
CREATE TABLE IF NOT EXISTS aid_chunks (
|
|
48
|
+
chunk_id text PRIMARY KEY,
|
|
49
|
+
doc_id text NOT NULL,
|
|
50
|
+
uri text NOT NULL,
|
|
51
|
+
chunk_index integer NOT NULL,
|
|
52
|
+
start_offset integer NOT NULL,
|
|
53
|
+
end_offset integer NOT NULL,
|
|
54
|
+
body text NOT NULL,
|
|
55
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
56
|
+
embedding vector NOT NULL
|
|
57
|
+
)
|
|
58
|
+
"""
|
|
59
|
+
_UPSERT = """
|
|
60
|
+
INSERT INTO aid_chunks (
|
|
61
|
+
chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, embedding
|
|
62
|
+
) VALUES (
|
|
63
|
+
%(chunk_id)s, %(doc_id)s, %(uri)s, %(chunk_index)s, %(start_offset)s, %(end_offset)s,
|
|
64
|
+
%(body)s, %(metadata)s::jsonb, %(embedding)s::vector
|
|
65
|
+
)
|
|
66
|
+
ON CONFLICT (chunk_id) DO UPDATE SET
|
|
67
|
+
doc_id = EXCLUDED.doc_id,
|
|
68
|
+
uri = EXCLUDED.uri,
|
|
69
|
+
chunk_index = EXCLUDED.chunk_index,
|
|
70
|
+
start_offset = EXCLUDED.start_offset,
|
|
71
|
+
end_offset = EXCLUDED.end_offset,
|
|
72
|
+
body = EXCLUDED.body,
|
|
73
|
+
metadata = EXCLUDED.metadata,
|
|
74
|
+
embedding = EXCLUDED.embedding
|
|
75
|
+
"""
|
|
76
|
+
_CORPUS = (
|
|
77
|
+
"SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
|
|
78
|
+
"embedding FROM aid_chunks ORDER BY chunk_id"
|
|
79
|
+
)
|
|
80
|
+
# `<=>` is cosine distance, `<#>` is negative inner product and `<->` is L2 distance.
|
|
81
|
+
# Each is rewritten into "higher is better" so callers see one orientation, matching
|
|
82
|
+
# `stores.similarity`. The tie-break on `chunk_id` matches the in-memory adapter too:
|
|
83
|
+
# equal scores must rank identically on both.
|
|
84
|
+
#
|
|
85
|
+
# Every statement is spelled out in full rather than assembled from a column list, so
|
|
86
|
+
# that no statement is ever *constructed*: the only values that reach SQL are bound
|
|
87
|
+
# parameters.
|
|
88
|
+
_SEARCH: dict[DistanceMetric, str] = {
|
|
89
|
+
DistanceMetric.COSINE: (
|
|
90
|
+
"SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
|
|
91
|
+
"embedding, 1 - (embedding <=> %(vector)s::vector) AS score FROM aid_chunks "
|
|
92
|
+
"ORDER BY embedding <=> %(vector)s::vector, chunk_id LIMIT %(top_k)s"
|
|
93
|
+
),
|
|
94
|
+
DistanceMetric.DOT: (
|
|
95
|
+
"SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
|
|
96
|
+
"embedding, -(embedding <#> %(vector)s::vector) AS score FROM aid_chunks "
|
|
97
|
+
"ORDER BY embedding <#> %(vector)s::vector, chunk_id LIMIT %(top_k)s"
|
|
98
|
+
),
|
|
99
|
+
DistanceMetric.L2: (
|
|
100
|
+
"SELECT chunk_id, doc_id, uri, chunk_index, start_offset, end_offset, body, metadata, "
|
|
101
|
+
"embedding, -(embedding <-> %(vector)s::vector) AS score FROM aid_chunks "
|
|
102
|
+
"ORDER BY embedding <-> %(vector)s::vector, chunk_id LIMIT %(top_k)s"
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load_driver() -> Any:
|
|
108
|
+
"""Import the optional driver, or explain how to install it."""
|
|
109
|
+
try:
|
|
110
|
+
import psycopg
|
|
111
|
+
except ImportError as error:
|
|
112
|
+
raise ConfigurationError(
|
|
113
|
+
"the pgvector store needs its driver; install it with "
|
|
114
|
+
"`pip install aid-runtime-python[pgvector]` (the default install omits it)"
|
|
115
|
+
) from error
|
|
116
|
+
return psycopg
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_uri(uri: str | None) -> str:
|
|
120
|
+
"""Resolve an ``env:NAME`` reference to the variable's value.
|
|
121
|
+
|
|
122
|
+
An ``env:`` reference names a variable, not an alias: nothing here reaches the
|
|
123
|
+
model catalog, so resolving it at run time cannot silently change which store a
|
|
124
|
+
run wrote to.
|
|
125
|
+
"""
|
|
126
|
+
if uri is None:
|
|
127
|
+
raise ConfigurationError("the pgvector store requires a uri")
|
|
128
|
+
if not uri.startswith(_ENV_PREFIX):
|
|
129
|
+
return uri
|
|
130
|
+
name = uri[len(_ENV_PREFIX) :]
|
|
131
|
+
if not name:
|
|
132
|
+
raise ConfigurationError(f"store uri {uri!r} names an empty environment variable")
|
|
133
|
+
value = os.environ.get(name)
|
|
134
|
+
if value is None:
|
|
135
|
+
raise ConfigurationError(
|
|
136
|
+
f"store uri {uri!r} refers to environment variable {name!r}, which is unset"
|
|
137
|
+
)
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _literal(vector: Sequence[float]) -> str:
|
|
142
|
+
"""pgvector's text input format, so its Python package is not a dependency."""
|
|
143
|
+
return "[" + ",".join(repr(float(value)) for value in vector) + "]"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PgVectorStore:
|
|
147
|
+
"""A single-table store: one row per chunk, keyed by content hash."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, *, uri: str | None, dimensions: int | None = None) -> None:
|
|
150
|
+
self._uri = resolve_uri(uri)
|
|
151
|
+
self._dimensions = dimensions
|
|
152
|
+
self._connection: Any = None
|
|
153
|
+
self._closed = False
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def kind(self) -> StoreKind:
|
|
157
|
+
return StoreKind.PGVECTOR
|
|
158
|
+
|
|
159
|
+
async def _connect(self) -> Any:
|
|
160
|
+
if self._closed:
|
|
161
|
+
raise ConfigurationError("this store has been closed")
|
|
162
|
+
if self._connection is None:
|
|
163
|
+
psycopg = _load_driver()
|
|
164
|
+
try:
|
|
165
|
+
self._connection = await psycopg.AsyncConnection.connect(self._uri)
|
|
166
|
+
except Exception as error:
|
|
167
|
+
raise ProviderError(f"cannot connect to the pgvector store: {error}") from error
|
|
168
|
+
await self._ensure_schema()
|
|
169
|
+
return self._connection
|
|
170
|
+
|
|
171
|
+
async def _ensure_schema(self) -> None:
|
|
172
|
+
async with self._connection.cursor() as cursor:
|
|
173
|
+
await cursor.execute(_CREATE_EXTENSION)
|
|
174
|
+
await cursor.execute(_CREATE_TABLE)
|
|
175
|
+
await self._connection.commit()
|
|
176
|
+
|
|
177
|
+
async def upsert(self, records: Sequence[StoreRecord]) -> int:
|
|
178
|
+
if self._dimensions is not None:
|
|
179
|
+
for record in records:
|
|
180
|
+
if len(record.vector) != self._dimensions:
|
|
181
|
+
raise ConfigurationError(
|
|
182
|
+
f"store is pinned to {self._dimensions} dimensions but a record carries "
|
|
183
|
+
f"{len(record.vector)}"
|
|
184
|
+
)
|
|
185
|
+
if not records:
|
|
186
|
+
return 0
|
|
187
|
+
connection = await self._connect()
|
|
188
|
+
async with connection.cursor() as cursor:
|
|
189
|
+
for record in records:
|
|
190
|
+
await cursor.execute(_UPSERT, self._params(record))
|
|
191
|
+
await connection.commit()
|
|
192
|
+
return len(records)
|
|
193
|
+
|
|
194
|
+
async def search(
|
|
195
|
+
self, vector: Sequence[float], *, top_k: int, metric: DistanceMetric
|
|
196
|
+
) -> tuple[SearchHit, ...]:
|
|
197
|
+
if top_k < 1:
|
|
198
|
+
raise ConfigurationError("top_k must be at least 1")
|
|
199
|
+
statement = _SEARCH.get(metric)
|
|
200
|
+
if statement is None:
|
|
201
|
+
raise ConfigurationError(f"pgvector cannot rank by {metric.value!r}")
|
|
202
|
+
connection = await self._connect()
|
|
203
|
+
async with connection.cursor() as cursor:
|
|
204
|
+
await cursor.execute(statement, {"vector": _literal(vector), "top_k": top_k})
|
|
205
|
+
rows = await cursor.fetchall()
|
|
206
|
+
return tuple(SearchHit(record=self._record(row), score=float(row[9])) for row in rows)
|
|
207
|
+
|
|
208
|
+
async def corpus(self) -> tuple[StoreRecord, ...]:
|
|
209
|
+
connection = await self._connect()
|
|
210
|
+
async with connection.cursor() as cursor:
|
|
211
|
+
await cursor.execute(_CORPUS)
|
|
212
|
+
rows = await cursor.fetchall()
|
|
213
|
+
return tuple(self._record(row) for row in rows)
|
|
214
|
+
|
|
215
|
+
async def aclose(self) -> None:
|
|
216
|
+
if self._closed:
|
|
217
|
+
return
|
|
218
|
+
self._closed = True
|
|
219
|
+
if self._connection is not None:
|
|
220
|
+
await self._connection.close()
|
|
221
|
+
self._connection = None
|
|
222
|
+
|
|
223
|
+
def _params(self, record: StoreRecord) -> dict[str, Any]:
|
|
224
|
+
chunk = record.chunk
|
|
225
|
+
return {
|
|
226
|
+
"chunk_id": chunk.chunk_id,
|
|
227
|
+
"doc_id": chunk.doc_id,
|
|
228
|
+
"uri": chunk.uri,
|
|
229
|
+
"chunk_index": chunk.index,
|
|
230
|
+
"start_offset": chunk.start,
|
|
231
|
+
"end_offset": chunk.end,
|
|
232
|
+
"body": chunk.text,
|
|
233
|
+
"metadata": json.dumps(chunk.metadata, sort_keys=True),
|
|
234
|
+
"embedding": _literal(record.vector),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def _record(self, row: Sequence[Any]) -> StoreRecord:
|
|
238
|
+
return StoreRecord(
|
|
239
|
+
chunk=Chunk(
|
|
240
|
+
chunk_id=row[0],
|
|
241
|
+
doc_id=row[1],
|
|
242
|
+
uri=row[2],
|
|
243
|
+
index=row[3],
|
|
244
|
+
start=row[4],
|
|
245
|
+
end=row[5],
|
|
246
|
+
text=row[6],
|
|
247
|
+
metadata=row[7] or {},
|
|
248
|
+
),
|
|
249
|
+
vector=tuple(float(value) for value in _parse_vector(row[8])),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_vector(value: Any) -> list[float]:
|
|
254
|
+
"""Accept either a driver-returned sequence or pgvector's text form."""
|
|
255
|
+
if isinstance(value, str):
|
|
256
|
+
stripped = value.strip().strip("[]")
|
|
257
|
+
return [float(part) for part in stripped.split(",")] if stripped else []
|
|
258
|
+
return [float(item) for item in value]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Reordering a retrieved set before it is returned.
|
|
2
|
+
|
|
3
|
+
Exactly one reranker exists in Phase 1, and it is lexical. That is a deliberate
|
|
4
|
+
limitation rather than an unfinished one: the seam is what this layer owes, and the
|
|
5
|
+
deterministic implementation is what lets a gate assert on ranking behaviour without
|
|
6
|
+
an API key or a floating cross-encoder.
|
|
7
|
+
|
|
8
|
+
Why rerank at all when hybrid search already blends two rankings? Because fusion
|
|
9
|
+
answers "which candidates are worth considering" and reranking answers "which of
|
|
10
|
+
these best answers the question", and the second question can look at the query and
|
|
11
|
+
one passage together instead of at ranks alone. The lexical reranker does that with
|
|
12
|
+
query-term coverage, which is cheap, explainable, and stable.
|
|
13
|
+
|
|
14
|
+
The reranker **replaces** each item's score instead of adding a second number. After
|
|
15
|
+
reranking, the number that ranks the list is the reranker's, and keeping a fused RRF
|
|
16
|
+
value in the same field would put two incomparable scales in one place. Ranks are
|
|
17
|
+
renumbered from 1 so ``RetrievalResult.context`` stays resolvable.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from typing import Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from aid_runtime.errors import ConfigurationError, FeatureDeferredError
|
|
26
|
+
from aid_runtime.retrieval.citations import RetrievedItem, cite
|
|
27
|
+
from aid_runtime.retrieval.hybrid import tokenize
|
|
28
|
+
from aid_runtime.retrieval.types import RerankSpec
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"LexicalReranker",
|
|
32
|
+
"Reranker",
|
|
33
|
+
"lexical_score",
|
|
34
|
+
"reranker_for",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
_LEXICAL_MARKER = "lexical"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def lexical_score(query: str, text: str) -> float:
|
|
41
|
+
"""How much of ``query`` the passage actually covers, in ``[0, 1]``.
|
|
42
|
+
|
|
43
|
+
Query-term coverage, not term frequency: for "which passages answer this", a
|
|
44
|
+
passage containing each distinct query term once is as good a candidate as one
|
|
45
|
+
repeating a term, and rewarding repetition is how a keyword-stuffed chunk wins.
|
|
46
|
+
An exact phrase match breaks ties toward the passage quoting the query.
|
|
47
|
+
"""
|
|
48
|
+
terms = set(tokenize(query))
|
|
49
|
+
if not terms:
|
|
50
|
+
return 0.0
|
|
51
|
+
present = set(tokenize(text))
|
|
52
|
+
coverage = len(terms & present) / len(terms)
|
|
53
|
+
if query.strip() and query.strip().lower() in text.lower():
|
|
54
|
+
# Small and bounded, so it can only order passages that already cover the
|
|
55
|
+
# query equally well; it can never promote an unrelated passage.
|
|
56
|
+
return min(1.0, coverage + 0.1)
|
|
57
|
+
return coverage
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@runtime_checkable
|
|
61
|
+
class Reranker(Protocol):
|
|
62
|
+
"""Reorder candidates for a query, deterministically."""
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def name(self) -> str: ...
|
|
66
|
+
|
|
67
|
+
async def rerank(
|
|
68
|
+
self,
|
|
69
|
+
query: str,
|
|
70
|
+
items: Sequence[RetrievedItem],
|
|
71
|
+
*,
|
|
72
|
+
top_n: int,
|
|
73
|
+
) -> tuple[RetrievedItem, ...]: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LexicalReranker:
|
|
77
|
+
"""Coverage-based reranking, with ties broken on chunk id."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *, name: str = "lexical") -> None:
|
|
80
|
+
self._name = name
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def name(self) -> str:
|
|
84
|
+
return self._name
|
|
85
|
+
|
|
86
|
+
async def rerank(
|
|
87
|
+
self,
|
|
88
|
+
query: str,
|
|
89
|
+
items: Sequence[RetrievedItem],
|
|
90
|
+
*,
|
|
91
|
+
top_n: int,
|
|
92
|
+
) -> tuple[RetrievedItem, ...]:
|
|
93
|
+
if top_n < 1:
|
|
94
|
+
raise ConfigurationError("rerank top_n must be at least 1")
|
|
95
|
+
scored = [
|
|
96
|
+
(lexical_score(query, item.chunk.text), item.chunk.chunk_id, item) for item in items
|
|
97
|
+
]
|
|
98
|
+
# A reranker that cannot read the query leaves the incoming order alone
|
|
99
|
+
# rather than shuffling it: every score is 0.0, so the tie-break would be
|
|
100
|
+
# chunk-id order, which is an arbitrary order dressed up as a decision.
|
|
101
|
+
if all(score == 0.0 for score, _, _ in scored):
|
|
102
|
+
return tuple(items[:top_n])
|
|
103
|
+
scored.sort(key=lambda entry: (-entry[0], entry[1]))
|
|
104
|
+
return tuple(
|
|
105
|
+
RetrievedItem(
|
|
106
|
+
chunk=item.chunk,
|
|
107
|
+
score=score,
|
|
108
|
+
rank=rank,
|
|
109
|
+
citation=cite(item.chunk),
|
|
110
|
+
)
|
|
111
|
+
for rank, (score, _, item) in enumerate(scored[:top_n], start=1)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def reranker_for(spec: RerankSpec | None) -> Reranker | None:
|
|
116
|
+
"""The reranker a retriever's ``rerank`` block asks for, if any.
|
|
117
|
+
|
|
118
|
+
``rerank.model`` is free-form in the IR, and Phase 1 ships exactly one
|
|
119
|
+
implementation. A declared model outside the lexical family is refused rather
|
|
120
|
+
than quietly served by :class:`LexicalReranker`, for the same reason an
|
|
121
|
+
unimplemented source kind or store kind is refused: a retriever that appears to
|
|
122
|
+
rerank with a cross-encoder it never called is worse than one that says it
|
|
123
|
+
cannot. The guard matches on the name because the name is the only signal the
|
|
124
|
+
IR gives; the lexical family is what this runtime can honestly claim.
|
|
125
|
+
"""
|
|
126
|
+
if spec is None:
|
|
127
|
+
return None
|
|
128
|
+
if _LEXICAL_MARKER not in spec.model.casefold():
|
|
129
|
+
raise FeatureDeferredError(
|
|
130
|
+
f"rerank model {spec.model!r} is declared but not implemented: Phase 1 ships one "
|
|
131
|
+
"deterministic lexical reranker, and a hosted cross-encoder is a later layer"
|
|
132
|
+
)
|
|
133
|
+
return LexicalReranker()
|