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,89 @@
|
|
|
1
|
+
"""search-as-code — one API, any vector database.
|
|
2
|
+
|
|
3
|
+
A unified "search as code" agentic harness: agents write portable Python against
|
|
4
|
+
a single primitive API, executed in a sandbox with intermediate state kept out
|
|
5
|
+
of the model context, regardless of which vector DB is underneath.
|
|
6
|
+
|
|
7
|
+
Quickstart:
|
|
8
|
+
|
|
9
|
+
import search_as_code as sac
|
|
10
|
+
|
|
11
|
+
s = sac.Session("memory") # any backend, same call
|
|
12
|
+
s.add([{"id": "1", "text": "hello world"}])
|
|
13
|
+
hits = s.search("hello", top_k=3)
|
|
14
|
+
print(hits.to_evidence())
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .adapters import MemoryStore, VectorStore, available, connect, register
|
|
18
|
+
from .embeddings import Embedder, HashEmbedder, as_embedder, get_embedder
|
|
19
|
+
from .explore import ProfilePack, explore
|
|
20
|
+
from .errors import (
|
|
21
|
+
BackendError,
|
|
22
|
+
BackendNotFoundError,
|
|
23
|
+
ConfigurationError,
|
|
24
|
+
DimensionMismatchError,
|
|
25
|
+
EmbeddingError,
|
|
26
|
+
ExtractorRequiredError,
|
|
27
|
+
GeneratorRequiredError,
|
|
28
|
+
InvalidArgumentError,
|
|
29
|
+
InvalidEmbedderError,
|
|
30
|
+
InvalidFilterError,
|
|
31
|
+
InvalidModeError,
|
|
32
|
+
MissingDependencyError,
|
|
33
|
+
SacError,
|
|
34
|
+
)
|
|
35
|
+
from .primitives import (
|
|
36
|
+
abstain,
|
|
37
|
+
auto_filter,
|
|
38
|
+
confidence,
|
|
39
|
+
decompose,
|
|
40
|
+
dedup,
|
|
41
|
+
diversity_quota,
|
|
42
|
+
expand,
|
|
43
|
+
extract,
|
|
44
|
+
fan_out,
|
|
45
|
+
freshness,
|
|
46
|
+
fuse,
|
|
47
|
+
mmr,
|
|
48
|
+
normalize_query,
|
|
49
|
+
normalize_scores,
|
|
50
|
+
quality_filter,
|
|
51
|
+
rare_terms,
|
|
52
|
+
relative_score_fusion,
|
|
53
|
+
rephrase,
|
|
54
|
+
rerank,
|
|
55
|
+
rrf,
|
|
56
|
+
score_cutoff,
|
|
57
|
+
topics,
|
|
58
|
+
)
|
|
59
|
+
from .rerankers import CrossEncoderReranker, QwenReranker
|
|
60
|
+
from .sandbox import ExecResult, LocalExecutor, Sandbox
|
|
61
|
+
from .session import Session, route
|
|
62
|
+
from .types import Capabilities, Document, Hit, ResultSet
|
|
63
|
+
|
|
64
|
+
__version__ = "0.0.1"
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
# data model
|
|
68
|
+
"Document", "Hit", "ResultSet", "Capabilities",
|
|
69
|
+
# backends
|
|
70
|
+
"connect", "register", "available", "VectorStore", "MemoryStore",
|
|
71
|
+
# embeddings
|
|
72
|
+
"Embedder", "HashEmbedder", "as_embedder", "get_embedder",
|
|
73
|
+
# harness
|
|
74
|
+
"Session", "route", "Sandbox", "LocalExecutor", "ExecResult",
|
|
75
|
+
# exploration / onboarding
|
|
76
|
+
"explore", "ProfilePack",
|
|
77
|
+
# primitives
|
|
78
|
+
"fan_out", "fuse", "dedup", "rerank", "freshness", "extract",
|
|
79
|
+
"mmr", "expand", "decompose", "rephrase", "rrf", "topics", "auto_filter", "score_cutoff",
|
|
80
|
+
"normalize_scores", "relative_score_fusion", "diversity_quota", "confidence", "abstain",
|
|
81
|
+
"normalize_query", "rare_terms", "quality_filter",
|
|
82
|
+
# rerankers
|
|
83
|
+
"CrossEncoderReranker", "QwenReranker",
|
|
84
|
+
# errors
|
|
85
|
+
"SacError", "ConfigurationError", "BackendNotFoundError", "MissingDependencyError",
|
|
86
|
+
"InvalidArgumentError", "InvalidModeError", "InvalidFilterError",
|
|
87
|
+
"DimensionMismatchError", "InvalidEmbedderError", "GeneratorRequiredError",
|
|
88
|
+
"ExtractorRequiredError", "EmbeddingError", "BackendError",
|
|
89
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Retry / batching helpers for network-backed adapters.
|
|
2
|
+
|
|
3
|
+
Small, dependency-free utilities so every real adapter gets consistent,
|
|
4
|
+
testable resilience: exponential backoff with jitter around transient backend
|
|
5
|
+
calls, and chunking for bulk upserts. The in-memory backend needs none of this;
|
|
6
|
+
the network adapters (OpenSearch/Qdrant/Chroma/pgvector) opt in.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Iterable, Iterator, Sequence, TypeVar
|
|
14
|
+
|
|
15
|
+
from .errors import BackendError, SacError
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
DEFAULT_ATTEMPTS = 3
|
|
20
|
+
DEFAULT_BACKOFF = 0.5 # seconds; grows 0.5, 1.0, 2.0, ...
|
|
21
|
+
DEFAULT_BATCH_SIZE = 500
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def with_retry(
|
|
25
|
+
fn: Callable[..., T],
|
|
26
|
+
*args: Any,
|
|
27
|
+
attempts: int = DEFAULT_ATTEMPTS,
|
|
28
|
+
backoff: float = DEFAULT_BACKOFF,
|
|
29
|
+
exceptions: tuple[type[BaseException], ...] = (Exception,),
|
|
30
|
+
backend: str = "backend",
|
|
31
|
+
op: str = "call",
|
|
32
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> T:
|
|
35
|
+
"""Call ``fn(*args, **kwargs)``, retrying transient ``exceptions``.
|
|
36
|
+
|
|
37
|
+
Retries ``attempts`` times with exponential backoff (deterministic multiples
|
|
38
|
+
of ``backoff``; ``sleep`` is injectable so tests run instantly). On final
|
|
39
|
+
failure the underlying error is wrapped in :class:`BackendError` (which is
|
|
40
|
+
also a ``RuntimeError``) with the original attached via ``__cause__``.
|
|
41
|
+
``SacError`` is never retried or re-wrapped — those are our own typed,
|
|
42
|
+
non-transient errors.
|
|
43
|
+
"""
|
|
44
|
+
if attempts < 1:
|
|
45
|
+
raise ValueError("attempts must be >= 1")
|
|
46
|
+
last: BaseException | None = None
|
|
47
|
+
for i in range(1, attempts + 1):
|
|
48
|
+
try:
|
|
49
|
+
return fn(*args, **kwargs)
|
|
50
|
+
except SacError:
|
|
51
|
+
raise # our own typed, non-transient errors: never retry or re-wrap
|
|
52
|
+
except exceptions as e: # noqa: BLE001 - deliberately broad, caller-scoped
|
|
53
|
+
last = e
|
|
54
|
+
if i >= attempts:
|
|
55
|
+
break
|
|
56
|
+
sleep(backoff * (2 ** (i - 1)))
|
|
57
|
+
raise BackendError(
|
|
58
|
+
f"{backend} {op} failed after {attempts} attempt(s)",
|
|
59
|
+
backend=backend,
|
|
60
|
+
op=op,
|
|
61
|
+
cause=type(last).__name__ if last else None,
|
|
62
|
+
) from last
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def retry(
|
|
66
|
+
*,
|
|
67
|
+
attempts: int = DEFAULT_ATTEMPTS,
|
|
68
|
+
backoff: float = DEFAULT_BACKOFF,
|
|
69
|
+
exceptions: tuple[type[BaseException], ...] = (Exception,),
|
|
70
|
+
backend: str = "backend",
|
|
71
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
72
|
+
"""Decorator form of :func:`with_retry` (``op`` defaults to the func name)."""
|
|
73
|
+
|
|
74
|
+
def deco(fn: Callable[..., T]) -> Callable[..., T]:
|
|
75
|
+
@functools.wraps(fn)
|
|
76
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
77
|
+
return with_retry(
|
|
78
|
+
fn, *args,
|
|
79
|
+
attempts=attempts, backoff=backoff, exceptions=exceptions,
|
|
80
|
+
backend=backend, op=fn.__name__, **kwargs,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return wrapper
|
|
84
|
+
|
|
85
|
+
return deco
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def chunked(seq: Sequence[T] | Iterable[T], size: int = DEFAULT_BATCH_SIZE) -> Iterator[list[T]]:
|
|
89
|
+
"""Yield ``seq`` in lists of at most ``size`` (for batched upserts)."""
|
|
90
|
+
if size < 1:
|
|
91
|
+
raise ValueError("size must be >= 1")
|
|
92
|
+
batch: list[T] = []
|
|
93
|
+
for item in seq:
|
|
94
|
+
batch.append(item)
|
|
95
|
+
if len(batch) >= size:
|
|
96
|
+
yield batch
|
|
97
|
+
batch = []
|
|
98
|
+
if batch:
|
|
99
|
+
yield batch
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The one interface every vector database must satisfy.
|
|
2
|
+
|
|
3
|
+
This ABC is the contract that makes "no separate SDK per DB" possible: agent
|
|
4
|
+
code and the primitive layer only ever touch these methods. Adapters translate
|
|
5
|
+
them to their backend's native client.
|
|
6
|
+
|
|
7
|
+
Design rules for adapters:
|
|
8
|
+
* Return :class:`Hit` with **larger-is-better** scores (convert distances).
|
|
9
|
+
* Accept the portable filter dialect from :mod:`search_as_code.filters`.
|
|
10
|
+
* Declare honestly via :meth:`capabilities`; the primitive layer emulates any
|
|
11
|
+
capability you report as ``False`` (e.g. client-side rerank or keyword search)
|
|
12
|
+
so agent code behaves the same everywhere.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import abc
|
|
18
|
+
from typing import Any, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
from ..types import Capabilities, Document, ResultSet
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class VectorStore(abc.ABC):
|
|
24
|
+
#: registry key, e.g. "memory", "qdrant"
|
|
25
|
+
backend: str = "base"
|
|
26
|
+
|
|
27
|
+
@abc.abstractmethod
|
|
28
|
+
def capabilities(self) -> Capabilities: ...
|
|
29
|
+
|
|
30
|
+
@abc.abstractmethod
|
|
31
|
+
def upsert(self, docs: Sequence[Document]) -> None: ...
|
|
32
|
+
|
|
33
|
+
@abc.abstractmethod
|
|
34
|
+
def query_vector(
|
|
35
|
+
self,
|
|
36
|
+
vector: Sequence[float],
|
|
37
|
+
top_k: int = 10,
|
|
38
|
+
flt: Optional[dict[str, Any]] = None,
|
|
39
|
+
) -> ResultSet:
|
|
40
|
+
"""Dense nearest-neighbour search."""
|
|
41
|
+
|
|
42
|
+
# ---- optional capabilities: default to NotImplemented so the primitive
|
|
43
|
+
# layer knows to emulate. Adapters override when the backend supports them.
|
|
44
|
+
|
|
45
|
+
def query_keyword(
|
|
46
|
+
self,
|
|
47
|
+
text: str,
|
|
48
|
+
top_k: int = 10,
|
|
49
|
+
flt: Optional[dict[str, Any]] = None,
|
|
50
|
+
) -> ResultSet:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def query_hybrid(
|
|
54
|
+
self,
|
|
55
|
+
vector: Sequence[float],
|
|
56
|
+
text: str,
|
|
57
|
+
top_k: int = 10,
|
|
58
|
+
flt: Optional[dict[str, Any]] = None,
|
|
59
|
+
alpha: float = 0.5,
|
|
60
|
+
) -> ResultSet:
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def query_regex(
|
|
64
|
+
self,
|
|
65
|
+
pattern: str,
|
|
66
|
+
top_k: int = 10,
|
|
67
|
+
flt: Optional[dict[str, Any]] = None,
|
|
68
|
+
) -> ResultSet:
|
|
69
|
+
"""Exact / regex / operator search over document text.
|
|
70
|
+
|
|
71
|
+
The "search as code for code" primitive (Hornet, Chroma): agents need
|
|
72
|
+
exact, case-sensitive matching that semantic search blurs away.
|
|
73
|
+
"""
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def embed_query(self, text: str) -> Optional[list[float]]:
|
|
77
|
+
"""Server-side embedding, if the backend does it. Returns None otherwise."""
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
@abc.abstractmethod
|
|
81
|
+
def get(self, ids: Sequence[str]) -> list[Document]: ...
|
|
82
|
+
|
|
83
|
+
def delete(self, ids: Sequence[str]) -> None:
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
def count(self) -> int:
|
|
87
|
+
raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
# ---- introspection: let the agent discover the DATA SHAPE before querying.
|
|
90
|
+
# "The first call tells the agent what data exists and what queries are safe to
|
|
91
|
+
# construct" (schema-first agentic retrieval). Adapters override with real detail.
|
|
92
|
+
|
|
93
|
+
def describe_schema(self) -> dict[str, Any]:
|
|
94
|
+
"""Field names -> types (+ sample values where cheap), doc count, backend.
|
|
95
|
+
Feed this to the LLM so it knows the fields/types before writing retrieval code."""
|
|
96
|
+
try:
|
|
97
|
+
n = self.count()
|
|
98
|
+
except Exception:
|
|
99
|
+
n = None
|
|
100
|
+
return {"backend": self.backend, "count": n, "fields": {}, "note": "override for full schema"}
|
|
101
|
+
|
|
102
|
+
def sample(self, n: int = 5) -> list[Document]:
|
|
103
|
+
"""A few representative documents so the agent sees the data shape (prose vs
|
|
104
|
+
table vs fact-card, typical length, metadata)."""
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
pass
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Chroma adapter. ``pip install 'search-as-code[chroma]'``.
|
|
2
|
+
|
|
3
|
+
Chroma returns L2/cosine *distances* (smaller is better); we convert to a
|
|
4
|
+
larger-is-better similarity so scores are comparable across every backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
from .._resilience import DEFAULT_BATCH_SIZE, chunked
|
|
12
|
+
from ..errors import MissingDependencyError
|
|
13
|
+
from ..filters import normalize
|
|
14
|
+
from ..types import Capabilities, Document, Hit, ResultSet
|
|
15
|
+
from .base import VectorStore
|
|
16
|
+
|
|
17
|
+
_OP_MAP = {"$eq": "$eq", "$ne": "$ne", "$gt": "$gt", "$gte": "$gte",
|
|
18
|
+
"$lt": "$lt", "$lte": "$lte", "$in": "$in", "$nin": "$nin"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChromaStore(VectorStore):
|
|
22
|
+
backend = "chroma"
|
|
23
|
+
|
|
24
|
+
def __init__(self, collection: str = "sac", persist_path: Optional[str] = None,
|
|
25
|
+
batch_size: int = DEFAULT_BATCH_SIZE, **_: Any):
|
|
26
|
+
try:
|
|
27
|
+
import chromadb
|
|
28
|
+
except ImportError as e: # pragma: no cover - optional dep
|
|
29
|
+
raise MissingDependencyError("chromadb", extra="search-as-code[chroma]") from e
|
|
30
|
+
self._client = chromadb.PersistentClient(path=persist_path) if persist_path else chromadb.Client()
|
|
31
|
+
self._col = self._client.get_or_create_collection(collection)
|
|
32
|
+
self.batch_size = batch_size
|
|
33
|
+
|
|
34
|
+
def capabilities(self) -> Capabilities:
|
|
35
|
+
return Capabilities(dense=True, keyword=False, hybrid=False, metadata_filter=True)
|
|
36
|
+
|
|
37
|
+
def upsert(self, docs: Sequence[Document]) -> None:
|
|
38
|
+
docs = [d for d in docs if d.vector is not None]
|
|
39
|
+
for batch in chunked(docs, self.batch_size):
|
|
40
|
+
self._col.upsert(
|
|
41
|
+
ids=[d.id for d in batch],
|
|
42
|
+
embeddings=[d.vector for d in batch],
|
|
43
|
+
documents=[d.text or "" for d in batch],
|
|
44
|
+
metadatas=[d.metadata or {"_": ""} for d in batch],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _to_where(self, flt: Optional[dict]) -> Optional[dict]:
|
|
48
|
+
if not flt:
|
|
49
|
+
return None
|
|
50
|
+
clauses = []
|
|
51
|
+
for field_name, cond in normalize(flt).items():
|
|
52
|
+
if field_name.startswith("$"):
|
|
53
|
+
continue
|
|
54
|
+
clauses.append({field_name: {_OP_MAP[op]: v for op, v in cond.items() if op in _OP_MAP}})
|
|
55
|
+
if not clauses:
|
|
56
|
+
return None
|
|
57
|
+
return clauses[0] if len(clauses) == 1 else {"$and": clauses}
|
|
58
|
+
|
|
59
|
+
def query_vector(self, vector, top_k=10, flt=None) -> ResultSet:
|
|
60
|
+
res = self._col.query(
|
|
61
|
+
query_embeddings=[list(vector)],
|
|
62
|
+
n_results=top_k,
|
|
63
|
+
where=self._to_where(flt),
|
|
64
|
+
include=["documents", "metadatas", "distances"],
|
|
65
|
+
)
|
|
66
|
+
hits = []
|
|
67
|
+
ids = res["ids"][0]
|
|
68
|
+
for i, _id in enumerate(ids):
|
|
69
|
+
dist = res["distances"][0][i]
|
|
70
|
+
hits.append(
|
|
71
|
+
Hit(
|
|
72
|
+
id=_id,
|
|
73
|
+
score=1.0 / (1.0 + float(dist)), # distance -> larger-is-better
|
|
74
|
+
document=Document(
|
|
75
|
+
id=_id,
|
|
76
|
+
text=res["documents"][0][i],
|
|
77
|
+
metadata=res["metadatas"][0][i] or {},
|
|
78
|
+
),
|
|
79
|
+
store=self.backend,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return ResultSet(hits)
|
|
83
|
+
|
|
84
|
+
def get(self, ids: Sequence[str]) -> list[Document]:
|
|
85
|
+
res = self._col.get(ids=list(ids), include=["documents", "metadatas"])
|
|
86
|
+
return [
|
|
87
|
+
Document(id=_id, text=res["documents"][i], metadata=res["metadatas"][i] or {})
|
|
88
|
+
for i, _id in enumerate(res["ids"])
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def delete(self, ids: Sequence[str]) -> None:
|
|
92
|
+
self._col.delete(ids=list(ids))
|
|
93
|
+
|
|
94
|
+
def count(self) -> int:
|
|
95
|
+
return self._col.count()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""FAISS adapter — in-process ANN, no server.
|
|
2
|
+
|
|
3
|
+
FAISS handles dense (exact inner-product on L2-normalized vectors == cosine); the
|
|
4
|
+
keyword/hybrid/regex capabilities are served client-side by a composed
|
|
5
|
+
:class:`MemoryStore` so agent code behaves identically to a full backend. This is
|
|
6
|
+
the "one primitive API, any vector DB" thesis for an embedded index.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from ..types import Capabilities, Document, Hit, ResultSet
|
|
16
|
+
from .base import VectorStore
|
|
17
|
+
from .memory import MemoryStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _norm(mat: np.ndarray) -> np.ndarray:
|
|
21
|
+
n = np.linalg.norm(mat, axis=1, keepdims=True)
|
|
22
|
+
n[n == 0] = 1.0
|
|
23
|
+
return mat / n
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FaissStore(VectorStore):
|
|
27
|
+
backend = "faiss"
|
|
28
|
+
|
|
29
|
+
def __init__(self, dim: int, **_: Any):
|
|
30
|
+
import faiss
|
|
31
|
+
|
|
32
|
+
self.dim = int(dim)
|
|
33
|
+
self.index = faiss.IndexFlatIP(self.dim) # exact cosine (vectors normalized)
|
|
34
|
+
self._ids: list[str] = []
|
|
35
|
+
self._mem = MemoryStore() # holds docs + keyword/regex/text
|
|
36
|
+
|
|
37
|
+
def capabilities(self) -> Capabilities:
|
|
38
|
+
return Capabilities(dense=True, keyword=True, hybrid=True, regex=True,
|
|
39
|
+
server_side_embedding=False, native_rerank=False,
|
|
40
|
+
metadata_filter=True)
|
|
41
|
+
|
|
42
|
+
def upsert(self, docs: Sequence[Document]) -> None:
|
|
43
|
+
vecs, keep = [], []
|
|
44
|
+
for d in docs:
|
|
45
|
+
if d.vector is None:
|
|
46
|
+
continue
|
|
47
|
+
vecs.append(d.vector)
|
|
48
|
+
keep.append(d)
|
|
49
|
+
if vecs:
|
|
50
|
+
self.index.add(_norm(np.asarray(vecs, dtype=np.float32)))
|
|
51
|
+
self._ids.extend(d.id for d in keep)
|
|
52
|
+
self._mem.upsert(docs)
|
|
53
|
+
|
|
54
|
+
def query_vector(self, vector, top_k=10, flt=None) -> ResultSet:
|
|
55
|
+
if self.index.ntotal == 0:
|
|
56
|
+
return ResultSet()
|
|
57
|
+
q = _norm(np.asarray([vector], dtype=np.float32))
|
|
58
|
+
# over-fetch when filtering, then apply the portable filter client-side
|
|
59
|
+
k = min(self.index.ntotal, top_k * (8 if flt else 1))
|
|
60
|
+
scores, idx = self.index.search(q, k)
|
|
61
|
+
from ..filters import matches
|
|
62
|
+
hits = []
|
|
63
|
+
for s, i in zip(scores[0], idx[0]):
|
|
64
|
+
if i < 0:
|
|
65
|
+
continue
|
|
66
|
+
doc = self._mem._docs.get(self._ids[i])
|
|
67
|
+
if doc is None or (flt and not matches(doc.metadata, flt)):
|
|
68
|
+
continue
|
|
69
|
+
hits.append(Hit(id=self._ids[i], score=float(s), document=doc, store=self.backend))
|
|
70
|
+
if len(hits) >= top_k:
|
|
71
|
+
break
|
|
72
|
+
return ResultSet(hits)
|
|
73
|
+
|
|
74
|
+
def query_keyword(self, text, top_k=10, flt=None) -> ResultSet:
|
|
75
|
+
return self._mem.query_keyword(text, top_k=top_k, flt=flt)
|
|
76
|
+
|
|
77
|
+
def query_regex(self, pattern, top_k=10, flt=None) -> ResultSet:
|
|
78
|
+
return self._mem.query_regex(pattern, top_k=top_k, flt=flt)
|
|
79
|
+
|
|
80
|
+
def query_hybrid(self, vector, text, top_k=10, flt=None, alpha=0.5) -> ResultSet:
|
|
81
|
+
dense = self.query_vector(vector, top_k=top_k * 4, flt=flt)
|
|
82
|
+
kw = self.query_keyword(text, top_k=top_k * 4, flt=flt)
|
|
83
|
+
from ..primitives import fuse
|
|
84
|
+
return fuse([dense, kw], weights=[alpha, 1 - alpha]).top(top_k)
|
|
85
|
+
|
|
86
|
+
def get(self, ids: Sequence[str]) -> list[Document]:
|
|
87
|
+
return self._mem.get(ids)
|
|
88
|
+
|
|
89
|
+
def count(self) -> int:
|
|
90
|
+
return int(self.index.ntotal)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Zero-dependency reference adapter.
|
|
2
|
+
|
|
3
|
+
Brute-force cosine over an in-process dict plus a naive BM25-ish keyword score.
|
|
4
|
+
It exists so the entire harness — primitives, session, sandbox, tests, demos —
|
|
5
|
+
runs with nothing installed but numpy. Treat it as the executable spec every
|
|
6
|
+
real adapter must match.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from typing import Any, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ..embeddings import _tokenize
|
|
18
|
+
from ..filters import matches
|
|
19
|
+
from ..types import Capabilities, Document, Hit, ResultSet
|
|
20
|
+
from .base import VectorStore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MemoryStore(VectorStore):
|
|
24
|
+
backend = "memory"
|
|
25
|
+
|
|
26
|
+
def __init__(self, **_: Any):
|
|
27
|
+
self._docs: dict[str, Document] = {}
|
|
28
|
+
self._matrix: Optional[np.ndarray] = None
|
|
29
|
+
self._ids: list[str] = []
|
|
30
|
+
self._dirty = True
|
|
31
|
+
|
|
32
|
+
def capabilities(self) -> Capabilities:
|
|
33
|
+
return Capabilities(
|
|
34
|
+
dense=True,
|
|
35
|
+
keyword=True,
|
|
36
|
+
hybrid=True,
|
|
37
|
+
regex=True,
|
|
38
|
+
server_side_embedding=False,
|
|
39
|
+
native_rerank=False,
|
|
40
|
+
metadata_filter=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def upsert(self, docs: Sequence[Document]) -> None:
|
|
44
|
+
for d in docs:
|
|
45
|
+
self._docs[d.id] = d
|
|
46
|
+
self._dirty = True
|
|
47
|
+
|
|
48
|
+
def _rebuild(self) -> None:
|
|
49
|
+
self._ids = [d.id for d in self._docs.values() if d.vector is not None]
|
|
50
|
+
if self._ids:
|
|
51
|
+
mat = np.array([self._docs[i].vector for i in self._ids], dtype=np.float32)
|
|
52
|
+
norms = np.linalg.norm(mat, axis=1, keepdims=True)
|
|
53
|
+
norms[norms == 0] = 1.0
|
|
54
|
+
self._matrix = mat / norms
|
|
55
|
+
else:
|
|
56
|
+
self._matrix = None
|
|
57
|
+
self._dirty = False
|
|
58
|
+
|
|
59
|
+
def _candidates(self, flt: Optional[dict[str, Any]]) -> list[int]:
|
|
60
|
+
if not flt:
|
|
61
|
+
return list(range(len(self._ids)))
|
|
62
|
+
return [i for i, did in enumerate(self._ids) if matches(self._docs[did].metadata, flt)]
|
|
63
|
+
|
|
64
|
+
def query_vector(self, vector, top_k=10, flt=None) -> ResultSet:
|
|
65
|
+
if self._dirty:
|
|
66
|
+
self._rebuild()
|
|
67
|
+
if self._matrix is None:
|
|
68
|
+
return ResultSet()
|
|
69
|
+
q = np.asarray(vector, dtype=np.float32)
|
|
70
|
+
qn = np.linalg.norm(q) or 1.0
|
|
71
|
+
sims = self._matrix @ (q / qn)
|
|
72
|
+
cand = self._candidates(flt)
|
|
73
|
+
cand.sort(key=lambda i: sims[i], reverse=True)
|
|
74
|
+
hits = [
|
|
75
|
+
Hit(id=self._ids[i], score=float(sims[i]), document=self._docs[self._ids[i]], store=self.backend)
|
|
76
|
+
for i in cand[:top_k]
|
|
77
|
+
]
|
|
78
|
+
return ResultSet(hits)
|
|
79
|
+
|
|
80
|
+
def query_keyword(self, text, top_k=10, flt=None) -> ResultSet:
|
|
81
|
+
terms = Counter(_tokenize(text))
|
|
82
|
+
if not terms:
|
|
83
|
+
return ResultSet()
|
|
84
|
+
docs = [d for d in self._docs.values() if d.text and matches(d.metadata, flt)]
|
|
85
|
+
n = len(docs) or 1
|
|
86
|
+
df: Counter = Counter()
|
|
87
|
+
toks_by_id: dict[str, Counter] = {}
|
|
88
|
+
for d in docs:
|
|
89
|
+
toks = Counter(_tokenize(d.text or ""))
|
|
90
|
+
toks_by_id[d.id] = toks
|
|
91
|
+
for t in set(toks):
|
|
92
|
+
df[t] += 1
|
|
93
|
+
scored = []
|
|
94
|
+
for d in docs:
|
|
95
|
+
toks = toks_by_id[d.id]
|
|
96
|
+
dl = sum(toks.values()) or 1
|
|
97
|
+
score = 0.0
|
|
98
|
+
for term, qf in terms.items():
|
|
99
|
+
if term in toks:
|
|
100
|
+
idf = math.log(1 + n / (1 + df[term]))
|
|
101
|
+
score += qf * idf * (toks[term] / dl)
|
|
102
|
+
if score > 0:
|
|
103
|
+
scored.append(Hit(id=d.id, score=score, document=d, store=self.backend))
|
|
104
|
+
scored.sort(key=lambda h: h.score, reverse=True)
|
|
105
|
+
return ResultSet(scored[:top_k])
|
|
106
|
+
|
|
107
|
+
def query_hybrid(self, vector, text, top_k=10, flt=None, alpha=0.5) -> ResultSet:
|
|
108
|
+
dense = self.query_vector(vector, top_k=top_k * 4, flt=flt)
|
|
109
|
+
kw = self.query_keyword(text, top_k=top_k * 4, flt=flt)
|
|
110
|
+
from ..primitives import fuse # local import to avoid cycle
|
|
111
|
+
|
|
112
|
+
return fuse([dense, kw], weights=[alpha, 1 - alpha]).top(top_k)
|
|
113
|
+
|
|
114
|
+
def query_regex(self, pattern, top_k=10, flt=None) -> ResultSet:
|
|
115
|
+
import re
|
|
116
|
+
|
|
117
|
+
rx = re.compile(pattern)
|
|
118
|
+
hits = []
|
|
119
|
+
for d in self._docs.values():
|
|
120
|
+
if not d.text or not matches(d.metadata, flt):
|
|
121
|
+
continue
|
|
122
|
+
found = rx.findall(d.text)
|
|
123
|
+
if found:
|
|
124
|
+
hits.append(Hit(id=d.id, score=float(len(found)), document=d, store=self.backend))
|
|
125
|
+
hits.sort(key=lambda h: h.score, reverse=True)
|
|
126
|
+
return ResultSet(hits[:top_k])
|
|
127
|
+
|
|
128
|
+
def get(self, ids: Sequence[str]) -> list[Document]:
|
|
129
|
+
return [self._docs[i] for i in ids if i in self._docs]
|
|
130
|
+
|
|
131
|
+
def delete(self, ids: Sequence[str]) -> None:
|
|
132
|
+
for i in ids:
|
|
133
|
+
self._docs.pop(i, None)
|
|
134
|
+
self._dirty = True
|
|
135
|
+
|
|
136
|
+
def count(self) -> int:
|
|
137
|
+
return len(self._docs)
|
|
138
|
+
|
|
139
|
+
def sample(self, n: int = 5) -> list[Document]:
|
|
140
|
+
return list(self._docs.values())[:n]
|
|
141
|
+
|
|
142
|
+
def describe_schema(self) -> dict:
|
|
143
|
+
docs = self.sample(3)
|
|
144
|
+
keys: set = set()
|
|
145
|
+
for d in docs:
|
|
146
|
+
keys |= set((d.metadata or {}).keys())
|
|
147
|
+
return {"backend": self.backend, "count": len(self._docs),
|
|
148
|
+
"metadata_keys": sorted(keys), "sample_text": [(d.text or "")[:200] for d in docs]}
|