search-as-code 0.0.1__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.
@@ -0,0 +1,460 @@
1
+ """The Session is the single handle agent-generated code writes against.
2
+
3
+ It binds a backend + embedder + optional reranker/extractor, exposes the
4
+ primitives as methods, and — crucially — owns a *state store* that lives outside
5
+ the model context. Agent code fans out, filters, and stashes bulky results in
6
+ the session; only ``.to_evidence()`` output ever needs to return to the model.
7
+
8
+ s = Session(connect("qdrant", ...), embedder=my_embedder)
9
+ seeds = s.search_many(["q1", "q2", "q3"], top_k=8).dedup()
10
+ s.remember("seeds", seeds)
11
+ evidence = seeds.top(5).to_evidence(fields=["title", "url"])
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Callable, Optional, Sequence
17
+
18
+ from . import filters as F
19
+ from . import primitives as P
20
+ from .adapters.base import VectorStore
21
+ from .adapters.registry import connect
22
+ from .embeddings import Embedder, HashEmbedder, as_embedder
23
+ from .errors import (
24
+ DimensionMismatchError,
25
+ GeneratorRequiredError,
26
+ InvalidArgumentError,
27
+ InvalidModeError,
28
+ )
29
+ from .filters import normalize
30
+ from .types import Capabilities, Document, Hit, ResultSet
31
+
32
+ _MODES = ("dense", "keyword", "hybrid", "regex")
33
+
34
+
35
+ def _check_query(query: Any) -> None:
36
+ if not isinstance(query, str) or not query.strip():
37
+ raise InvalidArgumentError("query must be a non-empty string", query=query)
38
+
39
+
40
+ def _check_top_k(top_k: Any, caps: Capabilities) -> None:
41
+ if isinstance(top_k, bool) or not isinstance(top_k, int) or top_k < 1:
42
+ raise InvalidArgumentError("top_k must be a positive integer", top_k=top_k)
43
+ if caps.max_top_k is not None and top_k > caps.max_top_k:
44
+ raise InvalidArgumentError(
45
+ "top_k exceeds backend maximum", top_k=top_k, max_top_k=caps.max_top_k
46
+ )
47
+
48
+
49
+ def _check_mode(mode: Any) -> None:
50
+ if mode not in _MODES:
51
+ raise InvalidModeError("unknown search mode", mode=mode, allowed=list(_MODES))
52
+
53
+
54
+ class Session:
55
+ def __init__(
56
+ self,
57
+ store: VectorStore | str,
58
+ embedder: Optional[Embedder | Callable] = None,
59
+ reranker: Optional[Callable[[str, list[str]], list[float]]] = None,
60
+ extractor: Optional[Callable[[list[str], dict, str], list[dict]]] = None,
61
+ generator: Optional[Callable[[str], list[str]]] = None,
62
+ **connect_opts: Any,
63
+ ):
64
+ self.store = connect(store, **connect_opts) if isinstance(store, str) else store
65
+ self._caps = self.store.capabilities()
66
+ self.embedder: Embedder = as_embedder(embedder) if embedder else HashEmbedder()
67
+ self.reranker = reranker
68
+ self.extractor = extractor
69
+ self.generator = generator # LLM callable(prompt) -> list[str], for query-side primitives
70
+ self._state: dict[str, Any] = {}
71
+
72
+ # ---- ingestion -------------------------------------------------------
73
+ def add(self, docs: Sequence[Document | dict]) -> None:
74
+ """Upsert documents, embedding any that lack a vector."""
75
+ norm = [d if isinstance(d, Document) else Document(**d) for d in docs]
76
+ for d in norm:
77
+ if not d.id:
78
+ raise InvalidArgumentError("every document needs a non-empty id")
79
+ missing = [d for d in norm if d.vector is None and d.text]
80
+ if missing and not self._caps.server_side_embedding:
81
+ vecs = self.embedder.embed([d.text or "" for d in missing])
82
+ for d, v in zip(missing, vecs):
83
+ d.vector = v
84
+ self._check_dims(norm)
85
+ self.store.upsert(norm)
86
+
87
+ def describe(self, n_samples: int = 4, llm: bool = False) -> dict:
88
+ """Corpus profile for the LLM (introspect BEFORE writing retrieval code):
89
+ the store's schema (fields/types/count) + the content-type mix of a sample
90
+ (prose vs table vs fact-card vs list) + short sample snippets.
91
+
92
+ Feed this into the agent prompt so it knows *what kind of data* it is querying —
93
+ the schema-first agentic-retrieval pattern.
94
+
95
+ The base profile is **sampled + heuristic** (real random sample from the store,
96
+ rule-based ``content_type`` tagging). Pass ``llm=True`` to add a **model-generated**
97
+ characterization: the generator reads the sample and returns a free-text summary of
98
+ the data (types, key entities/fields) plus recommended primitives — under
99
+ ``profile["llm"]``. Needs a generator on the Session.
100
+ """
101
+ from .primitives import content_type
102
+ try:
103
+ schema = self.store.describe_schema()
104
+ except Exception:
105
+ schema = {"backend": getattr(self.store, "backend", "?")}
106
+ try:
107
+ samples = self.store.sample(n_samples)
108
+ except NotImplementedError:
109
+ samples = []
110
+ types: dict[str, int] = {}
111
+ snippets = []
112
+ for d in samples:
113
+ ct = content_type(d.text or "")
114
+ types[ct] = types.get(ct, 0) + 1
115
+ snippets.append({"type": ct, "text": (d.text or "")[:160]})
116
+ profile = {**schema, "content_types": types, "samples": snippets}
117
+ if llm and self.generator is not None:
118
+ profile["llm"] = self._llm_profile(schema, samples)
119
+ return profile
120
+
121
+ def _llm_profile(self, schema: dict, samples: Sequence[Document]) -> str:
122
+ """Ask the generator to characterize the corpus from the sample — genuinely
123
+ LLM-driven data exploration (vs the heuristic content_type tagging)."""
124
+ fields = schema.get("fields") or schema.get("metadata_keys") or {}
125
+ rows = "\n".join(f"- {(d.text or '')[:300]}" for d in samples) or "(no text samples)"
126
+ prompt = (
127
+ "You are profiling a search corpus before writing retrieval code.\n"
128
+ f"Backend: {schema.get('backend') or schema.get('index')} "
129
+ f"Approx docs: {schema.get('count')}\n"
130
+ f"Fields: {fields}\n\n"
131
+ f"Random sample of documents:\n{rows}\n\n"
132
+ "In 4-6 short lines describe: (1) what kind of data this is (prose docs, tables, "
133
+ "curated fact-cards, code, mixed?), (2) the key entities/fields a query would target, "
134
+ "(3) which retrieval primitives fit best (e.g. keyword/exact & regex for part-numbers "
135
+ "and fact-cards, dense/hyde for prose, fielded/phrase for structured fields)."
136
+ )
137
+ try:
138
+ out = self.generator(prompt)
139
+ return out[0] if isinstance(out, list) else str(out)
140
+ except Exception as e: # pragma: no cover - profiling is best-effort
141
+ return f"(llm profile unavailable: {e})"
142
+
143
+ def _check_dims(self, docs: Sequence[Document]) -> None:
144
+ """Guardrail: all supplied/embedded vectors must share one dimension, and
145
+ match the backend's declared ``dim`` when it has one."""
146
+ vecs = [d for d in docs if d.vector is not None]
147
+ if not vecs:
148
+ return
149
+ want = getattr(self.store, "dim", None) or getattr(self.store, "_dim", None)
150
+ seen = len(vecs[0].vector) # type: ignore[arg-type]
151
+ for d in vecs:
152
+ n = len(d.vector) # type: ignore[arg-type]
153
+ if n != seen or (want is not None and n != want):
154
+ raise DimensionMismatchError(
155
+ "vector dimensionality mismatch", id=d.id, dim=n,
156
+ expected=want if want is not None else seen,
157
+ )
158
+
159
+ # ---- core search primitives (capability-aware) -----------------------
160
+ def search(
161
+ self,
162
+ query: str,
163
+ top_k: int = 10,
164
+ filter: Optional[dict] = None,
165
+ mode: str = "dense",
166
+ alpha: float = 0.5,
167
+ ) -> ResultSet:
168
+ """Single search. ``mode`` is dense | keyword | hybrid | regex; unsupported
169
+ modes are emulated in-SDK so agent code is identical on every backend.
170
+
171
+ ``alpha`` (hybrid only) is the dense weight in the dense↔keyword fusion:
172
+ alpha=1.0 → pure dense, 0.0 → pure keyword, 0.8 → dense-dominant (best on FiQA)."""
173
+ _check_query(query)
174
+ _check_mode(mode)
175
+ _check_top_k(top_k, self._caps)
176
+ F.validate(filter)
177
+ flt = normalize(filter)
178
+ if mode == "keyword":
179
+ return self._keyword(query, top_k, flt)
180
+ if mode == "hybrid":
181
+ return self._hybrid(query, top_k, flt, alpha)
182
+ if mode == "regex":
183
+ return self._regex(query, top_k, flt)
184
+ vec = self._embed_query(query)
185
+ return self.store.query_vector(vec, top_k=top_k, flt=flt)
186
+
187
+ def search_many(
188
+ self,
189
+ queries: Sequence[str],
190
+ top_k: int = 10,
191
+ filter: Optional[dict] = None,
192
+ mode: str = "dense",
193
+ concurrency: int = 8,
194
+ fuse: bool = True,
195
+ ) -> ResultSet:
196
+ """Fan out over many queries concurrently, then RRF-fuse (or return the
197
+ flat union). The primitive that replaces N serial model turns."""
198
+ if not queries:
199
+ raise InvalidArgumentError("queries must be a non-empty sequence")
200
+ if concurrency < 1:
201
+ raise InvalidArgumentError("concurrency must be >= 1", concurrency=concurrency)
202
+ sets = P.fan_out(lambda q: self.search(q, top_k, filter, mode), queries, concurrency)
203
+ for q, rs in zip(queries, sets):
204
+ for h in rs:
205
+ h.query = q
206
+ if fuse:
207
+ return P.fuse(sets).top(top_k * 2)
208
+ flat = ResultSet()
209
+ for rs in sets:
210
+ flat.extend(rs)
211
+ return flat
212
+
213
+ # ---- portable helpers over ResultSets --------------------------------
214
+ def rerank(self, query: str, results: ResultSet, top_k: Optional[int] = None) -> ResultSet:
215
+ return P.rerank(query, results, reranker=self.reranker, top_k=top_k)
216
+
217
+ def fuse(self, sets: Sequence[ResultSet], weights: Optional[Sequence[float]] = None) -> ResultSet:
218
+ return P.fuse(sets, weights)
219
+
220
+ def dedup(self, results: ResultSet, key: Optional[Callable[[Hit], Any]] = None) -> ResultSet:
221
+ return results.dedup(key)
222
+
223
+ def extract(self, results: ResultSet, schema: dict, instruction: str) -> list[dict]:
224
+ return P.extract(results, schema, instruction, extractor=self.extractor)
225
+
226
+ def smart_search(self, query: str, top_k: int = 10) -> ResultSet:
227
+ """Spelling/acronym-normalize the query, and if it carries rare exact tokens
228
+ (acronyms, quoted phrases, numbers) boost them via a keyword pass fused with
229
+ dense — otherwise fall back to dense. Targets the exact-token miss slice."""
230
+ q = P.normalize_query(query)
231
+ terms = P.rare_terms(query)
232
+ dense = self.search(q, top_k=top_k * 4, mode="dense")
233
+ if not terms:
234
+ return dense.top(top_k)
235
+ kwb = self.search(" ".join(terms), top_k=top_k * 4, mode="keyword")
236
+ return P.fuse([dense, kwb], weights=[0.6, 0.4]).dedup().top(top_k)
237
+
238
+ def retrieve_rerank(self, query: str, pool_k: int = 500, top_k: int = 10,
239
+ mode: str = "dense") -> ResultSet:
240
+ """Wide-pool retrieve → rerank → top_k. The near-miss recovery pipeline
241
+ (gold at rank 100-500 pulled forward by the reranker). Needs a reranker set."""
242
+ pool = self.search(query, top_k=pool_k, mode=mode)
243
+ return self.rerank(query, self.hydrate(pool), top_k=top_k)
244
+
245
+ def adaptive_search(self, query: str, mode: str = "dense", max_k: int = 100,
246
+ min_k: int = 10, rel_band: float = 0.1, method: str = "band",
247
+ filter: Optional[dict] = None) -> ResultSet:
248
+ """Retrieve a wide pool then size it by the score distribution: return MORE
249
+ results when similarity stays flat past the top (many near-equally-relevant
250
+ docs), FEWER when it drops off. Recovers gold that a hard top-10 cut misses
251
+ (higher recall-in-context) without a reranker. See primitives.score_cutoff."""
252
+ pool = self.search(query, top_k=max_k, mode=mode, filter=filter)
253
+ return P.score_cutoff(pool, method=method, rel_band=rel_band, min_k=min_k, max_k=max_k)
254
+
255
+ def semantic_dedup(self, results: ResultSet, threshold: float = 0.85) -> ResultSet:
256
+ """Collapse near-duplicate results (cosine >= threshold on their embeddings),
257
+ keeping one representative per semantic cluster — distinct from exact-id dedup.
258
+ (SemDeDup.) Uses the session embedder on hit texts."""
259
+ import numpy as np
260
+
261
+ hits = sorted(results, key=lambda h: h.score, reverse=True)
262
+ texts = [h.text or "" for h in hits]
263
+ if not texts:
264
+ return ResultSet()
265
+ v = np.asarray(self.embedder.embed(texts), dtype=np.float32)
266
+ v = v / (np.linalg.norm(v, axis=1, keepdims=True) + 1e-9)
267
+ kept: list[Hit] = []
268
+ kv: list = []
269
+ for h, vec in zip(hits, v):
270
+ if kv and max(float(vec @ k) for k in kv) >= threshold:
271
+ continue
272
+ kept.append(h)
273
+ kv.append(vec)
274
+ return ResultSet(kept)
275
+
276
+ def mmr(self, query: str, results: ResultSet, lambda_: float = 0.5, top_k: int = 10) -> ResultSet:
277
+ """Diversify results with Maximal Marginal Relevance (embeds ``query``)."""
278
+ results = self.hydrate(results)
279
+ return P.mmr(self._embed_query(query), results, lambda_=lambda_, top_k=top_k)
280
+
281
+ diversify = mmr # alias
282
+
283
+ def compress(self, query: str, results: ResultSet, keep: int = 2, per_hit: bool = True) -> ResultSet:
284
+ """Contextual compression — keep only the sentences in each hit most
285
+ relevant to ``query`` (LangChain contextual compression, but model-free:
286
+ it scores sentences with the embedder). Shrinks what returns to the model.
287
+ """
288
+ qv = self._embed_query(query)
289
+ import numpy as np
290
+
291
+ q = np.asarray(qv, dtype=np.float32)
292
+ q = q / (np.linalg.norm(q) or 1.0)
293
+ out = ResultSet()
294
+ for h in results:
295
+ if not h.text:
296
+ out.append(h)
297
+ continue
298
+ sents = [s.strip() for s in _split_sentences(h.text) if s.strip()]
299
+ if len(sents) <= keep:
300
+ out.append(h)
301
+ continue
302
+ svecs = self.embedder.embed(sents)
303
+ scores = []
304
+ for s, v in zip(sents, svecs):
305
+ vv = np.asarray(v, dtype=np.float32)
306
+ scores.append((float(q @ (vv / (np.linalg.norm(vv) or 1.0))), s))
307
+ top = [s for _, s in sorted(scores, reverse=True)[:keep]]
308
+ kept = " ".join(s for s in sents if s in top) # preserve reading order
309
+ doc = h.document.with_metadata() if h.document else Document(id=h.id)
310
+ doc.text = kept
311
+ out.append(Hit(id=h.id, score=h.score, document=doc, query=h.query, store=h.store))
312
+ return out
313
+
314
+ # ---- pluggable query-side primitives (need a generator/LLM) -----------
315
+ def expand_search(self, query: str, top_k: int = 10, n: int = 4, mode: str = "dense") -> ResultSet:
316
+ """Multi-query expansion → fan-out → RRF fuse (RAG-Fusion)."""
317
+ variants = P.expand(query, self._require_generator(), n=n)
318
+ return self.search_many(variants, top_k=top_k, mode=mode)
319
+
320
+ def decompose_search(self, query: str, top_k: int = 10, mode: str = "dense") -> ResultSet:
321
+ """Decompose into sub-questions → fan-out → RRF fuse."""
322
+ subs = P.decompose(query, self._require_generator())
323
+ return self.search_many(subs or [query], top_k=top_k, mode=mode)
324
+
325
+ def rephrase(self, query: str) -> str:
326
+ """LLM rewrite of the query (returns the improved string, does not search)."""
327
+ return P.rephrase(query, self._require_generator())
328
+
329
+ def rephrase_search(self, query: str, top_k: int = 10, mode: str = "dense") -> ResultSet:
330
+ """Rewrite the query with the LLM, then search with the improved form."""
331
+ better = P.rephrase(query, self._require_generator())
332
+ return self.search(better, top_k=top_k, mode=mode)
333
+
334
+ def topics(self, query: str, n: int = 5) -> list[str]:
335
+ """LLM-extracted key topics/entities in the query (for routing or filtering)."""
336
+ return P.topics(query, self._require_generator(), n=n)
337
+
338
+ def auto_filter(self, query: str, fields: Optional[Sequence[str]] = None) -> dict:
339
+ """Self-query: LLM infers a metadata filter implied by the query. Feed the
340
+ result to search(filter=...). Pass ``fields`` (the metadata keys in your
341
+ corpus) to constrain what the LLM may filter on."""
342
+ return P.auto_filter(query, self._require_generator(), fields=fields)
343
+
344
+ def hyde_search(self, query: str, top_k: int = 10) -> ResultSet:
345
+ """HyDE — generate a hypothetical answer document, embed it, and search
346
+ with that vector instead of the bare query (Gao et al. 2023). Reaches the
347
+ answer region of embedding space, a DIFFERENT neighborhood than the query."""
348
+ gen = self._require_generator()
349
+ prompt = f"Write a short passage that directly answers this query.\nQuery: {query}"
350
+ doc = (gen(prompt) or [query])[0]
351
+ vec = self.embedder.embed([doc])[0]
352
+ return self.store.query_vector(vec, top_k=top_k)
353
+
354
+ def prf_search(self, query: str, top_k: int = 10, feedback_k: int = 5,
355
+ alpha: float = 1.0, beta: float = 0.7, filter: Optional[dict] = None) -> ResultSet:
356
+ """Pseudo-relevance feedback (Rocchio): retrieve, then MOVE the query vector
357
+ toward the centroid of the top ``feedback_k`` retrieved docs and re-search.
358
+ This literally shifts the query into a NEW embedding neighborhood (the region
359
+ where the pseudo-relevant docs live) — reaching neighbors a paraphrase can't.
360
+ """
361
+ import numpy as np
362
+
363
+ flt = normalize(filter)
364
+ qv = np.asarray(self._embed_query(query), dtype=np.float32)
365
+ qv = qv / (np.linalg.norm(qv) or 1.0)
366
+ seed = self.hydrate(self.store.query_vector(qv.tolist(), top_k=feedback_k, flt=flt))
367
+ texts = [h.text for h in seed if h.text]
368
+ if not texts:
369
+ return self.store.query_vector(qv.tolist(), top_k=top_k, flt=flt)
370
+ dvecs = np.asarray(self.embedder.embed(texts), dtype=np.float32)
371
+ new_q = alpha * qv + beta * dvecs.mean(axis=0)
372
+ new_q = new_q / (np.linalg.norm(new_q) or 1.0)
373
+ return self.store.query_vector(new_q.tolist(), top_k=top_k, flt=flt)
374
+
375
+ def hydrate(self, results: ResultSet) -> ResultSet:
376
+ """Fetch full documents for hits that only carry ids (e.g. after fusion
377
+ across a store that returned partial payloads)."""
378
+ need = [h.id for h in results if h.document is None]
379
+ if need:
380
+ byid = {d.id: d for d in self.store.get(need)}
381
+ for h in results:
382
+ if h.document is None:
383
+ h.document = byid.get(h.id)
384
+ return results
385
+
386
+ # ---- out-of-context state store --------------------------------------
387
+ def remember(self, key: str, value: Any) -> None:
388
+ self._state[key] = value
389
+
390
+ def recall(self, key: str, default: Any = None) -> Any:
391
+ return self._state.get(key, default)
392
+
393
+ def forget(self, key: str) -> None:
394
+ self._state.pop(key, None)
395
+
396
+ def state_keys(self) -> list[str]:
397
+ return list(self._state)
398
+
399
+ # ---- internals -------------------------------------------------------
400
+ def _embed_query(self, query: str) -> list[float]:
401
+ if self._caps.server_side_embedding:
402
+ v = self.store.embed_query(query)
403
+ if v is not None:
404
+ return v
405
+ return self.embedder.embed([query])[0]
406
+
407
+ def _keyword(self, query: str, top_k: int, flt: dict) -> ResultSet:
408
+ if self._caps.keyword:
409
+ return self.store.query_keyword(query, top_k=top_k, flt=flt)
410
+ # Emulate: dense recall then lexical rerank.
411
+ pool = self.store.query_vector(self._embed_query(query), top_k=top_k * 5, flt=flt)
412
+ return P.rerank(query, pool, top_k=top_k)
413
+
414
+ def _hybrid(self, query: str, top_k: int, flt: dict, alpha: float = 0.5) -> ResultSet:
415
+ vec = self._embed_query(query)
416
+ if self._caps.hybrid:
417
+ return self.store.query_hybrid(vec, query, top_k=top_k, flt=flt, alpha=alpha)
418
+ dense = self.store.query_vector(vec, top_k=top_k * 3, flt=flt)
419
+ kw = self._keyword(query, top_k * 3, flt)
420
+ return P.fuse([dense, kw], weights=[alpha, 1 - alpha]).top(top_k)
421
+
422
+ def _regex(self, pattern: str, top_k: int, flt: dict) -> ResultSet:
423
+ if self._caps.regex:
424
+ return self.store.query_regex(pattern, top_k=top_k, flt=flt)
425
+ # Emulate client-side: pull a metadata-filtered pool and scan text.
426
+ import re
427
+
428
+ rx = re.compile(pattern)
429
+ pool = self.store.query_vector(self._embed_query(pattern), top_k=max(top_k * 20, 200), flt=flt)
430
+ pool = self.hydrate(pool)
431
+ hits = [h for h in pool if h.text and rx.search(h.text)]
432
+ return ResultSet(hits[:top_k])
433
+
434
+ def _require_generator(self) -> Callable[[str], list[str]]:
435
+ if self.generator is None:
436
+ raise GeneratorRequiredError(
437
+ "This primitive needs an LLM. Pass Session(..., generator=fn) where "
438
+ "fn(prompt: str) -> list[str]."
439
+ )
440
+ return self.generator
441
+
442
+
443
+ def route(
444
+ sessions: Sequence[Session],
445
+ query: str,
446
+ top_k: int = 10,
447
+ mode: str = "dense",
448
+ concurrency: int = 8,
449
+ ) -> ResultSet:
450
+ """Multi-store retrieval: fan the same query across several backends and
451
+ RRF-fuse the results (query routing / federated search). Each hit keeps its
452
+ ``store`` tag so you can see which backend it came from."""
453
+ sets = P.fan_out(lambda s: s.search(query, top_k, mode=mode), list(sessions), concurrency)
454
+ return P.fuse(sets).top(top_k)
455
+
456
+
457
+ def _split_sentences(text: str) -> list[str]:
458
+ import re
459
+
460
+ return re.split(r"(?<=[.!?])\s+", text)
@@ -0,0 +1,129 @@
1
+ """Core data model shared across every adapter and primitive.
2
+
3
+ The whole point of search-as-code is that agent-generated code manipulates a
4
+ *single* set of types regardless of which vector database is underneath. These
5
+ types are that lingua franca.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field, replace
11
+ from typing import Any, Callable, Iterable, Iterator, Optional
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class Document:
16
+ """A stored item. ``vector`` and ``text`` are both optional so the same type
17
+ works for dense, sparse, and metadata-only records."""
18
+
19
+ id: str
20
+ text: Optional[str] = None
21
+ vector: Optional[list[float]] = None
22
+ metadata: dict[str, Any] = field(default_factory=dict)
23
+
24
+ def with_metadata(self, **kw: Any) -> "Document":
25
+ md = {**self.metadata, **kw}
26
+ return replace(self, metadata=md)
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class Hit:
31
+ """A single retrieved result.
32
+
33
+ ``score`` is normalized so that *larger is better* for every adapter, even
34
+ those that natively return distances. ``query`` records which fan-out query
35
+ produced the hit (used by fusion/dedup); ``store`` records the source
36
+ backend (used when searching across multiple stores).
37
+ """
38
+
39
+ id: str
40
+ score: float
41
+ document: Optional[Document] = None
42
+ query: Optional[str] = None
43
+ store: Optional[str] = None
44
+
45
+ @property
46
+ def text(self) -> Optional[str]:
47
+ return self.document.text if self.document else None
48
+
49
+ @property
50
+ def metadata(self) -> dict[str, Any]:
51
+ return self.document.metadata if self.document else {}
52
+
53
+ def get(self, key: str, default: Any = None) -> Any:
54
+ return self.metadata.get(key, default)
55
+
56
+
57
+ class ResultSet(list):
58
+ """A list of :class:`Hit` with chainable, model-free helpers.
59
+
60
+ Every primitive returns a ``ResultSet`` so agent code can fluently compose
61
+ ``store.search(...).dedup().top(5).to_evidence()`` without dragging the raw
62
+ payloads back through the model context.
63
+ """
64
+
65
+ def top(self, k: int) -> "ResultSet":
66
+ return ResultSet(sorted(self, key=lambda h: h.score, reverse=True)[:k])
67
+
68
+ def ids(self) -> list[str]:
69
+ return [h.id for h in self]
70
+
71
+ def texts(self) -> list[str]:
72
+ return [h.text or "" for h in self]
73
+
74
+ def where(self, predicate: Callable[[Hit], bool]) -> "ResultSet":
75
+ return ResultSet(h for h in self if predicate(h))
76
+
77
+ def dedup(self, key: Optional[Callable[[Hit], Any]] = None) -> "ResultSet":
78
+ """Keep the highest-scoring hit per key (defaults to hit id)."""
79
+ keyfn = key or (lambda h: h.id)
80
+ best: dict[Any, Hit] = {}
81
+ for h in self:
82
+ k = keyfn(h)
83
+ if k not in best or h.score > best[k].score:
84
+ best[k] = h
85
+ return ResultSet(best.values()).top(len(best))
86
+
87
+ def to_evidence(
88
+ self,
89
+ fields: Optional[Iterable[str]] = None,
90
+ max_chars: int = 500,
91
+ ) -> list[dict[str, Any]]:
92
+ """Compact, context-friendly representation to hand back to the model.
93
+
94
+ This is the boundary between "bulky state kept in the sandbox" and "the
95
+ few structured facts the model actually needs to see."
96
+ """
97
+ out: list[dict[str, Any]] = []
98
+ for h in self:
99
+ row: dict[str, Any] = {"id": h.id, "score": round(h.score, 4)}
100
+ if h.text is not None:
101
+ row["text"] = h.text[:max_chars]
102
+ if fields is None:
103
+ if h.metadata:
104
+ row["metadata"] = h.metadata
105
+ else:
106
+ for f in fields:
107
+ if f in h.metadata:
108
+ row[f] = h.metadata[f]
109
+ out.append(row)
110
+ return out
111
+
112
+ def __iter__(self) -> Iterator[Hit]: # typing aid
113
+ return super().__iter__()
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class Capabilities:
118
+ """What a backend can do natively. The primitive layer reads this to decide
119
+ when to emulate a feature in-SDK so agent code stays portable."""
120
+
121
+ dense: bool = True
122
+ keyword: bool = False # BM25 / full-text
123
+ hybrid: bool = False # server-side dense+sparse fusion
124
+ regex: bool = False # exact / pattern / operator search (code-friendly)
125
+ multi_vector: bool = False # late-interaction (ColBERT/ColPali) MaxSim
126
+ server_side_embedding: bool = False
127
+ native_rerank: bool = False
128
+ metadata_filter: bool = True
129
+ max_top_k: Optional[int] = None