epzero 0.5.0__cp313-cp313-win_amd64.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.
epzero/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Epzero: an HNSW vector database written from scratch in C++20.
2
+
3
+ import numpy as np
4
+ import epzero
5
+
6
+ index = epzero.build(vectors, metric="cosine") # configured for the data and this machine
7
+ labels, distances = index.search(queries, k=10) # at the effort calibrated for recall 0.95
8
+ print(index.plan) # every setting, and why; pass any to override
9
+
10
+ `build` chooses compression, the build passes, the re-rank and the beam width from the data, and
11
+ times prefetching on this CPU (see epzero.auto); the settings are saved with the index. `Index` is
12
+ the same engine configured by hand:
13
+ the in-memory HNSW index (filtered search, 8-bit quantization, exact search and miss
14
+ explanations); `Database` adds a write-ahead log and crash recovery; `RecallGuard` measures recall
15
+ on a sample of live queries and moves the search effort to hold a target. `TextIndex` is keyword
16
+ search (BM25), and `hybrid_search` merges it with vector search. A LangChain vector store is in
17
+ `epzero.langchain` (pip install "epzero[langchain]").
18
+ """
19
+
20
+ from importlib.metadata import PackageNotFoundError, version
21
+
22
+ from ._core import Database, Filter, Index, Metadata, RecallGuard, TextIndex, fuse_rankings, hybrid_search, machine, simd_level
23
+ from .auto import Plan, build, plan
24
+
25
+ try:
26
+ __version__ = version("epzero")
27
+ except PackageNotFoundError: # running from a source tree
28
+ __version__ = "0.0.0"
29
+
30
+ __all__ = [
31
+ "Database",
32
+ "Filter",
33
+ "Index",
34
+ "Metadata",
35
+ "Plan",
36
+ "RecallGuard",
37
+ "TextIndex",
38
+ "build",
39
+ "fuse_rankings",
40
+ "hybrid_search",
41
+ "machine",
42
+ "plan",
43
+ "simd_level",
44
+ "__version__",
45
+ ]
Binary file
epzero/auto.py ADDED
@@ -0,0 +1,149 @@
1
+ """Automatic configuration: an index set up for your data and your machine, every choice overridable.
2
+
3
+ import epzero
4
+
5
+ index = epzero.build(vectors, metric="cosine") # chooses everything
6
+ labels, distances = index.search(queries, k=10) # at the effort calibrated for recall 0.95
7
+ print(index.plan) # what it chose, and why
8
+
9
+ index = epzero.build(vectors, metric="cosine", nav_dim=0, refine=False) # override any choice
10
+ plan = epzero.plan(vectors, metric="cosine") # or look first, edit, then build
11
+ index = epzero.build(vectors, plan=plan.replace(recall=0.99))
12
+
13
+ Two kinds of choice:
14
+ - Data: compression, walking on principal directions, the build passes, the re-rank, and the beam
15
+ width (ef) that reaches the recall target. Decided from the data.
16
+ - Machine: the SIMD kernel (picked when the library loads), the prefetch setting (timed on this
17
+ CPU) and the thread count (every core).
18
+
19
+ The decisions are made in C++ (epzero/auto.h), so every binding shares them, and the result is saved
20
+ inside the index file: Index.save / Index.load keep it, and a loaded index re-applies its prefetch
21
+ setting only on the same kind of CPU (call tune_prefetch() elsewhere). The rules come from
22
+ measurements in docs/performance.md.
23
+ """
24
+
25
+ import dataclasses
26
+
27
+ import numpy as np
28
+
29
+ from ._core import Index, _build_auto, _plan_auto
30
+
31
+ # Settings the C++ planner decides, which a caller can fix instead.
32
+ _DECIDED = ("quantization", "nav_dim", "fast_build", "refine", "fill_after", "rerank", "rerank_z", "ef", "prefetch",
33
+ "half_vectors")
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True)
37
+ class Plan:
38
+ """Every setting of an automatically configured index. None means "decide when building"."""
39
+
40
+ metric: str = "l2"
41
+ quantization: str = "sq8"
42
+ M: int = 16
43
+ ef_construction: int = 200
44
+ nav_dim: int = 0 # walk on 8-bit codes of this many principal directions (0: off)
45
+ fast_build: bool = True # build on the 8-bit codes
46
+ refine: bool = True # a second pass: every node searches the finished graph and reconnects
47
+ fill_after: bool = False # give free link slots to the closest two-hop neighbours
48
+ half_vectors: bool = False # keep the full vectors as 16-bit floats: half their memory, a little speed and recall
49
+ rerank: int = 0 # candidates re-scored with full vectors (0: twice k)
50
+ rerank_z: float = 0.0 # stop the re-rank early at this many standard deviations (0: off)
51
+ recall: float = 0.95 # the target the beam width is calibrated for
52
+ k: int = 10
53
+ ef: int | None = None # None: calibrated for `recall` at `k`
54
+ prefetch: tuple | None = None # (depth, lines); None: timed on this machine
55
+ threads: int = 0 # 0: every core
56
+ seed: int = 42
57
+ reasons: dict = dataclasses.field(default_factory=dict, compare=False)
58
+ measured: dict = dataclasses.field(default_factory=dict, compare=False)
59
+
60
+ def replace(self, **changes):
61
+ """A copy with `changes`; a changed setting is recorded as yours, not the plan's."""
62
+ unknown = set(changes) - {f.name for f in dataclasses.fields(self)}
63
+ if unknown:
64
+ raise TypeError(f"unknown settings: {', '.join(sorted(unknown))}")
65
+ reasons = {**self.reasons, **{name: "set by you" for name in changes if name not in ("reasons", "measured")}}
66
+ return dataclasses.replace(self, **changes, reasons=reasons)
67
+
68
+ def fixed(self):
69
+ """The settings you chose, which building must keep."""
70
+ return {name: getattr(self, name) for name in _DECIDED if self.reasons.get(name) == "set by you"}
71
+
72
+ def __str__(self):
73
+ width = max(len(f.name) for f in dataclasses.fields(self))
74
+ lines = []
75
+ for f in dataclasses.fields(self):
76
+ if f.name in ("reasons", "measured"):
77
+ continue
78
+ value = getattr(self, f.name)
79
+ why = self.reasons.get(f.name)
80
+ lines.append(f"{f.name:<{width}} {value!r:<14}" + (f" {why}" if why else ""))
81
+ lines += [f"{name:<{width}} {value!r}" for name, value in self.measured.items()]
82
+ return "\n".join(lines)
83
+
84
+
85
+ def _from_core(params, profile, recall, threads, seed, built):
86
+ """A Plan from the C++ planner's (or a built index's) params and profile."""
87
+ measured = {}
88
+ if built and profile["recall_target"] > 0:
89
+ measured["recall at calibration"] = round(float(profile["recall_measured"]), 4)
90
+ if built and profile["machine"]:
91
+ measured["machine"] = profile["machine"]
92
+ return Plan(metric=params["metric"], quantization=params["quantization"], M=params["M"],
93
+ ef_construction=params["ef_construction"], nav_dim=params["nav_dim"],
94
+ fast_build=profile["fast_build"] if built else params["fast_build"],
95
+ refine=profile["refine"], fill_after=profile["fill_after"], half_vectors=params["half_vectors"],
96
+ rerank=profile["rerank"],
97
+ rerank_z=round(float(profile["rerank_z"]), 4), recall=round(float(profile["recall_target"] or recall), 4),
98
+ k=profile["k"], ef=profile["ef"] or None, prefetch=tuple(profile["prefetch"]) if built else None,
99
+ threads=threads, seed=seed, reasons=dict(profile["notes"]), measured=measured)
100
+
101
+
102
+ def _rows(data):
103
+ data = np.ascontiguousarray(data, dtype=np.float32)
104
+ if data.ndim != 2 or len(data) == 0 or data.shape[1] == 0:
105
+ raise ValueError("data must be a non-empty 2-D array (rows of vectors)")
106
+ return data
107
+
108
+
109
+ def plan(data, metric="l2", queries=None, **settings):
110
+ """The settings build() would use for `data`, with the reason for each, without building. Pass any
111
+ Plan setting to fix it; ef and prefetch stay None until build() measures them."""
112
+ p = Plan(metric=metric).replace(**settings) if settings else Plan(metric=metric)
113
+ core = _plan_auto(_rows(data), p.metric, p.M, p.ef_construction, p.k, p.recall, p.threads, 0, p.seed,
114
+ queries is not None, p.fixed())
115
+ out = _from_core(core["params"], core["profile"], p.recall, p.threads, p.seed, built=False)
116
+ # The planner records your settings as "set by you"; keep the measured ones you fixed too.
117
+ return dataclasses.replace(out, **{name: getattr(p, name) for name in ("ef", "prefetch") if name in p.fixed()})
118
+
119
+
120
+ def build(data, metric="l2", labels=None, plan=None, capacity=None, queries=None, **settings):
121
+ """An index of `data` (rows of vectors) configured for it and for this machine. Any Plan setting
122
+ passed here overrides the automatic choice; `plan` starts from a Plan you made with plan().
123
+ `capacity` is how many vectors the index can hold (default: the rows of `data`). `labels`
124
+ default to 0..n-1.
125
+
126
+ `queries`: a few hundred typical queries, if you have them. The beam width is then calibrated on
127
+ them, which is the most faithful; otherwise on rows of the data held out of the graph until
128
+ calibration is done, which is accurate when your queries look like your data and optimistic
129
+ when they don't (text queries against image vectors, say).
130
+
131
+ Returns an epzero.Index whose search() uses the calibrated settings by default; index.plan says
132
+ what was chosen and why. The settings are saved with the index."""
133
+ data = _rows(data)
134
+ p = plan if plan is not None else Plan(metric=metric)
135
+ if settings:
136
+ p = p.replace(**settings)
137
+ if queries is not None:
138
+ queries = np.ascontiguousarray(queries, dtype=np.float32)
139
+ if queries.ndim != 2 or queries.shape[1] != data.shape[1] or len(queries) == 0:
140
+ raise ValueError(f"queries must be a non-empty array of shape (n, {data.shape[1]})")
141
+ return _build_auto(data, labels, p.metric, p.M, p.ef_construction, p.k, p.recall, p.threads, capacity or 0, p.seed,
142
+ queries, p.fixed())
143
+
144
+
145
+ def _index_plan(index):
146
+ return _from_core(index.params, index.profile, 0.95, 0, index.params["seed"], built=True)
147
+
148
+
149
+ Index.plan = property(_index_plan, doc="How this index is configured, and why: an epzero.Plan (saved with it).")
epzero/haystack.py ADDED
@@ -0,0 +1,283 @@
1
+ """A Haystack document store and retriever backed by Epzero (pip install "epzero[haystack]").
2
+
3
+ from haystack import Pipeline
4
+ from epzero.haystack import EpzeroDocumentStore, EpzeroEmbeddingRetriever
5
+
6
+ store = EpzeroDocumentStore(metric="cosine")
7
+ store.write_documents(documents_with_embeddings)
8
+ pipeline = Pipeline()
9
+ pipeline.add_component("retriever", EpzeroEmbeddingRetriever(document_store=store, top_k=5))
10
+ pipeline.run({"retriever": {"query_embedding": embedding, "filters": {"field": "meta.lang", "operator": "==", "value": "en"}}})
11
+
12
+ Filters use Haystack's syntax. Each part of a filter runs in the engine (epzero.Metadata) when that
13
+ gives exactly Haystack's answer: ==, !=, in and not in on str, int, float and bool values, numeric
14
+ ranges, and AND, OR and NOT. Anything else (ranges on strings such as dates, None, fields that mix
15
+ kinds of value) is checked by Haystack's own reference implementation, so results and errors match.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+ from haystack import component, default_from_dict, default_to_dict
24
+ from haystack.dataclasses import Document
25
+ from haystack.document_stores.errors import DuplicateDocumentError
26
+ from haystack.document_stores.types import DuplicatePolicy
27
+ from haystack.utils.filters import document_matches_filter
28
+
29
+ from ._core import Index, Metadata
30
+
31
+
32
+ def _kind(value: Any) -> str | None:
33
+ if isinstance(value, bool):
34
+ return "bool"
35
+ if isinstance(value, (int, float)):
36
+ return "number"
37
+ if isinstance(value, str):
38
+ return "str"
39
+ return None
40
+
41
+
42
+ class _Fallback(Exception):
43
+ """This part of the filter must be evaluated by Haystack's reference implementation."""
44
+
45
+
46
+ class EpzeroDocumentStore:
47
+ """Documents with their embeddings in an Epzero index; metadata filters in the engine."""
48
+
49
+ def __init__(self, metric: str = "cosine", M: int = 16, ef_construction: int = 200, ef: int = 64):
50
+ if metric not in ("cosine", "ip", "l2"):
51
+ raise ValueError("metric must be 'cosine', 'ip' or 'l2'")
52
+ self.metric, self.M, self.ef_construction, self.ef = metric, M, ef_construction, ef
53
+ self._documents: dict[str, Document] = {}
54
+ self._labels: dict[str, int] = {} # document id -> label
55
+ self._ids: dict[int, str] = {} # label -> document id
56
+ self._next = 0
57
+ self._meta = Metadata()
58
+ self._kinds: dict[str, set[str]] = {} # per metadata field: the kinds of value it has held
59
+ self._index: Index | None = None
60
+ self._dim = 0
61
+ self._capacity = 1024
62
+ self._vectors = np.zeros((0, 0), dtype=np.float32)
63
+
64
+ # ---- DocumentStore protocol ----
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ return default_to_dict(self, metric=self.metric, M=self.M, ef_construction=self.ef_construction, ef=self.ef)
68
+
69
+ @classmethod
70
+ def from_dict(cls, data: dict[str, Any]) -> EpzeroDocumentStore:
71
+ return default_from_dict(cls, data)
72
+
73
+ def count_documents(self) -> int:
74
+ return len(self._documents)
75
+
76
+ def filter_documents(self, filters: dict[str, Any] | None = None) -> list[Document]:
77
+ if not filters:
78
+ return list(self._documents.values())
79
+ return [self._documents[self._ids[label]] for label in self._select(filters)]
80
+
81
+ def write_documents(self, documents: list[Document], policy: DuplicatePolicy = DuplicatePolicy.NONE) -> int:
82
+ if not isinstance(documents, list) or not all(isinstance(d, Document) for d in documents):
83
+ raise ValueError("write_documents takes a list of Documents")
84
+ if policy == DuplicatePolicy.NONE:
85
+ policy = DuplicatePolicy.FAIL
86
+ written = 0
87
+ for document in documents:
88
+ if document.id in self._documents:
89
+ if policy == DuplicatePolicy.SKIP:
90
+ continue
91
+ if policy == DuplicatePolicy.FAIL:
92
+ raise DuplicateDocumentError(f"ID '{document.id}' already exists.")
93
+ self._remove(document.id)
94
+ self._add(document)
95
+ written += 1
96
+ return written
97
+
98
+ def delete_documents(self, document_ids: list[str]) -> None:
99
+ for document_id in document_ids:
100
+ self._remove(document_id)
101
+
102
+ # ---- Retrieval ----
103
+
104
+ def embedding_retrieval(self, query_embedding: list[float], filters: dict[str, Any] | None = None, top_k: int = 10,
105
+ scale_score: bool = False, return_embedding: bool = False) -> list[Document]:
106
+ if self._index is None or top_k <= 0:
107
+ return []
108
+ query = np.asarray(query_embedding, dtype=np.float32)
109
+ if query.shape != (self._dim,):
110
+ raise ValueError(f"the query embedding must have {self._dim} dimensions")
111
+ allowed = None
112
+ if filters:
113
+ allowed = np.array([label for label in self._select(filters) if self._has_vector(label)], dtype=np.int64)
114
+ if len(allowed) == 0:
115
+ return []
116
+ ef = max(self.ef, top_k)
117
+ options = {} if allowed is None else {"filter": self._index.filter(allowed)}
118
+ labels, distances = self._index.search(query[None, :], k=top_k, ef=ef, **options)
119
+ out = []
120
+ for label, distance in zip(labels[0].tolist(), distances[0].tolist()):
121
+ if label < 0:
122
+ continue
123
+ document = self._documents[self._ids[label]]
124
+ score = self._score(distance, scale_score)
125
+ fields = {"score": score}
126
+ if not return_embedding:
127
+ fields["embedding"] = None
128
+ out.append(_replace(document, **fields))
129
+ return out
130
+
131
+ def _score(self, distance: float, scale: bool) -> float:
132
+ # Higher is more similar, as in Haystack's in-memory store: cosine similarity (scaled to 0-1 as
133
+ # (s + 1) / 2), the dot product (scaled by a sigmoid of s / 100), or minus the squared L2 distance
134
+ # (scaled as exp(-d)). Engine distances are 1 - dot for cosine and ip, and the squared L2 distance.
135
+ if self.metric == "l2":
136
+ return float(np.exp(-distance)) if scale else -float(distance)
137
+ similarity = 1.0 - float(distance)
138
+ if not scale:
139
+ return similarity
140
+ return (similarity + 1) / 2 if self.metric == "cosine" else float(1 / (1 + np.exp(-similarity / 100)))
141
+
142
+ # ---- Internals ----
143
+
144
+ def _has_vector(self, label: int) -> bool:
145
+ return self._documents[self._ids[label]].embedding is not None
146
+
147
+ def _add(self, document: Document) -> None:
148
+ label = self._next
149
+ self._next += 1
150
+ self._documents[document.id] = document
151
+ self._labels[document.id] = label
152
+ self._ids[label] = document.id
153
+ scalars = {}
154
+ for key, value in (document.meta or {}).items():
155
+ kind = _kind(value)
156
+ self._kinds.setdefault(key, set()).add(kind or "other")
157
+ if kind is not None:
158
+ scalars[key] = value
159
+ self._meta.set(label, {**scalars, "__doc": True}) # every document is in the metadata index
160
+ if document.embedding is None:
161
+ return
162
+ vector = np.asarray(document.embedding, dtype=np.float32)
163
+ if self._index is None:
164
+ self._dim = len(vector)
165
+ self._vectors = np.zeros((self._capacity, self._dim), dtype=np.float32)
166
+ self._index = self._new_index(self._capacity)
167
+ elif len(vector) != self._dim:
168
+ raise ValueError(f"expected {self._dim}-dimensional embeddings, got {len(vector)}")
169
+ self._grow(label + 1)
170
+ self._vectors[label] = vector
171
+ self._index.add(vector[None, :], np.array([label], dtype=np.int64))
172
+
173
+ def _remove(self, document_id: str) -> None:
174
+ label = self._labels.pop(document_id, None)
175
+ if label is None:
176
+ return
177
+ document = self._documents.pop(document_id)
178
+ self._ids.pop(label)
179
+ self._meta.remove(label)
180
+ if document.embedding is not None and self._index is not None:
181
+ self._index.remove(label)
182
+
183
+ def _new_index(self, capacity: int) -> Index:
184
+ return Index(dim=self._dim, metric=self.metric, max_elements=capacity, M=self.M, ef_construction=self.ef_construction)
185
+
186
+ def _grow(self, needed: int) -> None:
187
+ if needed <= self._capacity:
188
+ return
189
+ while self._capacity < needed:
190
+ self._capacity *= 2
191
+ vectors = np.zeros((self._capacity, self._dim), dtype=np.float32)
192
+ vectors[: len(self._vectors)] = self._vectors
193
+ self._vectors = vectors
194
+ self._index.reserve(self._capacity) # copies the nodes and keeps the graph
195
+
196
+ def _select(self, filters: dict[str, Any]) -> list[int]:
197
+ """Labels of the documents matching `filters`, in insertion order."""
198
+ if "operator" not in filters and "conditions" not in filters:
199
+ raise ValueError("Invalid filter syntax. See https://docs.haystack.deepset.ai/docs/metadata-filtering")
200
+ try:
201
+ labels = self._engine(filters)
202
+ return sorted(labels.tolist())
203
+ except _Fallback:
204
+ return sorted(label for label, document_id in self._ids.items()
205
+ if document_matches_filter(filters, self._documents[document_id]))
206
+
207
+ def _all(self) -> np.ndarray:
208
+ return self._meta.select({"__doc": True})
209
+
210
+ def _engine(self, f: dict[str, Any]) -> np.ndarray:
211
+ operator = f.get("operator")
212
+ if "conditions" in f:
213
+ parts = [self._engine(c) for c in f["conditions"]]
214
+ if operator == "AND":
215
+ result = self._all() if not parts else parts[0]
216
+ for part in parts[1:]:
217
+ result = np.intersect1d(result, part, assume_unique=True)
218
+ return result
219
+ if operator == "OR":
220
+ return np.unique(np.concatenate(parts)) if parts else np.array([], dtype=np.int64)
221
+ if operator == "NOT":
222
+ inner = self._engine({"operator": "AND", "conditions": f["conditions"]})
223
+ return np.setdiff1d(self._all(), inner, assume_unique=True)
224
+ raise _Fallback
225
+ field, value = f.get("field", ""), f.get("value")
226
+ if not field.startswith("meta.") or "." in field[5:]:
227
+ raise _Fallback
228
+ key = field[5:]
229
+ kinds = self._kinds.get(key, set())
230
+ if "other" in kinds or {"bool", "number"} <= kinds:
231
+ raise _Fallback
232
+ if operator in ("==", "!="):
233
+ if _kind(value) is None or (_kind(value) == "bool" and "number" in kinds) or (_kind(value) == "number" and "bool" in kinds):
234
+ raise _Fallback
235
+ equal = self._meta.select({key: value})
236
+ return equal if operator == "==" else np.setdiff1d(self._all(), equal, assume_unique=True)
237
+ if operator in ("in", "not in"):
238
+ if not isinstance(value, list) or not all(_kind(v) is not None for v in value):
239
+ raise _Fallback
240
+ if any(_kind(v) == "bool" for v in value) and "number" in kinds or any(_kind(v) == "number" for v in value) and "bool" in kinds:
241
+ raise _Fallback
242
+ inside = self._meta.select({key: value}) if value else np.array([], dtype=np.int64)
243
+ return inside if operator == "in" else np.setdiff1d(self._all(), inside, assume_unique=True)
244
+ if operator in (">", ">=", "<", "<="):
245
+ if _kind(value) != "number" or kinds - {"number"}:
246
+ raise _Fallback # strings (dates) and others compare by Haystack's rules
247
+ op = {">": "$gt", ">=": "$gte", "<": "$lt", "<=": "$lte"}[operator]
248
+ return self._meta.select({key: {op: value}})
249
+ raise _Fallback
250
+
251
+
252
+ def _replace(document: Document, **fields: Any) -> Document:
253
+ data = {name: getattr(document, name) for name in ("id", "content", "blob", "meta", "score", "embedding", "sparse_embedding")}
254
+ data.update(fields)
255
+ return Document(**data)
256
+
257
+
258
+ @component
259
+ class EpzeroEmbeddingRetriever:
260
+ """Retrieves the documents nearest a query embedding from an EpzeroDocumentStore."""
261
+
262
+ def __init__(self, document_store: EpzeroDocumentStore, filters: dict[str, Any] | None = None, top_k: int = 10,
263
+ scale_score: bool = False, return_embedding: bool = False):
264
+ if not isinstance(document_store, EpzeroDocumentStore):
265
+ raise ValueError("document_store must be an EpzeroDocumentStore")
266
+ self.document_store, self.filters, self.top_k = document_store, filters, top_k
267
+ self.scale_score, self.return_embedding = scale_score, return_embedding
268
+
269
+ def to_dict(self) -> dict[str, Any]:
270
+ return default_to_dict(self, document_store=self.document_store.to_dict(), filters=self.filters, top_k=self.top_k,
271
+ scale_score=self.scale_score, return_embedding=self.return_embedding)
272
+
273
+ @classmethod
274
+ def from_dict(cls, data: dict[str, Any]) -> EpzeroEmbeddingRetriever:
275
+ data["init_parameters"]["document_store"] = EpzeroDocumentStore.from_dict(data["init_parameters"]["document_store"])
276
+ return default_from_dict(cls, data)
277
+
278
+ @component.output_types(documents=list[Document])
279
+ def run(self, query_embedding: list[float], filters: dict[str, Any] | None = None, top_k: int | None = None) -> dict[str, list[Document]]:
280
+ documents = self.document_store.embedding_retrieval(
281
+ query_embedding, filters=filters if filters is not None else self.filters, top_k=top_k or self.top_k,
282
+ scale_score=self.scale_score, return_embedding=self.return_embedding)
283
+ return {"documents": documents}