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,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,5 @@
1
+ from .base import VectorStore
2
+ from .memory import MemoryStore
3
+ from .registry import available, connect, register
4
+
5
+ __all__ = ["VectorStore", "MemoryStore", "connect", "register", "available"]
@@ -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]}