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.
- search_as_code/__init__.py +89 -0
- search_as_code/_resilience.py +99 -0
- search_as_code/adapters/__init__.py +5 -0
- search_as_code/adapters/base.py +108 -0
- search_as_code/adapters/chroma.py +95 -0
- search_as_code/adapters/faiss_store.py +90 -0
- search_as_code/adapters/memory.py +148 -0
- search_as_code/adapters/milvus_store.py +87 -0
- search_as_code/adapters/nmslib_store.py +102 -0
- search_as_code/adapters/opensearch.py +370 -0
- search_as_code/adapters/pgvector.py +110 -0
- search_as_code/adapters/qdrant.py +133 -0
- search_as_code/adapters/registry.py +98 -0
- search_as_code/adapters/sqlite_store.py +105 -0
- search_as_code/embeddings.py +155 -0
- search_as_code/errors.py +145 -0
- search_as_code/explore/__init__.py +31 -0
- search_as_code/explore/engine.py +160 -0
- search_as_code/explore/pack.py +127 -0
- search_as_code/explore/report.py +72 -0
- search_as_code/explore/stages.py +349 -0
- search_as_code/filters.py +102 -0
- search_as_code/primitives.py +422 -0
- search_as_code/rerankers.py +94 -0
- search_as_code/sandbox.py +116 -0
- search_as_code/session.py +460 -0
- search_as_code/types.py +129 -0
- search_as_code-0.0.1.dist-info/METADATA +234 -0
- search_as_code-0.0.1.dist-info/RECORD +32 -0
- search_as_code-0.0.1.dist-info/WHEEL +5 -0
- search_as_code-0.0.1.dist-info/licenses/LICENSE +201 -0
- search_as_code-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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)
|
search_as_code/types.py
ADDED
|
@@ -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
|