langchain-dynamic-tools-middleware 0.2.0__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.
- langchain_dynamic_tools/__init__.py +26 -0
- langchain_dynamic_tools/_embeddings.py +306 -0
- langchain_dynamic_tools/_index.py +365 -0
- langchain_dynamic_tools/_middleware.py +298 -0
- langchain_dynamic_tools/py.typed +0 -0
- langchain_dynamic_tools_middleware-0.2.0.dist-info/METADATA +454 -0
- langchain_dynamic_tools_middleware-0.2.0.dist-info/RECORD +9 -0
- langchain_dynamic_tools_middleware-0.2.0.dist-info/WHEEL +4 -0
- langchain_dynamic_tools_middleware-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""LangChain middleware that picks the right tools for each step with local hybrid vector search."""
|
|
2
|
+
|
|
3
|
+
from langchain_dynamic_tools._embeddings import (
|
|
4
|
+
DefaultDenseEmbedder,
|
|
5
|
+
DefaultSparseEmbedder,
|
|
6
|
+
DenseEmbedder,
|
|
7
|
+
LangChainDenseEmbedder,
|
|
8
|
+
SparseEmbedder,
|
|
9
|
+
render_tool_text,
|
|
10
|
+
)
|
|
11
|
+
from langchain_dynamic_tools._index import ToolVectorIndex
|
|
12
|
+
from langchain_dynamic_tools._middleware import DynamicToolSelectorMiddleware
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DefaultDenseEmbedder",
|
|
18
|
+
"DefaultSparseEmbedder",
|
|
19
|
+
"DenseEmbedder",
|
|
20
|
+
"DynamicToolSelectorMiddleware",
|
|
21
|
+
"LangChainDenseEmbedder",
|
|
22
|
+
"SparseEmbedder",
|
|
23
|
+
"ToolVectorIndex",
|
|
24
|
+
"render_tool_text",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Embedding protocols, default local embedders, and tool text rendering.
|
|
2
|
+
|
|
3
|
+
The middleware treats embedders as simple protocols so you can plug in any
|
|
4
|
+
embedding backend. The defaults wrap the local models that ship with zvec's
|
|
5
|
+
extension module: all-MiniLM-L6-v2 for dense vectors and SPLADE for sparse
|
|
6
|
+
vectors. Both run on your machine, no API keys, no network calls after the
|
|
7
|
+
first model download.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from langchain_core.embeddings import Embeddings
|
|
16
|
+
from langchain_core.tools import BaseTool
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
SparseEncoding = Literal["query", "document"]
|
|
21
|
+
|
|
22
|
+
_INSTALL_HINT = (
|
|
23
|
+
"The default embedders need the sentence-transformers package. Install it "
|
|
24
|
+
"with: pip install 'langchain-dynamic-tools-middleware[local]'"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class DenseEmbedder(Protocol):
|
|
30
|
+
"""Anything that maps text to a fixed-size float vector."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def dimension(self) -> int:
|
|
34
|
+
"""The dimension of the vectors returned by embed()."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def embed(self, text: str) -> list[float]:
|
|
38
|
+
"""Return the dense embedding for a piece of text."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class SparseEmbedder(Protocol):
|
|
44
|
+
"""Anything that maps text to a sparse {dimension index: weight} mapping."""
|
|
45
|
+
|
|
46
|
+
def embed_document(self, text: str) -> dict[int, float]:
|
|
47
|
+
"""Return the sparse embedding used when indexing a document."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def embed_query(self, text: str) -> dict[int, float]:
|
|
51
|
+
"""Return the sparse embedding used when searching with a query."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DefaultDenseEmbedder:
|
|
56
|
+
"""Dense embedder backed by zvec's all-MiniLM-L6-v2 wrapper (384 dims).
|
|
57
|
+
|
|
58
|
+
The model loads lazily on the first embed call so that constructing the
|
|
59
|
+
middleware stays cheap. Works with any keyword arguments accepted by
|
|
60
|
+
``zvec.extension.DefaultLocalDenseEmbedding``.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
```python
|
|
64
|
+
from langchain_dynamic_tools import DefaultDenseEmbedder
|
|
65
|
+
|
|
66
|
+
embedder = DefaultDenseEmbedder()
|
|
67
|
+
vector = embedder.embed("Send an email to the team")
|
|
68
|
+
len(vector) # 384
|
|
69
|
+
```
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, **model_kwargs: Any) -> None:
|
|
73
|
+
"""Store model kwargs; the model itself loads on first use."""
|
|
74
|
+
self._model_kwargs = model_kwargs
|
|
75
|
+
self._model: Any | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def dimension(self) -> int:
|
|
79
|
+
"""Always 384, matching the all-MiniLM-L6-v2 model."""
|
|
80
|
+
return 384
|
|
81
|
+
|
|
82
|
+
def _load_model(self) -> Any:
|
|
83
|
+
if self._model is None:
|
|
84
|
+
try:
|
|
85
|
+
from zvec.extension import DefaultLocalDenseEmbedding
|
|
86
|
+
except ImportError as exc: # pragma: no cover - depends on zvec build
|
|
87
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
88
|
+
try:
|
|
89
|
+
self._model = DefaultLocalDenseEmbedding(**self._model_kwargs)
|
|
90
|
+
except ImportError as exc:
|
|
91
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
92
|
+
return self._model
|
|
93
|
+
|
|
94
|
+
def embed(self, text: str) -> list[float]:
|
|
95
|
+
"""Embed one piece of text into a 384-dimensional float vector."""
|
|
96
|
+
if not isinstance(text, str):
|
|
97
|
+
msg = f"Expected text to be str, got {type(text).__name__}"
|
|
98
|
+
raise TypeError(msg)
|
|
99
|
+
if not text.strip():
|
|
100
|
+
msg = "Text cannot be empty or whitespace only"
|
|
101
|
+
raise ValueError(msg)
|
|
102
|
+
return [float(value) for value in self._load_model().embed(text)]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DefaultSparseEmbedder:
|
|
106
|
+
"""Sparse embedder backed by zvec's SPLADE wrapper.
|
|
107
|
+
|
|
108
|
+
SPLADE produces lexical sparse vectors that complement the dense model:
|
|
109
|
+
dense catches meaning, sparse catches exact terms like function names.
|
|
110
|
+
Query and document sides use different encodings, as SPLADE expects.
|
|
111
|
+
Both share one underlying model through zvec's class-level cache.
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
```python
|
|
115
|
+
from langchain_dynamic_tools import DefaultSparseEmbedder
|
|
116
|
+
|
|
117
|
+
embedder = DefaultSparseEmbedder()
|
|
118
|
+
vector = embedder.embed_query("query the sql database")
|
|
119
|
+
# {10412: 0.83, 8871: 1.02, ...}
|
|
120
|
+
```
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, **model_kwargs: Any) -> None:
|
|
124
|
+
"""Store model kwargs; models load lazily on first use."""
|
|
125
|
+
self._model_kwargs = model_kwargs
|
|
126
|
+
self._document_model: Any | None = None
|
|
127
|
+
self._query_model: Any | None = None
|
|
128
|
+
|
|
129
|
+
def _build_model(self, encoding_type: SparseEncoding) -> Any:
|
|
130
|
+
try:
|
|
131
|
+
from zvec.extension import DefaultLocalSparseEmbedding
|
|
132
|
+
except ImportError as exc: # pragma: no cover - depends on zvec build
|
|
133
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
134
|
+
try:
|
|
135
|
+
return DefaultLocalSparseEmbedding(encoding_type=encoding_type, **self._model_kwargs)
|
|
136
|
+
except ImportError as exc:
|
|
137
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
138
|
+
|
|
139
|
+
def _load_document_model(self) -> Any:
|
|
140
|
+
if self._document_model is None:
|
|
141
|
+
self._document_model = self._build_model("document")
|
|
142
|
+
return self._document_model
|
|
143
|
+
|
|
144
|
+
def _load_query_model(self) -> Any:
|
|
145
|
+
if self._query_model is None:
|
|
146
|
+
self._query_model = self._build_model("query")
|
|
147
|
+
return self._query_model
|
|
148
|
+
|
|
149
|
+
def embed_document(self, text: str) -> dict[int, float]:
|
|
150
|
+
"""Embed text with the document-side encoding, used when indexing."""
|
|
151
|
+
if not isinstance(text, str):
|
|
152
|
+
msg = f"Expected text to be str, got {type(text).__name__}"
|
|
153
|
+
raise TypeError(msg)
|
|
154
|
+
if not text.strip():
|
|
155
|
+
msg = "Text cannot be empty or whitespace only"
|
|
156
|
+
raise ValueError(msg)
|
|
157
|
+
raw = self._load_document_model().embed(text)
|
|
158
|
+
return {int(index): float(weight) for index, weight in raw.items()}
|
|
159
|
+
|
|
160
|
+
def embed_query(self, text: str) -> dict[int, float]:
|
|
161
|
+
"""Embed text with the query-side encoding, used when searching."""
|
|
162
|
+
if not isinstance(text, str):
|
|
163
|
+
msg = f"Expected text to be str, got {type(text).__name__}"
|
|
164
|
+
raise TypeError(msg)
|
|
165
|
+
if not text.strip():
|
|
166
|
+
msg = "Text cannot be empty or whitespace only"
|
|
167
|
+
raise ValueError(msg)
|
|
168
|
+
raw = self._load_query_model().embed(text)
|
|
169
|
+
return {int(index): float(weight) for index, weight in raw.items()}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class LangChainDenseEmbedder:
|
|
173
|
+
"""Dense embedder backed by any standard LangChain Embeddings object.
|
|
174
|
+
|
|
175
|
+
LangChain's Embeddings interface (``embed_query``/``embed_documents``)
|
|
176
|
+
does not match the DenseEmbedder protocol, so this adapter bridges the
|
|
177
|
+
two: ``embed()`` delegates to ``embed_query()`` (tool texts are short,
|
|
178
|
+
so the query/document distinction does not matter here), and
|
|
179
|
+
``dimension`` is the explicit value when given, otherwise probed once
|
|
180
|
+
from the first embedding and cached. The probe costs no extra API call
|
|
181
|
+
when the first use is ``embed()`` rather than ``dimension``.
|
|
182
|
+
|
|
183
|
+
``ToolVectorIndex`` applies this adapter automatically, so you can pass
|
|
184
|
+
e.g. ``OpenAIEmbeddings`` directly as ``dense_embedder``:
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
```python
|
|
188
|
+
from langchain_openai import OpenAIEmbeddings
|
|
189
|
+
from langchain_dynamic_tools import DynamicToolSelectorMiddleware
|
|
190
|
+
|
|
191
|
+
tool_router = DynamicToolSelectorMiddleware(
|
|
192
|
+
tools=all_tools,
|
|
193
|
+
top_k=4,
|
|
194
|
+
dense_embedder=OpenAIEmbeddings(model="text-embedding-3-small"),
|
|
195
|
+
dense_dim=1536,
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self, embeddings: Embeddings, dimension: int | None = None) -> None:
|
|
201
|
+
"""Wrap a LangChain Embeddings object, optionally fixing its dimension."""
|
|
202
|
+
if dimension is not None and dimension < 1:
|
|
203
|
+
msg = f"dimension must be >= 1, got {dimension}"
|
|
204
|
+
raise ValueError(msg)
|
|
205
|
+
self._embeddings = embeddings
|
|
206
|
+
self._explicit_dimension = dimension
|
|
207
|
+
self._probed_dimension: int | None = None
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def dimension(self) -> int:
|
|
211
|
+
"""Explicit dimension, or the probed embedding length (cached)."""
|
|
212
|
+
if self._explicit_dimension is not None:
|
|
213
|
+
return self._explicit_dimension
|
|
214
|
+
if self._probed_dimension is None:
|
|
215
|
+
self._probed_dimension = len(self._embeddings.embed_query("dimension probe"))
|
|
216
|
+
return self._probed_dimension
|
|
217
|
+
|
|
218
|
+
def embed(self, text: str) -> list[float]:
|
|
219
|
+
"""Embed one piece of text via the wrapped model's embed_query()."""
|
|
220
|
+
if not isinstance(text, str):
|
|
221
|
+
msg = f"Expected text to be str, got {type(text).__name__}"
|
|
222
|
+
raise TypeError(msg)
|
|
223
|
+
if not text.strip():
|
|
224
|
+
msg = "Text cannot be empty or whitespace only"
|
|
225
|
+
raise ValueError(msg)
|
|
226
|
+
vector = [float(value) for value in self._embeddings.embed_query(text)]
|
|
227
|
+
if self._explicit_dimension is not None:
|
|
228
|
+
if len(vector) != self._explicit_dimension:
|
|
229
|
+
msg = (
|
|
230
|
+
f"Embedding dimension mismatch: expected {self._explicit_dimension}, "
|
|
231
|
+
f"got {len(vector)}"
|
|
232
|
+
)
|
|
233
|
+
raise ValueError(msg)
|
|
234
|
+
elif self._probed_dimension is None:
|
|
235
|
+
self._probed_dimension = len(vector)
|
|
236
|
+
return vector
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def render_tool_text(tool: BaseTool) -> str:
|
|
240
|
+
"""Render a tool as one compact string for embedding and storage.
|
|
241
|
+
|
|
242
|
+
The string carries the tool name, its description, and its parameters with
|
|
243
|
+
types, required flags, and per-parameter descriptions. This is the exact
|
|
244
|
+
lexical material a retrieval step needs to tell tools apart.
|
|
245
|
+
|
|
246
|
+
Example:
|
|
247
|
+
```python
|
|
248
|
+
from langchain_core.tools import tool
|
|
249
|
+
from langchain_dynamic_tools import render_tool_text
|
|
250
|
+
|
|
251
|
+
@tool
|
|
252
|
+
def get_weather(city: str) -> str:
|
|
253
|
+
"Get the current weather for a city."
|
|
254
|
+
...
|
|
255
|
+
|
|
256
|
+
print(render_tool_text(get_weather))
|
|
257
|
+
# get_weather: Get the current weather for a city.
|
|
258
|
+
# Parameters: city (string, required)
|
|
259
|
+
```
|
|
260
|
+
"""
|
|
261
|
+
description = (tool.description or "No description provided.").strip()
|
|
262
|
+
parts = [f"{tool.name}: {description}"]
|
|
263
|
+
parameters = _render_parameters(tool)
|
|
264
|
+
if parameters:
|
|
265
|
+
parts.append(f"Parameters: {parameters}")
|
|
266
|
+
return " ".join(parts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _render_parameters(tool: BaseTool) -> str:
|
|
270
|
+
"""Format a tool's arguments as a short readable list.
|
|
271
|
+
|
|
272
|
+
Handles both shapes ``tool.args`` produces across langchain-core
|
|
273
|
+
versions: the flat ``{param: {type, description, default}}`` mapping of
|
|
274
|
+
current versions, and the nested JSON schema with a "properties" key.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
args = tool.args
|
|
278
|
+
except Exception: # noqa: BLE001 - a broken args schema must not break indexing
|
|
279
|
+
logger.debug("Could not read args schema for tool %r", tool.name)
|
|
280
|
+
return ""
|
|
281
|
+
if not isinstance(args, dict) or not args:
|
|
282
|
+
return ""
|
|
283
|
+
|
|
284
|
+
if isinstance(args.get("properties"), dict):
|
|
285
|
+
properties = args["properties"]
|
|
286
|
+
required_names = set(args.get("required") or [])
|
|
287
|
+
nested = True
|
|
288
|
+
else:
|
|
289
|
+
properties = args
|
|
290
|
+
required_names = set()
|
|
291
|
+
nested = False
|
|
292
|
+
|
|
293
|
+
rendered = []
|
|
294
|
+
for name, spec in properties.items():
|
|
295
|
+
if not isinstance(spec, dict):
|
|
296
|
+
continue
|
|
297
|
+
type_name = str(spec.get("type", "any"))
|
|
298
|
+
# Flat mapping (current langchain-core): optional parameters carry a default.
|
|
299
|
+
is_required = name in required_names if nested else "default" not in spec
|
|
300
|
+
flag = "required" if is_required else "optional"
|
|
301
|
+
piece = f"{name} ({type_name}, {flag})"
|
|
302
|
+
param_description = str(spec.get("description", "")).strip()
|
|
303
|
+
if param_description:
|
|
304
|
+
piece = f"{piece}: {param_description}"
|
|
305
|
+
rendered.append(piece)
|
|
306
|
+
return "; ".join(rendered)
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""The zvec-backed index that stores tool embeddings and answers hybrid searches.
|
|
2
|
+
|
|
3
|
+
Every tool becomes one zvec document with a dense vector, a sparse vector, and
|
|
4
|
+
the rendered tool text. A search embeds the query both ways, runs both vectors
|
|
5
|
+
through a single zvec query, and fuses the two ranked lists with reciprocal
|
|
6
|
+
rank fusion. zvec is an in-process engine, so the whole index lives on local
|
|
7
|
+
disk and searches take milliseconds with no server to run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
import zvec
|
|
18
|
+
|
|
19
|
+
from langchain_dynamic_tools._embeddings import (
|
|
20
|
+
DefaultDenseEmbedder,
|
|
21
|
+
DefaultSparseEmbedder,
|
|
22
|
+
DenseEmbedder,
|
|
23
|
+
LangChainDenseEmbedder,
|
|
24
|
+
SparseEmbedder,
|
|
25
|
+
render_tool_text,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
|
|
31
|
+
from langchain_core.embeddings import Embeddings
|
|
32
|
+
from langchain_core.tools import BaseTool
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
DENSE_FIELD = "denseEmbedding"
|
|
37
|
+
SPARSE_FIELD = "sparseEmbedding"
|
|
38
|
+
TEXT_FIELD = "text"
|
|
39
|
+
DEFAULT_PATH = ".dynamicToolsMiddleware"
|
|
40
|
+
DEFAULT_COLLECTION_NAME = "VectorSearch"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_reranker() -> Any:
|
|
44
|
+
"""Build the default reciprocal rank fusion reranker from zvec."""
|
|
45
|
+
from zvec.extension import RrfReRanker
|
|
46
|
+
|
|
47
|
+
return RrfReRanker(rank_constant=60)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _schema_vectors(schema: Any) -> list[Any]:
|
|
51
|
+
"""Return the vector schemas of a collection schema as a list."""
|
|
52
|
+
vectors = schema.vectors
|
|
53
|
+
if isinstance(vectors, (list, tuple)):
|
|
54
|
+
return list(vectors)
|
|
55
|
+
return [vectors]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _stored_dense_dimension(collection: Any) -> int | None:
|
|
59
|
+
"""Read the dense vector dimension from an opened collection, if present."""
|
|
60
|
+
for vector in _schema_vectors(collection.schema):
|
|
61
|
+
if vector.name == DENSE_FIELD:
|
|
62
|
+
dimension = getattr(vector, "dimension", None)
|
|
63
|
+
if dimension:
|
|
64
|
+
return int(dimension)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ToolVectorIndex:
|
|
69
|
+
"""A local zvec collection holding hybrid embeddings of tool definitions.
|
|
70
|
+
|
|
71
|
+
The index is the storage and retrieval half of the middleware. Give it
|
|
72
|
+
tools, it embeds and upserts them once; give it a query, it returns the
|
|
73
|
+
most similar tool names. Syncs are incremental: unchanged tools are never
|
|
74
|
+
re-embedded, so restarting an application with the same tool set costs
|
|
75
|
+
nothing beyond opening the collection.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
```python
|
|
79
|
+
from langchain_dynamic_tools import ToolVectorIndex
|
|
80
|
+
|
|
81
|
+
index = ToolVectorIndex(tools=[get_weather, send_email])
|
|
82
|
+
index.search("what is the weather in tokyo", top_k=2)
|
|
83
|
+
# [("get_weather", 0.93), ("convert_currency", 0.12)]
|
|
84
|
+
index.close()
|
|
85
|
+
```
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
tools: Sequence[BaseTool],
|
|
91
|
+
*,
|
|
92
|
+
path: str = DEFAULT_PATH,
|
|
93
|
+
collection_name: str = DEFAULT_COLLECTION_NAME,
|
|
94
|
+
dense_embedder: DenseEmbedder | Embeddings | None = None,
|
|
95
|
+
sparse_embedder: SparseEmbedder | None = None,
|
|
96
|
+
dense_dim: int | None = None,
|
|
97
|
+
reranker: Any | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Open (or create) the collection and index the given tools.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
tools: Tools to index right away. May be empty for an index you
|
|
103
|
+
fill in later with sync().
|
|
104
|
+
path: Filesystem path of the zvec collection.
|
|
105
|
+
collection_name: Name recorded in the collection schema.
|
|
106
|
+
dense_embedder: Maps text to dense vectors. Defaults to the local
|
|
107
|
+
all-MiniLM-L6-v2 model (384 dims). A standard LangChain
|
|
108
|
+
Embeddings object (e.g. OpenAIEmbeddings) is adapted
|
|
109
|
+
automatically.
|
|
110
|
+
sparse_embedder: Maps text to sparse vectors. Defaults to the
|
|
111
|
+
local SPLADE model.
|
|
112
|
+
dense_dim: Dense vector dimension. Defaults to the embedder's
|
|
113
|
+
dimension. If an existing collection was built with a
|
|
114
|
+
different dimension, it is rebuilt.
|
|
115
|
+
reranker: zvec reranker that fuses the dense and sparse result
|
|
116
|
+
lists. Defaults to RrfReRanker(rank_constant=60).
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: If path is empty or dense_dim is less than 1.
|
|
120
|
+
ValueError: If tools contain duplicate names or empty renders.
|
|
121
|
+
"""
|
|
122
|
+
if not isinstance(path, str) or not path.strip():
|
|
123
|
+
msg = "path must be a non-empty string"
|
|
124
|
+
raise ValueError(msg)
|
|
125
|
+
dense: DenseEmbedder
|
|
126
|
+
if dense_embedder is None:
|
|
127
|
+
dense = DefaultDenseEmbedder()
|
|
128
|
+
elif isinstance(dense_embedder, DenseEmbedder):
|
|
129
|
+
dense = dense_embedder
|
|
130
|
+
else:
|
|
131
|
+
from langchain_core.embeddings import Embeddings
|
|
132
|
+
|
|
133
|
+
if not isinstance(dense_embedder, Embeddings):
|
|
134
|
+
msg = (
|
|
135
|
+
"dense_embedder must expose .dimension and .embed(text), or be a "
|
|
136
|
+
f"standard LangChain Embeddings object; got {type(dense_embedder).__name__}"
|
|
137
|
+
)
|
|
138
|
+
raise TypeError(msg)
|
|
139
|
+
# Accept standard LangChain embeddings (e.g. OpenAIEmbeddings)
|
|
140
|
+
# directly; an explicit dense_dim doubles as the adapter's
|
|
141
|
+
# dimension so no probing API call is needed.
|
|
142
|
+
dense = LangChainDenseEmbedder(dense_embedder, dimension=dense_dim)
|
|
143
|
+
self._dense = dense
|
|
144
|
+
self._sparse = sparse_embedder if sparse_embedder is not None else DefaultSparseEmbedder()
|
|
145
|
+
self._dense_dim = dense_dim if dense_dim is not None else self._dense.dimension
|
|
146
|
+
if self._dense_dim < 1:
|
|
147
|
+
msg = f"dense_dim must be >= 1, got {self._dense_dim}"
|
|
148
|
+
raise ValueError(msg)
|
|
149
|
+
self._path = path
|
|
150
|
+
self._collection_name = collection_name
|
|
151
|
+
self._reranker = reranker if reranker is not None else _default_reranker()
|
|
152
|
+
self._lock = threading.Lock()
|
|
153
|
+
self._indexed: dict[str, str] = {}
|
|
154
|
+
self._collection = self._open_or_create()
|
|
155
|
+
if tools:
|
|
156
|
+
self.sync(tools)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def path(self) -> str:
|
|
160
|
+
"""Filesystem path of the underlying zvec collection."""
|
|
161
|
+
return self._path
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def indexed_names(self) -> set[str]:
|
|
165
|
+
"""Names of the tools this instance has synced into the collection."""
|
|
166
|
+
return set(self._indexed)
|
|
167
|
+
|
|
168
|
+
def _build_schema(self) -> Any:
|
|
169
|
+
"""Build the collection schema: one dense and one sparse vector field."""
|
|
170
|
+
return zvec.CollectionSchema(
|
|
171
|
+
name=self._collection_name,
|
|
172
|
+
vectors=[
|
|
173
|
+
zvec.VectorSchema(
|
|
174
|
+
name=DENSE_FIELD,
|
|
175
|
+
data_type=zvec.DataType.VECTOR_FP32,
|
|
176
|
+
dimension=self._dense_dim,
|
|
177
|
+
index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP),
|
|
178
|
+
),
|
|
179
|
+
zvec.VectorSchema(
|
|
180
|
+
name=SPARSE_FIELD,
|
|
181
|
+
data_type=zvec.DataType.SPARSE_VECTOR_FP32,
|
|
182
|
+
index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP),
|
|
183
|
+
),
|
|
184
|
+
],
|
|
185
|
+
fields=[zvec.FieldSchema(name=TEXT_FIELD, data_type=zvec.DataType.STRING)],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _open_or_create(self) -> Any:
|
|
189
|
+
"""Open the collection at path, creating it if needed.
|
|
190
|
+
|
|
191
|
+
An existing collection built with a different dense dimension is
|
|
192
|
+
destroyed and rebuilt, since its vectors cannot be reused. A path
|
|
193
|
+
that exists but holds no usable collection raises a clear error
|
|
194
|
+
rather than deleting anything, except for one case: an empty
|
|
195
|
+
leftover directory holds no data, so the stub is removed and the
|
|
196
|
+
collection created fresh.
|
|
197
|
+
"""
|
|
198
|
+
schema = self._build_schema()
|
|
199
|
+
collection: Any | None = None
|
|
200
|
+
if os.path.exists(self._path):
|
|
201
|
+
try:
|
|
202
|
+
collection = zvec.open(self._path)
|
|
203
|
+
except Exception as open_error:
|
|
204
|
+
if os.path.isdir(self._path) and not os.listdir(self._path):
|
|
205
|
+
os.rmdir(self._path)
|
|
206
|
+
return zvec.create_and_open(path=self._path, schema=schema)
|
|
207
|
+
try:
|
|
208
|
+
collection = zvec.create_and_open(path=self._path, schema=schema)
|
|
209
|
+
except Exception:
|
|
210
|
+
msg = (
|
|
211
|
+
f"Path {self._path!r} exists but is not a usable zvec "
|
|
212
|
+
f"collection (open failed with: {open_error}). Remove the "
|
|
213
|
+
f"path or choose a different one."
|
|
214
|
+
)
|
|
215
|
+
raise RuntimeError(msg) from open_error
|
|
216
|
+
if collection is None:
|
|
217
|
+
return zvec.create_and_open(path=self._path, schema=schema)
|
|
218
|
+
stored_dimension = _stored_dense_dimension(collection)
|
|
219
|
+
if stored_dimension is not None and stored_dimension != self._dense_dim:
|
|
220
|
+
logger.info(
|
|
221
|
+
"Collection at %r has dense dimension %d but the embedder produces %d; "
|
|
222
|
+
"rebuilding the collection",
|
|
223
|
+
self._path,
|
|
224
|
+
stored_dimension,
|
|
225
|
+
self._dense_dim,
|
|
226
|
+
)
|
|
227
|
+
collection.destroy()
|
|
228
|
+
return zvec.create_and_open(path=self._path, schema=schema)
|
|
229
|
+
return collection
|
|
230
|
+
|
|
231
|
+
def _stored_texts(self) -> dict[str, str]:
|
|
232
|
+
"""Read the id and text of every document currently stored."""
|
|
233
|
+
texts: dict[str, str] = {}
|
|
234
|
+
with self._collection.iter_docs(include_vector=False) as docs:
|
|
235
|
+
for doc in docs:
|
|
236
|
+
texts[doc.id] = doc.field(TEXT_FIELD) or ""
|
|
237
|
+
return texts
|
|
238
|
+
|
|
239
|
+
def _make_doc(self, name: str, text: str) -> Any:
|
|
240
|
+
"""Build one zvec document for a tool from both embeddings."""
|
|
241
|
+
return zvec.Doc(
|
|
242
|
+
id=name,
|
|
243
|
+
vectors={
|
|
244
|
+
DENSE_FIELD: self._dense.embed(text),
|
|
245
|
+
SPARSE_FIELD: self._sparse.embed_document(text),
|
|
246
|
+
},
|
|
247
|
+
fields={TEXT_FIELD: text},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def sync(self, tools: Sequence[BaseTool], *, prune: bool = True) -> None:
|
|
251
|
+
"""Bring the collection in line with the given tools.
|
|
252
|
+
|
|
253
|
+
New and changed tools are embedded and upserted. With prune=True,
|
|
254
|
+
stored documents whose tool is absent from the list are deleted.
|
|
255
|
+
Unchanged tools are left alone, so re-syncing the same tool set
|
|
256
|
+
embeds nothing.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
tools: The tools the collection should hold afterwards.
|
|
260
|
+
prune: Also delete stored tools missing from the list. Set to
|
|
261
|
+
False when adding tools found in a request on the fly.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ValueError: If two tools share a name or one has an empty name.
|
|
265
|
+
"""
|
|
266
|
+
wanted: dict[str, str] = {}
|
|
267
|
+
for tool in tools:
|
|
268
|
+
if not tool.name.strip():
|
|
269
|
+
msg = f"Tool {tool.name!r} has an empty name after stripping whitespace"
|
|
270
|
+
raise ValueError(msg)
|
|
271
|
+
text = render_tool_text(tool)
|
|
272
|
+
if tool.name in wanted:
|
|
273
|
+
msg = f"Duplicate tool name {tool.name!r}; tool names must be unique"
|
|
274
|
+
raise ValueError(msg)
|
|
275
|
+
wanted[tool.name] = text
|
|
276
|
+
|
|
277
|
+
with self._lock:
|
|
278
|
+
collection = self._collection
|
|
279
|
+
if collection is None:
|
|
280
|
+
msg = "The index is closed; create a new ToolVectorIndex to reuse it"
|
|
281
|
+
raise RuntimeError(msg)
|
|
282
|
+
existing = self._stored_texts()
|
|
283
|
+
to_delete = [name for name in existing if prune and name not in wanted]
|
|
284
|
+
to_upsert = [
|
|
285
|
+
(name, text)
|
|
286
|
+
for name, text in wanted.items()
|
|
287
|
+
if name not in existing or existing[name] != text
|
|
288
|
+
]
|
|
289
|
+
if to_delete:
|
|
290
|
+
collection.delete(to_delete)
|
|
291
|
+
for name in to_delete:
|
|
292
|
+
self._indexed.pop(name, None)
|
|
293
|
+
if to_upsert:
|
|
294
|
+
collection.upsert([self._make_doc(name, text) for name, text in to_upsert])
|
|
295
|
+
for name, text in to_upsert:
|
|
296
|
+
self._indexed[name] = text
|
|
297
|
+
collection.flush()
|
|
298
|
+
if to_upsert or to_delete:
|
|
299
|
+
logger.debug(
|
|
300
|
+
"Synced tool index: %d upsert(s), %d deletion(s)",
|
|
301
|
+
len(to_upsert),
|
|
302
|
+
len(to_delete),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def search(self, query: str, top_k: int) -> list[tuple[str, float]]:
|
|
306
|
+
"""Return the top_k most similar tools as (name, score) pairs, best first.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
query: Natural language text to search with.
|
|
310
|
+
top_k: Maximum number of tools to return.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
A list of (tool name, fused score) pairs, highest score first.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
TypeError: If query is not a string.
|
|
317
|
+
ValueError: If top_k is less than 1.
|
|
318
|
+
RuntimeError: If the index has been closed.
|
|
319
|
+
"""
|
|
320
|
+
if not isinstance(query, str):
|
|
321
|
+
msg = f"Expected query to be str, got {type(query).__name__}"
|
|
322
|
+
raise TypeError(msg)
|
|
323
|
+
if top_k < 1:
|
|
324
|
+
msg = f"top_k must be >= 1, got {top_k}"
|
|
325
|
+
raise ValueError(msg)
|
|
326
|
+
query = query.strip()
|
|
327
|
+
if not query:
|
|
328
|
+
return []
|
|
329
|
+
collection = self._collection
|
|
330
|
+
if collection is None:
|
|
331
|
+
msg = "The index is closed; create a new ToolVectorIndex to reuse it"
|
|
332
|
+
raise RuntimeError(msg)
|
|
333
|
+
dense_vector = self._dense.embed(query)
|
|
334
|
+
sparse_vector = self._sparse.embed_query(query)
|
|
335
|
+
queries = [zvec.Query(field_name=DENSE_FIELD, vector=dense_vector)]
|
|
336
|
+
if sparse_vector:
|
|
337
|
+
# zvec rejects empty query vectors; skip the sparse clause instead.
|
|
338
|
+
queries.append(zvec.Query(field_name=SPARSE_FIELD, vector=sparse_vector))
|
|
339
|
+
results = collection.query(
|
|
340
|
+
queries=queries,
|
|
341
|
+
topk=top_k,
|
|
342
|
+
reranker=self._reranker,
|
|
343
|
+
)
|
|
344
|
+
hits: list[tuple[str, float]] = []
|
|
345
|
+
for doc in results:
|
|
346
|
+
score = float(doc.score) if doc.score is not None else 0.0
|
|
347
|
+
hits.append((doc.id, score))
|
|
348
|
+
return hits
|
|
349
|
+
|
|
350
|
+
def close(self) -> None:
|
|
351
|
+
"""Flush and close the collection, releasing its file lock.
|
|
352
|
+
|
|
353
|
+
Closing an already closed index is a no-op. Do not close while
|
|
354
|
+
searches from other threads are still running.
|
|
355
|
+
"""
|
|
356
|
+
with self._lock:
|
|
357
|
+
if self._collection is not None:
|
|
358
|
+
self._collection.close()
|
|
359
|
+
self._collection = None
|
|
360
|
+
|
|
361
|
+
def __enter__(self) -> ToolVectorIndex:
|
|
362
|
+
return self
|
|
363
|
+
|
|
364
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
365
|
+
self.close()
|