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,422 @@
|
|
|
1
|
+
"""Composable retrieval primitives — the atoms agent code writes against.
|
|
2
|
+
|
|
3
|
+
These are deliberately *lower level* than a monolithic ``search()`` endpoint
|
|
4
|
+
(the Perplexity "search as code" insight): fan-out, fusion, dedup, rerank, and
|
|
5
|
+
extraction are separate so the model can orchestrate them however the task
|
|
6
|
+
needs. Everything here is portable — it runs on ``ResultSet`` objects and never
|
|
7
|
+
touches a specific backend.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re as _re
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from typing import Any, Callable, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from .errors import ExtractorRequiredError
|
|
17
|
+
from .types import Hit, ResultSet
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fan_out(
|
|
21
|
+
fn: Callable[[Any], ResultSet],
|
|
22
|
+
items: Sequence[Any],
|
|
23
|
+
concurrency: int = 8,
|
|
24
|
+
) -> list[ResultSet]:
|
|
25
|
+
"""Run ``fn`` over ``items`` concurrently. The workhorse behind querying many
|
|
26
|
+
variants at once without serializing through model turns."""
|
|
27
|
+
if not items:
|
|
28
|
+
return []
|
|
29
|
+
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as ex:
|
|
30
|
+
return list(ex.map(fn, items))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fuse(
|
|
34
|
+
result_sets: Sequence[ResultSet],
|
|
35
|
+
weights: Optional[Sequence[float]] = None,
|
|
36
|
+
k: int = 60,
|
|
37
|
+
) -> ResultSet:
|
|
38
|
+
"""Reciprocal Rank Fusion across result sets.
|
|
39
|
+
|
|
40
|
+
RRF is rank-based, so it merges dense, keyword, and multi-query results
|
|
41
|
+
without needing comparable score scales — the portable, model-free way to
|
|
42
|
+
combine signals. ``weights`` optionally biases each set.
|
|
43
|
+
"""
|
|
44
|
+
weights = list(weights) if weights is not None else [1.0] * len(result_sets)
|
|
45
|
+
agg: dict[str, float] = {}
|
|
46
|
+
keep: dict[str, Hit] = {}
|
|
47
|
+
for rs, w in zip(result_sets, weights):
|
|
48
|
+
for rank, hit in enumerate(sorted(rs, key=lambda h: h.score, reverse=True)):
|
|
49
|
+
agg[hit.id] = agg.get(hit.id, 0.0) + w * (1.0 / (k + rank + 1))
|
|
50
|
+
if hit.id not in keep or hit.score > keep[hit.id].score:
|
|
51
|
+
keep[hit.id] = hit
|
|
52
|
+
fused = [
|
|
53
|
+
Hit(id=i, score=s, document=keep[i].document, query=keep[i].query, store=keep[i].store)
|
|
54
|
+
for i, s in agg.items()
|
|
55
|
+
]
|
|
56
|
+
fused.sort(key=lambda h: h.score, reverse=True)
|
|
57
|
+
return ResultSet(fused)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# RRF is exactly the fusion primitive above; `rrf` is an explicit, discoverable alias.
|
|
61
|
+
rrf = fuse
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def dedup(results: ResultSet, key: Optional[Callable[[Hit], Any]] = None) -> ResultSet:
|
|
65
|
+
return results.dedup(key)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def score_cutoff(results: ResultSet, method: str = "band", rel_band: float = 0.1,
|
|
69
|
+
min_k: int = 10, max_k: int = 100) -> ResultSet:
|
|
70
|
+
"""Adaptive result-set sizing from the score distribution — keep MORE when the
|
|
71
|
+
similarity curve is flat (many near-equally-relevant hits), FEWER when it drops
|
|
72
|
+
off sharply. This is the "don't hard-cut at k" idea from score-based retrieval.
|
|
73
|
+
|
|
74
|
+
method="band": keep hits within ``rel_band`` of the top score (relative), i.e.
|
|
75
|
+
score >= top*(1-rel_band). Flat curve → keeps a large pool; peaked → keeps few.
|
|
76
|
+
method="knee": cut at the largest score gap between consecutive ranks (elbow).
|
|
77
|
+
Always returns between ``min_k`` and ``max_k`` hits.
|
|
78
|
+
"""
|
|
79
|
+
hits = sorted(results, key=lambda h: h.score, reverse=True)[:max_k]
|
|
80
|
+
if not hits:
|
|
81
|
+
return ResultSet()
|
|
82
|
+
if method == "knee":
|
|
83
|
+
scores = [h.score for h in hits]
|
|
84
|
+
best_i, best_gap = len(hits), -1.0
|
|
85
|
+
for i in range(min_k, len(scores)):
|
|
86
|
+
gap = scores[i - 1] - scores[i]
|
|
87
|
+
if gap > best_gap:
|
|
88
|
+
best_gap, best_i = gap, i
|
|
89
|
+
kept = hits[:best_i]
|
|
90
|
+
else: # band (relative to the top score)
|
|
91
|
+
top = hits[0].score
|
|
92
|
+
cut = top * (1 - rel_band) if top > 0 else top - rel_band
|
|
93
|
+
kept = [h for h in hits if h.score >= cut]
|
|
94
|
+
if len(kept) < min_k:
|
|
95
|
+
kept = hits[:min_k]
|
|
96
|
+
return ResultSet(kept[:max_k])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_scores(results: ResultSet, method: str = "minmax") -> ResultSet:
|
|
100
|
+
"""Rescale scores so incomparable BM25/cosine scales become fusable while
|
|
101
|
+
KEEPING magnitude (unlike RRF, which discards it). method=minmax → [0,1];
|
|
102
|
+
zscore → standardized. (Weaviate hybrid-fusion.)"""
|
|
103
|
+
if not results:
|
|
104
|
+
return ResultSet()
|
|
105
|
+
scores = [h.score for h in results]
|
|
106
|
+
if method == "zscore":
|
|
107
|
+
mu = sum(scores) / len(scores)
|
|
108
|
+
var = sum((s - mu) ** 2 for s in scores) / len(scores)
|
|
109
|
+
sd = var ** 0.5 or 1.0
|
|
110
|
+
norm = [(s - mu) / sd for s in scores]
|
|
111
|
+
else:
|
|
112
|
+
lo, hi = min(scores), max(scores)
|
|
113
|
+
rng = (hi - lo) or 1.0
|
|
114
|
+
norm = [(s - lo) / rng for s in scores]
|
|
115
|
+
return ResultSet(Hit(id=h.id, score=float(n), document=h.document, query=h.query, store=h.store)
|
|
116
|
+
for h, n in zip(results, norm))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def relative_score_fusion(result_sets: Sequence[ResultSet], weights: Optional[Sequence[float]] = None,
|
|
120
|
+
method: str = "minmax") -> ResultSet:
|
|
121
|
+
"""Fuse by summing NORMALIZED scores (not ranks) — preserves how much better a
|
|
122
|
+
hit is, not just its position. Weaviate's default fusion since v1.24; often
|
|
123
|
+
beats RRF when score magnitudes are meaningful."""
|
|
124
|
+
weights = list(weights) if weights is not None else [1.0] * len(result_sets)
|
|
125
|
+
agg: dict[str, float] = {}
|
|
126
|
+
keep: dict[str, Hit] = {}
|
|
127
|
+
for rs, w in zip(result_sets, weights):
|
|
128
|
+
for h in normalize_scores(rs, method):
|
|
129
|
+
agg[h.id] = agg.get(h.id, 0.0) + w * h.score
|
|
130
|
+
if h.id not in keep or h.score > keep[h.id].score:
|
|
131
|
+
keep[h.id] = h
|
|
132
|
+
fused = [Hit(id=i, score=s, document=keep[i].document, query=keep[i].query, store=keep[i].store)
|
|
133
|
+
for i, s in agg.items()]
|
|
134
|
+
fused.sort(key=lambda h: h.score, reverse=True)
|
|
135
|
+
return ResultSet(fused)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def diversity_quota(results: ResultSet, key: Callable[[Hit], Any],
|
|
139
|
+
max_per_group: int = 1, top_k: Optional[int] = None) -> ResultSet:
|
|
140
|
+
"""Enforce source/topic/entity diversity: at most ``max_per_group`` hits per
|
|
141
|
+
group (by ``key``) while walking down the ranking. (Vespa result diversity.)"""
|
|
142
|
+
counts: dict[Any, int] = {}
|
|
143
|
+
out: list[Hit] = []
|
|
144
|
+
for h in sorted(results, key=lambda x: x.score, reverse=True):
|
|
145
|
+
g = key(h)
|
|
146
|
+
if counts.get(g, 0) < max_per_group:
|
|
147
|
+
out.append(h)
|
|
148
|
+
counts[g] = counts.get(g, 0) + 1
|
|
149
|
+
if top_k and len(out) >= top_k:
|
|
150
|
+
break
|
|
151
|
+
return ResultSet(out)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def confidence(results: ResultSet) -> dict[str, float]:
|
|
155
|
+
"""Retrieval-confidence signals from the score curve: top score and the gap to
|
|
156
|
+
#2 (a large gap = a confident single winner). Feeds abstain/reformulate. (R³AG.)"""
|
|
157
|
+
hits = sorted(results, key=lambda h: h.score, reverse=True)
|
|
158
|
+
if not hits:
|
|
159
|
+
return {"top": 0.0, "gap": 0.0, "n": 0}
|
|
160
|
+
top = hits[0].score
|
|
161
|
+
gap = top - hits[1].score if len(hits) > 1 else top
|
|
162
|
+
return {"top": float(top), "gap": float(gap), "n": len(hits)}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def abstain(results: ResultSet, min_top: float = 0.0, min_gap: float = 0.0) -> bool:
|
|
166
|
+
"""True when the result is too weak/uncertain to trust (top score or score-gap
|
|
167
|
+
below threshold) — the agent should reformulate or say 'insufficient' rather
|
|
168
|
+
than answer from noise. (R³AG confidence gating.)"""
|
|
169
|
+
c = confidence(results)
|
|
170
|
+
return c["n"] == 0 or c["top"] < min_top or c["gap"] < min_gap
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def rerank(
|
|
174
|
+
query: str,
|
|
175
|
+
results: ResultSet,
|
|
176
|
+
reranker: Optional[Callable[[str, list[str]], list[float]]] = None,
|
|
177
|
+
top_k: Optional[int] = None,
|
|
178
|
+
) -> ResultSet:
|
|
179
|
+
"""Re-score ``results`` for ``query``.
|
|
180
|
+
|
|
181
|
+
Pass a ``reranker(query, texts) -> scores`` (e.g. a cross-encoder). When none
|
|
182
|
+
is supplied we emulate with lexical-overlap scoring so the primitive still
|
|
183
|
+
works on every backend — quality tracks whatever reranker you inject.
|
|
184
|
+
"""
|
|
185
|
+
if not results:
|
|
186
|
+
return results
|
|
187
|
+
texts = [h.text or "" for h in results]
|
|
188
|
+
scores = reranker(query, texts) if reranker else _lexical_overlap(query, texts)
|
|
189
|
+
rescored = [
|
|
190
|
+
Hit(id=h.id, score=float(s), document=h.document, query=h.query, store=h.store)
|
|
191
|
+
for h, s in zip(results, scores)
|
|
192
|
+
]
|
|
193
|
+
rescored.sort(key=lambda h: h.score, reverse=True)
|
|
194
|
+
out = ResultSet(rescored)
|
|
195
|
+
return out.top(top_k) if top_k else out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def freshness(
|
|
199
|
+
results: ResultSet,
|
|
200
|
+
timestamp: Callable[[Hit], float],
|
|
201
|
+
now: float,
|
|
202
|
+
half_life: float,
|
|
203
|
+
weight: float = 0.5,
|
|
204
|
+
) -> ResultSet:
|
|
205
|
+
"""Blend a recency decay into scores (a Hornet-style freshness primitive).
|
|
206
|
+
|
|
207
|
+
``now``/``half_life`` are caller-supplied (seconds) because the sandbox
|
|
208
|
+
forbids wall-clock nondeterminism; pass ``time.time()`` from the harness.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
out = []
|
|
212
|
+
for h in results:
|
|
213
|
+
age = max(0.0, now - timestamp(h))
|
|
214
|
+
decay = 0.5 ** (age / half_life) if half_life > 0 else 1.0
|
|
215
|
+
blended = (1 - weight) * h.score + weight * decay
|
|
216
|
+
out.append(Hit(id=h.id, score=blended, document=h.document, query=h.query, store=h.store))
|
|
217
|
+
out.sort(key=lambda h: h.score, reverse=True)
|
|
218
|
+
return ResultSet(out)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def mmr(
|
|
222
|
+
query_vector: Sequence[float],
|
|
223
|
+
results: ResultSet,
|
|
224
|
+
lambda_: float = 0.5,
|
|
225
|
+
top_k: int = 10,
|
|
226
|
+
) -> ResultSet:
|
|
227
|
+
"""Maximal Marginal Relevance — greedily pick results that are relevant to
|
|
228
|
+
the query but diverse from what's already chosen (Carbonell & Goldstein '98).
|
|
229
|
+
|
|
230
|
+
Portable and model-free: needs only the query vector and per-hit vectors, so
|
|
231
|
+
it complements ``dedup`` by suppressing *near*-duplicates, not just exact ids.
|
|
232
|
+
Hits without a vector are appended after the diversified set.
|
|
233
|
+
"""
|
|
234
|
+
import numpy as np
|
|
235
|
+
|
|
236
|
+
q = np.asarray(query_vector, dtype=np.float32)
|
|
237
|
+
q = q / (np.linalg.norm(q) or 1.0)
|
|
238
|
+
withvec = [h for h in results if h.document and h.document.vector is not None]
|
|
239
|
+
novec = [h for h in results if not (h.document and h.document.vector is not None)]
|
|
240
|
+
vecs = {}
|
|
241
|
+
for h in withvec:
|
|
242
|
+
assert h.document is not None # guaranteed by the withvec filter above
|
|
243
|
+
v = np.asarray(h.document.vector, dtype=np.float32)
|
|
244
|
+
vecs[h.id] = v / (np.linalg.norm(v) or 1.0)
|
|
245
|
+
|
|
246
|
+
selected: list[Hit] = []
|
|
247
|
+
pool = list(withvec)
|
|
248
|
+
while pool and len(selected) < top_k:
|
|
249
|
+
best, best_score = None, float("-inf")
|
|
250
|
+
for h in pool:
|
|
251
|
+
rel = float(q @ vecs[h.id])
|
|
252
|
+
div = max((float(vecs[h.id] @ vecs[s.id]) for s in selected), default=0.0)
|
|
253
|
+
score = lambda_ * rel - (1 - lambda_) * div
|
|
254
|
+
if score > best_score:
|
|
255
|
+
best, best_score = h, score
|
|
256
|
+
selected.append(best) # type: ignore[arg-type]
|
|
257
|
+
pool.remove(best) # type: ignore[arg-type]
|
|
258
|
+
return ResultSet(selected + novec[: max(0, top_k - len(selected))])
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def expand(query: str, generate: Callable[[str], list[str]], n: int = 4) -> list[str]:
|
|
262
|
+
"""Query expansion / multi-query (RAG-Fusion, LangChain MultiQuery, Haystack
|
|
263
|
+
QueryExpander). ``generate(prompt) -> list[str]`` is your LLM; the original
|
|
264
|
+
query is always included so recall never drops below the baseline."""
|
|
265
|
+
prompt = (
|
|
266
|
+
f"Generate {n} alternative search queries. Crucially, include SYNONYMS, EUPHEMISMS, and "
|
|
267
|
+
f"domain aliases (e.g. 'disappear'->'die', 'hobbyist'->'hobby', 'CD'->'certificate of "
|
|
268
|
+
f"deposit'), not just reworded questions — paraphrases alone retrieve the same documents. "
|
|
269
|
+
f"Return one per line.\nQuery: {query}"
|
|
270
|
+
)
|
|
271
|
+
variants = [q.strip() for q in generate(prompt) if q.strip()]
|
|
272
|
+
return [query, *[v for v in variants if v != query]]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def rephrase(query: str, generate: Callable[[str], list[str]]) -> str:
|
|
276
|
+
"""Rewrite a query into a single retrieval-optimized, standalone formulation
|
|
277
|
+
(Rewrite-Retrieve-Read). Returns the original if the model yields nothing."""
|
|
278
|
+
prompt = (
|
|
279
|
+
"Rewrite the following search query to be clearer and more retrieval-effective "
|
|
280
|
+
"while preserving its exact information need. Return only the rewritten query.\n"
|
|
281
|
+
f"Query: {query}"
|
|
282
|
+
)
|
|
283
|
+
out = [q.strip() for q in generate(prompt) if q.strip()]
|
|
284
|
+
return out[0] if out else query
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def decompose(query: str, generate: Callable[[str], list[str]]) -> list[str]:
|
|
288
|
+
"""Break a complex query into answerable sub-questions (least-to-most,
|
|
289
|
+
LlamaIndex sub-question, Haystack decomposition)."""
|
|
290
|
+
prompt = (
|
|
291
|
+
"Decompose this question into the minimal set of simpler sub-questions "
|
|
292
|
+
f"needed to answer it. Return one per line.\nQuestion: {query}"
|
|
293
|
+
)
|
|
294
|
+
return [q.strip() for q in generate(prompt) if q.strip()]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def topics(query: str, generate: Callable[[str], list[str]], n: int = 5) -> list[str]:
|
|
298
|
+
"""LLM topic/entity extraction — the key concepts to search or filter on."""
|
|
299
|
+
prompt = (
|
|
300
|
+
f"List up to {n} key topics or named entities in this search query, "
|
|
301
|
+
f"one per line, no numbering or extra text.\nQuery: {query}"
|
|
302
|
+
)
|
|
303
|
+
return [t.strip(" -*\t") for t in generate(prompt) if t.strip()][:n]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def auto_filter(query: str, generate: Callable[[str], list[str]],
|
|
307
|
+
fields: Optional[Sequence[str]] = None) -> dict[str, Any]:
|
|
308
|
+
"""Self-query: the LLM infers a metadata filter implied by the query
|
|
309
|
+
(LangChain SelfQueryRetriever / LlamaIndex auto-retrieval). Returns a filter
|
|
310
|
+
dict in the portable dialect, or {} if the query implies no constraint."""
|
|
311
|
+
import json
|
|
312
|
+
import re
|
|
313
|
+
|
|
314
|
+
fdesc = f"Available metadata fields: {', '.join(fields)}.\n" if fields else ""
|
|
315
|
+
prompt = (
|
|
316
|
+
"Infer a metadata filter that this query implies, as a JSON object in this dialect: "
|
|
317
|
+
'{"field": value} or {"field": {"$gte": n}} / {"$in": [...]} etc. '
|
|
318
|
+
"Return ONLY the JSON object, or {} if the query implies no filter.\n"
|
|
319
|
+
f"{fdesc}Query: {query}"
|
|
320
|
+
)
|
|
321
|
+
raw = "\n".join(generate(prompt))
|
|
322
|
+
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
323
|
+
if not m:
|
|
324
|
+
return {}
|
|
325
|
+
try:
|
|
326
|
+
out = json.loads(m.group(0))
|
|
327
|
+
return out if isinstance(out, dict) else {}
|
|
328
|
+
except Exception:
|
|
329
|
+
return {}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Spelling / acronym normalization (extend per domain). Fixes the cheque↔check class.
|
|
333
|
+
DEFAULT_ALIASES = {
|
|
334
|
+
"cheque": "check", "cheques": "checks", "favour": "favor", "colour": "color",
|
|
335
|
+
"organisation": "organization", "cancelled": "canceled", "cheque's": "check's",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def normalize_query(query: str, aliases: Optional[dict] = None) -> str:
|
|
340
|
+
"""Normalize spelling/acronym variants so query and doc share tokens
|
|
341
|
+
(cheque→check, US/UK, domain aliases). Apply to query AND at index time."""
|
|
342
|
+
aliases = aliases or DEFAULT_ALIASES
|
|
343
|
+
return "".join(aliases.get(t.lower(), t) if t.strip() else t
|
|
344
|
+
for t in _re.findall(r"\w+|\W+", query))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def rare_terms(query: str) -> list[str]:
|
|
348
|
+
"""High-signal exact tokens worth boosting in a keyword pass: quoted phrases,
|
|
349
|
+
ALL-CAPS acronyms (CD, EIN, SEC, LLC), numbers/$amounts/versions, Proper Bigrams."""
|
|
350
|
+
terms: list[str] = []
|
|
351
|
+
terms += _re.findall(r'"([^"]+)"', query) # quoted phrases
|
|
352
|
+
terms += _re.findall(r"\b[A-Z]{2,}\b", query) # acronyms
|
|
353
|
+
terms += _re.findall(r"\$?\d[\d,.]*[kKmM%]?", query) # numbers / $ / versions
|
|
354
|
+
terms += _re.findall(r"\b[A-Z][a-z]+\s+[A-Z][a-z]+\b", query) # Proper Bigrams
|
|
355
|
+
seen: set = set()
|
|
356
|
+
out: list[str] = []
|
|
357
|
+
for t in terms:
|
|
358
|
+
t = t.strip()
|
|
359
|
+
if t and t.lower() not in seen:
|
|
360
|
+
seen.add(t.lower())
|
|
361
|
+
out.append(t)
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def quality_filter(results: ResultSet, min_chars: int = 40) -> ResultSet:
|
|
366
|
+
"""Drop empty / near-empty docs (label & parse artifacts, pure noise)."""
|
|
367
|
+
return ResultSet(h for h in results if h.text and len(h.text.strip()) >= min_chars)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def extract(
|
|
371
|
+
results: ResultSet,
|
|
372
|
+
schema: dict[str, Any],
|
|
373
|
+
instruction: str,
|
|
374
|
+
extractor: Optional[Callable[[list[str], dict, str], list[dict]]] = None,
|
|
375
|
+
) -> list[dict[str, Any]]:
|
|
376
|
+
"""Pull structured records out of hits (the SaC "verification" stage).
|
|
377
|
+
|
|
378
|
+
Requires an ``extractor(texts, schema, instruction) -> list[dict]`` — usually
|
|
379
|
+
an LLM call. Kept pluggable so the core stays model-agnostic.
|
|
380
|
+
"""
|
|
381
|
+
if extractor is None:
|
|
382
|
+
raise ExtractorRequiredError(
|
|
383
|
+
"extract() needs an extractor callable (e.g. an LLM). "
|
|
384
|
+
"Pass Session(..., extractor=fn) or extract(..., extractor=fn)."
|
|
385
|
+
)
|
|
386
|
+
return extractor(results.texts(), schema, instruction)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _lexical_overlap(query: str, texts: Sequence[str]) -> list[float]:
|
|
390
|
+
from .embeddings import _tokenize
|
|
391
|
+
|
|
392
|
+
q = set(_tokenize(query))
|
|
393
|
+
if not q:
|
|
394
|
+
return [0.0] * len(texts)
|
|
395
|
+
scores = []
|
|
396
|
+
for t in texts:
|
|
397
|
+
toks = set(_tokenize(t))
|
|
398
|
+
scores.append(len(q & toks) / len(q) if toks else 0.0)
|
|
399
|
+
return scores
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def content_type(text: str) -> str:
|
|
403
|
+
"""Heuristic classifier so an agent knows the DATA SHAPE of a chunk — one of
|
|
404
|
+
'table', 'fact-card', 'list', 'code', 'prose', 'short-fact', 'empty'.
|
|
405
|
+
Pairs with store.describe_schema()/sample() for schema-first agentic retrieval."""
|
|
406
|
+
if not text or not text.strip():
|
|
407
|
+
return "empty"
|
|
408
|
+
t = text.strip()
|
|
409
|
+
lines = [ln for ln in t.splitlines() if ln.strip()]
|
|
410
|
+
kv = sum(1 for ln in lines if "=" in ln and len(ln) < 90)
|
|
411
|
+
if t.count("|") >= 4 or t.count("\t") >= 3:
|
|
412
|
+
return "table"
|
|
413
|
+
if lines and kv >= max(2, len(lines) // 2):
|
|
414
|
+
return "fact-card"
|
|
415
|
+
if lines and sum(1 for ln in lines if ln.lstrip()[:2] in ("- ", "* ", "• ")
|
|
416
|
+
or ln.lstrip()[:1].isdigit()) >= max(2, len(lines) // 2):
|
|
417
|
+
return "list"
|
|
418
|
+
if any(k in t for k in ("def ", "import ", "function ", "{", "};", "</")):
|
|
419
|
+
return "code"
|
|
420
|
+
if t.count(". ") >= 2 or len(t) > 300:
|
|
421
|
+
return "prose"
|
|
422
|
+
return "short-fact"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Concrete reranker implementations (optional; local, no API).
|
|
2
|
+
|
|
3
|
+
A reranker is any ``callable(query, texts) -> list[float]``. ``CrossEncoderReranker``
|
|
4
|
+
wraps a sentence-transformers cross-encoder — the standard two-stage
|
|
5
|
+
retrieve-then-rerank primitive — and plugs straight into ``Session(reranker=...)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CrossEncoderReranker:
|
|
14
|
+
"""Cross-encoder relevance scorer (e.g. BAAI/bge-reranker, ms-marco-MiniLM).
|
|
15
|
+
|
|
16
|
+
Lazy-loads the model on first call so importing the SDK stays cheap.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2", device: str | None = None):
|
|
20
|
+
self.model_name = model
|
|
21
|
+
self._device = device
|
|
22
|
+
self._model = None
|
|
23
|
+
|
|
24
|
+
def _ensure(self):
|
|
25
|
+
if self._model is None:
|
|
26
|
+
import torch
|
|
27
|
+
from sentence_transformers import CrossEncoder
|
|
28
|
+
|
|
29
|
+
device = self._device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
30
|
+
self._model = CrossEncoder(self.model_name, device=device)
|
|
31
|
+
return self._model
|
|
32
|
+
|
|
33
|
+
def __call__(self, query: str, texts: Sequence[str]) -> list[float]:
|
|
34
|
+
if not texts:
|
|
35
|
+
return []
|
|
36
|
+
model = self._ensure()
|
|
37
|
+
pairs = [(query, t or "") for t in texts]
|
|
38
|
+
return [float(s) for s in model.predict(pairs, show_progress_bar=False)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class QwenReranker:
|
|
42
|
+
"""Qwen3-Reranker — an LLM reranker that scores relevance via the yes/no logit.
|
|
43
|
+
Much stronger than ms-marco cross-encoders on out-of-domain corpora (FiQA etc.).
|
|
44
|
+
Same ``callable(query, texts) -> list[float]`` interface. Lazy-loaded.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_PREFIX = ('<|im_start|>system\nJudge whether the Document meets the requirements based on the '
|
|
48
|
+
'Query and the Instruct provided. Note that the answer can only be "yes" or "no".'
|
|
49
|
+
'<|im_end|>\n<|im_start|>user\n')
|
|
50
|
+
_SUFFIX = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
|
|
51
|
+
|
|
52
|
+
def __init__(self, model: str = "Qwen/Qwen3-Reranker-0.6B", device: str | None = None,
|
|
53
|
+
max_length: int = 512,
|
|
54
|
+
instruction: str = "Given a query, retrieve passages that answer it"):
|
|
55
|
+
self.model_name = model
|
|
56
|
+
self._device = device
|
|
57
|
+
self.max_length = max_length
|
|
58
|
+
self.instruction = instruction
|
|
59
|
+
self._model: Any = None
|
|
60
|
+
|
|
61
|
+
def _ensure(self):
|
|
62
|
+
if self._model is None:
|
|
63
|
+
import torch
|
|
64
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
65
|
+
|
|
66
|
+
dev = self._device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
67
|
+
self.tok = AutoTokenizer.from_pretrained(self.model_name, padding_side="left")
|
|
68
|
+
self._model = AutoModelForCausalLM.from_pretrained(
|
|
69
|
+
self.model_name,
|
|
70
|
+
torch_dtype=torch.float16 if dev == "cuda" else torch.float32,
|
|
71
|
+
).to(dev).eval() # type: ignore[arg-type]
|
|
72
|
+
self.dev = dev
|
|
73
|
+
self.tid_yes = self.tok.convert_tokens_to_ids("yes")
|
|
74
|
+
self.tid_no = self.tok.convert_tokens_to_ids("no")
|
|
75
|
+
return self._model
|
|
76
|
+
|
|
77
|
+
def __call__(self, query: str, texts: Sequence[str], batch_size: int = 16) -> list[float]:
|
|
78
|
+
if not texts:
|
|
79
|
+
return []
|
|
80
|
+
import torch
|
|
81
|
+
|
|
82
|
+
self._ensure()
|
|
83
|
+
prompts = [f"{self._PREFIX}<Instruct>: {self.instruction}\n<Query>: {query}\n"
|
|
84
|
+
f"<Document>: {t or ''}{self._SUFFIX}" for t in texts]
|
|
85
|
+
scores: list[float] = []
|
|
86
|
+
for i in range(0, len(prompts), batch_size):
|
|
87
|
+
enc = self.tok(prompts[i:i + batch_size], return_tensors="pt", padding=True,
|
|
88
|
+
truncation=True, max_length=self.max_length).to(self.dev)
|
|
89
|
+
with torch.no_grad():
|
|
90
|
+
logits = self._model(**enc).logits[:, -1, :]
|
|
91
|
+
yn = torch.stack([logits[:, self.tid_no], logits[:, self.tid_yes]], dim=1)
|
|
92
|
+
p = torch.log_softmax(yn.float(), dim=1)[:, 1].exp()
|
|
93
|
+
scores.extend(p.cpu().tolist())
|
|
94
|
+
return scores
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Execution layer for search-as-code.
|
|
2
|
+
|
|
3
|
+
Agent-generated Python runs *here*, with a :class:`Session` injected as ``sac``
|
|
4
|
+
and the primitives in scope. Bulky intermediate results stay in this namespace;
|
|
5
|
+
only what the code ``print``s or assigns to ``evidence`` is returned to the
|
|
6
|
+
model. That boundary is the whole efficiency argument of code-mode retrieval.
|
|
7
|
+
|
|
8
|
+
``LocalExecutor`` is an in-process runner for development and tests. It is NOT a
|
|
9
|
+
security boundary — untrusted code needs a real isolate (Docker / e2b / Pyodide)
|
|
10
|
+
behind the same :class:`Sandbox` interface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
import contextlib
|
|
17
|
+
import io
|
|
18
|
+
import traceback
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from . import primitives as P
|
|
23
|
+
from .session import Session
|
|
24
|
+
from .types import Document, Hit, ResultSet
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ExecResult:
|
|
29
|
+
ok: bool
|
|
30
|
+
evidence: Any = None
|
|
31
|
+
stdout: str = ""
|
|
32
|
+
error: Optional[str] = None
|
|
33
|
+
state_keys: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
def for_model(self) -> dict[str, Any]:
|
|
36
|
+
"""The compact payload to feed back into the model context."""
|
|
37
|
+
payload: dict[str, Any] = {"ok": self.ok}
|
|
38
|
+
if self.evidence is not None:
|
|
39
|
+
payload["evidence"] = self.evidence
|
|
40
|
+
if self.stdout.strip():
|
|
41
|
+
payload["stdout"] = self.stdout.strip()[:4000]
|
|
42
|
+
if self.error:
|
|
43
|
+
payload["error"] = self.error
|
|
44
|
+
if self.state_keys:
|
|
45
|
+
payload["state_keys"] = self.state_keys
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Sandbox(abc.ABC):
|
|
50
|
+
@abc.abstractmethod
|
|
51
|
+
def run(self, code: str) -> ExecResult: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LocalExecutor(Sandbox):
|
|
55
|
+
"""In-process executor. State persists across ``run`` calls via the shared
|
|
56
|
+
Session, enabling multi-turn workflows without re-fetching."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, session: Session):
|
|
59
|
+
self.session = session
|
|
60
|
+
self._globals = self._build_namespace()
|
|
61
|
+
|
|
62
|
+
def _build_namespace(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"sac": self.session, # the harness handle
|
|
65
|
+
"session": self.session, # alias
|
|
66
|
+
"Document": Document,
|
|
67
|
+
"Hit": Hit,
|
|
68
|
+
"ResultSet": ResultSet,
|
|
69
|
+
# primitives available as bare functions too
|
|
70
|
+
"fan_out": P.fan_out,
|
|
71
|
+
"fuse": P.fuse,
|
|
72
|
+
"rerank": P.rerank,
|
|
73
|
+
"dedup": P.dedup,
|
|
74
|
+
"freshness": P.freshness,
|
|
75
|
+
"mmr": P.mmr,
|
|
76
|
+
"expand": P.expand,
|
|
77
|
+
"decompose": P.decompose,
|
|
78
|
+
"__builtins__": _safe_builtins(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def run(self, code: str) -> ExecResult:
|
|
82
|
+
self._globals.pop("evidence", None)
|
|
83
|
+
buf = io.StringIO()
|
|
84
|
+
try:
|
|
85
|
+
with contextlib.redirect_stdout(buf):
|
|
86
|
+
exec(compile(code, "<agent-code>", "exec"), self._globals) # noqa: S102
|
|
87
|
+
except Exception:
|
|
88
|
+
return ExecResult(
|
|
89
|
+
ok=False,
|
|
90
|
+
stdout=buf.getvalue(),
|
|
91
|
+
error=traceback.format_exc(limit=3),
|
|
92
|
+
state_keys=self.session.state_keys(),
|
|
93
|
+
)
|
|
94
|
+
return ExecResult(
|
|
95
|
+
ok=True,
|
|
96
|
+
evidence=self._globals.get("evidence"),
|
|
97
|
+
stdout=buf.getvalue(),
|
|
98
|
+
state_keys=self.session.state_keys(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _safe_builtins() -> dict[str, Any]:
|
|
103
|
+
"""A conservative builtins allowlist for the local executor.
|
|
104
|
+
|
|
105
|
+
Real isolation belongs in the sandbox backend; this only trims the sharpest
|
|
106
|
+
edges so a stray ``open``/``__import__`` in dev doesn't touch the host.
|
|
107
|
+
"""
|
|
108
|
+
import builtins
|
|
109
|
+
|
|
110
|
+
allow = {
|
|
111
|
+
"abs", "all", "any", "bool", "dict", "enumerate", "filter", "float",
|
|
112
|
+
"int", "len", "list", "map", "max", "min", "print", "range", "reversed",
|
|
113
|
+
"round", "set", "sorted", "str", "sum", "tuple", "zip", "isinstance",
|
|
114
|
+
"getattr", "hasattr", "True", "False", "None", "Exception",
|
|
115
|
+
}
|
|
116
|
+
return {k: getattr(builtins, k) for k in allow if hasattr(builtins, k)}
|