langchain-vectoramp 0.1.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.
@@ -0,0 +1,7 @@
1
+ """LangChain integration for VectorAmp."""
2
+
3
+ from .intelligence import VectorAmpIntelligence
4
+ from .loaders import VectorAmpLoader
5
+ from .vectorstores import VectorAmpVectorStore
6
+
7
+ __all__ = ["VectorAmpIntelligence", "VectorAmpLoader", "VectorAmpVectorStore"]
@@ -0,0 +1,333 @@
1
+ """Small internal VectorAmp HTTP client used by the LangChain adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any, Optional, Union
9
+
10
+ import httpx
11
+
12
+ JSON = dict[str, Any]
13
+ FilterValue = Union[str, int, float, bool, None]
14
+ Filters = Mapping[str, FilterValue]
15
+
16
+ # A vector record id may be a string or an integer. Integer ids must round-trip
17
+ # to the API as JSON numbers (not strings), otherwise the server rewrites them.
18
+ VectorId = Union[str, int]
19
+
20
+
21
+ class VectorAmpClientError(RuntimeError):
22
+ """Raised when the VectorAmp API returns an error."""
23
+
24
+
25
+ class VectorAmpHTTPClient:
26
+ """Minimal client for dataset text insertion and text search endpoints."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ api_key: Optional[str] = None,
32
+ base_url: str = "https://api.vectoramp.com",
33
+ timeout: float = 30.0,
34
+ http_client: Optional[httpx.Client] = None,
35
+ async_http_client: Optional[httpx.AsyncClient] = None,
36
+ ) -> None:
37
+ self.api_key = api_key or os.getenv("VECTORAMP_API_KEY") or ""
38
+ if not self.api_key:
39
+ raise ValueError("api_key is required or VECTORAMP_API_KEY must be set.")
40
+ self.base_url = base_url.rstrip("/")
41
+ self._owns_client = http_client is None
42
+ self._owns_async_client = async_http_client is None
43
+ self._client = http_client or httpx.Client(timeout=timeout)
44
+ self._async_client = async_http_client or httpx.AsyncClient(timeout=timeout)
45
+
46
+ def close(self) -> None:
47
+ if self._owns_client:
48
+ self._client.close()
49
+
50
+ async def aclose(self) -> None:
51
+ if self._owns_async_client:
52
+ await self._async_client.aclose()
53
+
54
+ def get_dataset_id(self, *, dataset_id: Optional[str], dataset_name: Optional[str]) -> str:
55
+ if dataset_id:
56
+ return dataset_id
57
+ if not dataset_name:
58
+ raise ValueError("dataset_id or dataset_name is required.")
59
+ page = self._request("GET", "/datasets", params={"limit": 100, "offset": 0})
60
+ for dataset in page.get("datasets", []):
61
+ if dataset.get("name") == dataset_name:
62
+ value = dataset.get("id") or dataset.get("dataset_id")
63
+ if value is not None:
64
+ return str(value)
65
+ raise ValueError(f"Dataset named {dataset_name!r} was not found.")
66
+
67
+ async def aget_dataset_id(
68
+ self, *, dataset_id: Optional[str], dataset_name: Optional[str]
69
+ ) -> str:
70
+ if dataset_id:
71
+ return dataset_id
72
+ if not dataset_name:
73
+ raise ValueError("dataset_id or dataset_name is required.")
74
+ page = await self._arequest("GET", "/datasets", params={"limit": 100, "offset": 0})
75
+ for dataset in page.get("datasets", []):
76
+ if dataset.get("name") == dataset_name:
77
+ value = dataset.get("id") or dataset.get("dataset_id")
78
+ if value is not None:
79
+ return str(value)
80
+ raise ValueError(f"Dataset named {dataset_name!r} was not found.")
81
+
82
+ def add_texts(
83
+ self,
84
+ dataset_id: str,
85
+ texts: Sequence[str],
86
+ *,
87
+ ids: Optional[Sequence[VectorId]] = None,
88
+ metadatas: Optional[Sequence[Mapping[str, Any]]] = None,
89
+ ) -> list[VectorId]:
90
+ embeddings = self._embed(dataset_id, texts)
91
+ vector_ids = self._build_ids(texts, ids)
92
+ self._insert_vectors(dataset_id, texts, embeddings, vector_ids, metadatas)
93
+ return vector_ids
94
+
95
+ async def aadd_texts(
96
+ self,
97
+ dataset_id: str,
98
+ texts: Sequence[str],
99
+ *,
100
+ ids: Optional[Sequence[VectorId]] = None,
101
+ metadatas: Optional[Sequence[Mapping[str, Any]]] = None,
102
+ ) -> list[VectorId]:
103
+ embeddings = await self._aembed(dataset_id, texts)
104
+ vector_ids = self._build_ids(texts, ids)
105
+ await self._ainsert_vectors(dataset_id, texts, embeddings, vector_ids, metadatas)
106
+ return vector_ids
107
+
108
+ def search(self, dataset_id: str, query: str, *, k: int, **kwargs: Any) -> JSON:
109
+ return self._request(
110
+ "POST", f"/datasets/{dataset_id}/search", json=self._search_body(query, k, kwargs)
111
+ )
112
+
113
+ def intelligence_query(self, body: Mapping[str, Any]) -> JSON:
114
+ """POST an Intelligence/RAG query and return the JSON answer."""
115
+ return self._request("POST", "/intelligence/query", json=dict(body))
116
+
117
+ async def aintelligence_query(self, body: Mapping[str, Any]) -> JSON:
118
+ """Async variant of :meth:`intelligence_query`."""
119
+ return await self._arequest("POST", "/intelligence/query", json=dict(body))
120
+
121
+ def list_documents(
122
+ self,
123
+ dataset_id: str,
124
+ *,
125
+ limit: int = 50,
126
+ cursor: Optional[str] = None,
127
+ status: Optional[str] = None,
128
+ ) -> JSON:
129
+ return self._request(
130
+ "GET",
131
+ f"/datasets/{dataset_id}/documents",
132
+ params={"limit": limit, "cursor": cursor, "status": status},
133
+ )
134
+
135
+ def retry_job(self, job_id: str) -> JSON:
136
+ """Queue a fresh full-rerun job from an eligible failed or cancelled job."""
137
+ return self._request("POST", f"/ingestion/jobs/{job_id}/retry")
138
+
139
+ async def aretry_job(self, job_id: str) -> JSON:
140
+ """Async variant of :meth:`retry_job`."""
141
+ return await self._arequest("POST", f"/ingestion/jobs/{job_id}/retry")
142
+
143
+ def download_document(self, dataset_id: str, document_id: str) -> bytes:
144
+ response = self._client.request(
145
+ "GET",
146
+ f"{self.base_url}/datasets/{dataset_id}/documents/{document_id}/download",
147
+ headers=self._headers(),
148
+ follow_redirects=True,
149
+ )
150
+ if response.status_code >= 300:
151
+ raise VectorAmpClientError(
152
+ f"VectorAmp API error {response.status_code}: {response.text}"
153
+ )
154
+ return response.content
155
+
156
+ async def asearch(self, dataset_id: str, query: str, *, k: int, **kwargs: Any) -> JSON:
157
+ return await self._arequest(
158
+ "POST", f"/datasets/{dataset_id}/search", json=self._search_body(query, k, kwargs)
159
+ )
160
+
161
+ def _embed(self, dataset_id: str, texts: Sequence[str]) -> list[Sequence[float]]:
162
+ response = self._request(
163
+ "POST", f"/datasets/{dataset_id}/embed", json={"texts": list(texts)}
164
+ )
165
+ return self._extract_embeddings(response, len(texts))
166
+
167
+ async def _aembed(self, dataset_id: str, texts: Sequence[str]) -> list[Sequence[float]]:
168
+ response = await self._arequest(
169
+ "POST", f"/datasets/{dataset_id}/embed", json={"texts": list(texts)}
170
+ )
171
+ return self._extract_embeddings(response, len(texts))
172
+
173
+ def _insert_vectors(
174
+ self,
175
+ dataset_id: str,
176
+ texts: Sequence[str],
177
+ embeddings: Sequence[Sequence[float]],
178
+ ids: Sequence[VectorId],
179
+ metadatas: Optional[Sequence[Mapping[str, Any]]],
180
+ ) -> JSON:
181
+ return self._request(
182
+ "POST",
183
+ f"/datasets/{dataset_id}/insert",
184
+ json={"vectors": self._vectors(texts, embeddings, ids, metadatas)},
185
+ )
186
+
187
+ async def _ainsert_vectors(
188
+ self,
189
+ dataset_id: str,
190
+ texts: Sequence[str],
191
+ embeddings: Sequence[Sequence[float]],
192
+ ids: Sequence[VectorId],
193
+ metadatas: Optional[Sequence[Mapping[str, Any]]],
194
+ ) -> JSON:
195
+ return await self._arequest(
196
+ "POST",
197
+ f"/datasets/{dataset_id}/insert",
198
+ json={"vectors": self._vectors(texts, embeddings, ids, metadatas)},
199
+ )
200
+
201
+ def _request(
202
+ self,
203
+ method: str,
204
+ path: str,
205
+ *,
206
+ params: Optional[Mapping[str, Any]] = None,
207
+ json: Optional[Mapping[str, Any]] = None,
208
+ ) -> JSON:
209
+ response = self._client.request(
210
+ method,
211
+ f"{self.base_url}{path}",
212
+ headers=self._headers(),
213
+ params=params,
214
+ json=json,
215
+ )
216
+ return self._decode_response(response)
217
+
218
+ async def _arequest(
219
+ self,
220
+ method: str,
221
+ path: str,
222
+ *,
223
+ params: Optional[Mapping[str, Any]] = None,
224
+ json: Optional[Mapping[str, Any]] = None,
225
+ ) -> JSON:
226
+ response = await self._async_client.request(
227
+ method,
228
+ f"{self.base_url}{path}",
229
+ headers=self._headers(),
230
+ params=params,
231
+ json=json,
232
+ )
233
+ return self._decode_response(response)
234
+
235
+ def _headers(self) -> dict[str, str]:
236
+ return {"x-api-key": self.api_key, "authorization": f"Bearer {self.api_key}"}
237
+
238
+ @staticmethod
239
+ def _decode_response(response: httpx.Response) -> JSON:
240
+ if response.status_code >= 400:
241
+ raise VectorAmpClientError(
242
+ f"VectorAmp API error {response.status_code}: {response.text}"
243
+ )
244
+ if response.status_code == 204 or not response.content:
245
+ return {}
246
+ data = response.json()
247
+ if not isinstance(data, dict):
248
+ raise VectorAmpClientError("VectorAmp API returned a non-object JSON response.")
249
+ return data
250
+
251
+ @staticmethod
252
+ def _extract_embeddings(response: Mapping[str, Any], count: int) -> list[Sequence[float]]:
253
+ embeddings = response.get("embeddings")
254
+ if not isinstance(embeddings, list) or len(embeddings) != count:
255
+ raise VectorAmpClientError("Embedding response length did not match input texts.")
256
+ return embeddings
257
+
258
+ @staticmethod
259
+ def _build_ids(texts: Sequence[str], ids: Optional[Sequence[VectorId]]) -> list[VectorId]:
260
+ if ids is not None:
261
+ if len(ids) != len(texts):
262
+ raise ValueError("ids length must match texts length.")
263
+ # Preserve the caller's id types. Integer ids stay integers so they
264
+ # serialize as JSON numbers; everything else is coerced to a string.
265
+ # (bool is a subclass of int but is not a valid id, so it is excluded.)
266
+ return [
267
+ value if isinstance(value, int) and not isinstance(value, bool) else str(value)
268
+ for value in ids
269
+ ]
270
+ return [str(uuid.uuid4()) for _ in texts]
271
+
272
+ @staticmethod
273
+ def _vectors(
274
+ texts: Sequence[str],
275
+ embeddings: Sequence[Sequence[float]],
276
+ ids: Sequence[VectorId],
277
+ metadatas: Optional[Sequence[Mapping[str, Any]]],
278
+ ) -> list[JSON]:
279
+ if metadatas is not None and len(metadatas) != len(texts):
280
+ raise ValueError("metadatas length must match texts length.")
281
+ vectors: list[JSON] = []
282
+ for index, (text, values) in enumerate(zip(texts, embeddings, strict=True)):
283
+ metadata = dict(metadatas[index]) if metadatas is not None else {}
284
+ metadata.setdefault("text", text)
285
+ # ``ids[index]`` keeps its original type (int stays int -> JSON number,
286
+ # str stays str) so the API never rewrites integer ids.
287
+ vectors.append({"id": ids[index], "values": list(values), "metadata": metadata})
288
+ return vectors
289
+
290
+ @staticmethod
291
+ def _search_body(query: str, k: int, kwargs: Mapping[str, Any]) -> JSON:
292
+ # Metadata filters accept ``filter`` (preferred, LangChain convention) or
293
+ # ``filters`` (SDK parity). Both map to the API's ``filters`` field; pass
294
+ # only one.
295
+ search_text = kwargs.get("search_text")
296
+ if search_text is not None and search_text != query:
297
+ raise ValueError(
298
+ "Use the LangChain query argument for search text, "
299
+ "not a separate search_text value."
300
+ )
301
+ body: JSON = {"query_text": query, "top_k": k, "include_documents": True}
302
+ filter_value = kwargs.get("filter")
303
+ filters_value = kwargs.get("filters")
304
+ if filter_value is not None and filters_value is not None:
305
+ raise ValueError("Use only one of filter or filters.")
306
+ filters = filter_value if filter_value is not None else filters_value
307
+ if filters is not None:
308
+ body["filters"] = dict(filters)
309
+ passthrough = {
310
+ "advanced_filters",
311
+ "embedding_provider",
312
+ "embedding_model",
313
+ "nprobe_override",
314
+ "rerank_depth_override",
315
+ "hybrid",
316
+ "sparse_query",
317
+ "alpha",
318
+ "include_embeddings",
319
+ "include_documents",
320
+ "include_metadata",
321
+ "rerank",
322
+ }
323
+ for key in passthrough:
324
+ if key in kwargs and kwargs[key] is not None:
325
+ body[key] = kwargs[key]
326
+ # Ergonomic: ``rerank=True`` expands to the full VectorAmp rerank object.
327
+ if body.get("rerank") is True:
328
+ body["rerank"] = {
329
+ "enabled": True,
330
+ "provider": "vectoramp",
331
+ "model": "VectorAmp-Rerank-v1",
332
+ }
333
+ return body
@@ -0,0 +1,196 @@
1
+ """LangChain Intelligence (RAG) interface for VectorAmp.
2
+
3
+ Designed for minimal-friction usage: a one-liner answers a question, queries run
4
+ across all datasets by default, and multi-turn conversations "just work" because
5
+ LangChain message history is mapped to the API's ``conversation_history``
6
+ automatically. The caller decides how many prior messages to include simply by
7
+ how many they pass.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+ from typing import Any, Optional, Union
14
+
15
+ from langchain_core.messages import BaseMessage
16
+ from langchain_core.runnables import Runnable, RunnableConfig
17
+
18
+ from .client import VectorAmpHTTPClient
19
+
20
+ JSON = dict[str, Any]
21
+ Turn = dict[str, str]
22
+ Message = Union[BaseMessage, dict[str, Any], str]
23
+
24
+ # LangChain message ``type`` -> Intelligence ``role``.
25
+ _ROLE_BY_TYPE = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}
26
+
27
+
28
+ def _content_to_text(content: Any) -> str:
29
+ if isinstance(content, str):
30
+ return content
31
+ if isinstance(content, list): # LangChain content blocks
32
+ parts = [
33
+ block.get("text", "") if isinstance(block, dict) else str(block)
34
+ for block in content
35
+ ]
36
+ return "".join(parts)
37
+ return str(content)
38
+
39
+
40
+ def _to_turn(message: Message) -> Turn:
41
+ if isinstance(message, BaseMessage):
42
+ return {
43
+ "role": _ROLE_BY_TYPE.get(message.type, "user"),
44
+ "content": _content_to_text(message.content),
45
+ }
46
+ if isinstance(message, dict):
47
+ role = message.get("role") or _ROLE_BY_TYPE.get(str(message.get("type", "")), "user")
48
+ return {"role": role, "content": _content_to_text(message.get("content", ""))}
49
+ return {"role": "user", "content": str(message)}
50
+
51
+
52
+ class VectorAmpIntelligence(Runnable[Any, str]):
53
+ """Ask VectorAmp Intelligence (RAG) from LangChain.
54
+
55
+ The simplest usage needs only a question::
56
+
57
+ intel = VectorAmpIntelligence() # uses VECTORAMP_API_KEY, all datasets
58
+ print(intel.ask("What changed in Q4 planning?"))
59
+
60
+ Multi-turn follow-ups: pass prior LangChain messages (or plain dicts); they
61
+ are forwarded as ``conversation_history`` automatically::
62
+
63
+ from langchain_core.messages import HumanMessage, AIMessage
64
+ history = [HumanMessage("What is VectorAmp?"), AIMessage("A vector DB platform.")]
65
+ intel.ask("Does it support hybrid search?", history=history)
66
+
67
+ As a LangChain ``Runnable`` it composes in chains and works with
68
+ ``RunnableWithMessageHistory`` (pass a list of messages; the last human
69
+ message is the question and the rest become conversation history)::
70
+
71
+ intel.invoke("Summarize the docs")
72
+ intel.invoke([HumanMessage("hi"), AIMessage("hello"), HumanMessage("and bye?")])
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ dataset_id: Optional[str] = None,
79
+ dataset_name: Optional[str] = None,
80
+ api_key: Optional[str] = None,
81
+ base_url: str = "https://api.vectoramp.com",
82
+ client: Optional[VectorAmpHTTPClient] = None,
83
+ top_k: Optional[int] = None,
84
+ include_sources: bool = True,
85
+ ) -> None:
86
+ self._client = client or VectorAmpHTTPClient(api_key=api_key, base_url=base_url)
87
+ self._dataset_id = dataset_id
88
+ self._dataset_name = dataset_name
89
+ self._top_k = top_k
90
+ self._include_sources = include_sources
91
+
92
+ def _dataset(self) -> str:
93
+ # Default to every accessible dataset; only resolve a name/id when given.
94
+ if self._dataset_id or self._dataset_name:
95
+ return self._client.get_dataset_id(
96
+ dataset_id=self._dataset_id, dataset_name=self._dataset_name
97
+ )
98
+ return "all"
99
+
100
+ def _body(
101
+ self,
102
+ query: str,
103
+ history: Optional[Sequence[Message]],
104
+ overrides: dict[str, Any],
105
+ ) -> JSON:
106
+ body: JSON = {
107
+ "query": query,
108
+ "dataset_id": overrides.get("dataset_id") or self._dataset(),
109
+ "include_sources": overrides.get("include_sources", self._include_sources),
110
+ "stream": False,
111
+ }
112
+ top_k = overrides.get("top_k", self._top_k)
113
+ if top_k is not None:
114
+ body["top_k"] = top_k
115
+ turns = [_to_turn(message) for message in (history or [])]
116
+ if turns:
117
+ body["conversation_history"] = turns
118
+ return body
119
+
120
+ def ask(
121
+ self,
122
+ query: str,
123
+ *,
124
+ history: Optional[Sequence[Message]] = None,
125
+ **overrides: Any,
126
+ ) -> str:
127
+ """Ask a question and return the answer text."""
128
+ response = self._client.intelligence_query(self._body(query, history, overrides))
129
+ return str(response.get("answer", ""))
130
+
131
+ async def aask(
132
+ self,
133
+ query: str,
134
+ *,
135
+ history: Optional[Sequence[Message]] = None,
136
+ **overrides: Any,
137
+ ) -> str:
138
+ """Async variant of :meth:`ask`."""
139
+ data = await self._client.aintelligence_query(self._body(query, history, overrides))
140
+ return str(data.get("answer", ""))
141
+
142
+ def ask_with_sources(
143
+ self,
144
+ query: str,
145
+ *,
146
+ history: Optional[Sequence[Message]] = None,
147
+ **overrides: Any,
148
+ ) -> JSON:
149
+ """Return the full Intelligence response (answer, sources, metadata)."""
150
+ return self._client.intelligence_query(self._body(query, history, overrides))
151
+
152
+ # --- Runnable interface -------------------------------------------------
153
+ def invoke(
154
+ self,
155
+ input: Any,
156
+ config: Optional[RunnableConfig] = None,
157
+ **kwargs: Any,
158
+ ) -> str:
159
+ query, history = self._split(input)
160
+ return self.ask(query, history=history)
161
+
162
+ async def ainvoke(
163
+ self,
164
+ input: Any,
165
+ config: Optional[RunnableConfig] = None,
166
+ **kwargs: Any,
167
+ ) -> str:
168
+ query, history = self._split(input)
169
+ return await self.aask(query, history=history)
170
+
171
+ @staticmethod
172
+ def _split(input: Any) -> tuple[str, list[Message]]:
173
+ """Normalize Runnable input into (query, history).
174
+
175
+ Accepts a plain string, a dict
176
+ ({query|question|input, history|chat_history|messages}), or a sequence of
177
+ messages where the last one is the question.
178
+ """
179
+ if isinstance(input, str):
180
+ return input, []
181
+ if isinstance(input, dict):
182
+ query = input.get("query") or input.get("question") or input.get("input") or ""
183
+ history = list(
184
+ input.get("history") or input.get("chat_history") or input.get("messages") or []
185
+ )
186
+ if not query and history:
187
+ last = history.pop()
188
+ return _to_turn(last)["content"], history
189
+ return str(query), history
190
+ if isinstance(input, Sequence):
191
+ messages = list(input)
192
+ if not messages:
193
+ return "", []
194
+ last = messages[-1]
195
+ return _to_turn(last)["content"], messages[:-1]
196
+ return str(input), []
@@ -0,0 +1,128 @@
1
+ """LangChain document loader for VectorAmp dataset documents or search results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping
6
+ from typing import Any, Optional
7
+
8
+ from langchain_core.document_loaders import BaseLoader
9
+ from langchain_core.documents import Document
10
+
11
+ from .client import VectorAmpHTTPClient
12
+ from .vectorstores import VectorAmpVectorStore
13
+
14
+
15
+ class VectorAmpLoader(BaseLoader):
16
+ """Load LangChain documents from VectorAmp retained documents or search results.
17
+
18
+ When ``query`` is omitted, the loader uses VectorAmp's public
19
+ ``/datasets/{id}/documents`` endpoint to iterate retained source documents.
20
+ When ``query`` is provided, it remains semantic-search-backed and returns the
21
+ top ``k`` matching documents.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ api_key: Optional[str] = None,
28
+ base_url: str = "https://api.vectoramp.com",
29
+ dataset_id: str,
30
+ client: Optional[Any] = None,
31
+ query: Optional[str] = None,
32
+ filter: Optional[Mapping[str, Any]] = None,
33
+ k: int = 10,
34
+ metadata: Optional[Mapping[str, Any]] = None,
35
+ timeout: float = 30.0,
36
+ **search_kwargs: Any,
37
+ ) -> None:
38
+ self.dataset_id = dataset_id
39
+ self.query = query
40
+ self.filter = dict(filter) if filter is not None else None
41
+ self.k = k
42
+ self.metadata = dict(metadata) if metadata is not None else {}
43
+ self.search_kwargs = dict(search_kwargs)
44
+ self._external_client = client
45
+ self._client = client or VectorAmpHTTPClient(
46
+ api_key=api_key,
47
+ base_url=base_url,
48
+ timeout=timeout,
49
+ )
50
+
51
+ def lazy_load(self) -> Iterator[Document]:
52
+ """Yield retained dataset documents or semantic-search matches."""
53
+ if not self.query:
54
+ yield from self._lazy_load_documents()
55
+ return
56
+
57
+ kwargs = dict(self.search_kwargs)
58
+ if self.filter is not None:
59
+ kwargs["filter"] = self.filter
60
+ kwargs.setdefault("include_documents", True)
61
+ kwargs.setdefault("include_metadata", True)
62
+ store = VectorAmpVectorStore(client=self._client, dataset_id=self.dataset_id)
63
+ for document in store.similarity_search(self.query, k=self.k, **kwargs):
64
+ if self.metadata:
65
+ document.metadata = {**self.metadata, **document.metadata}
66
+ yield document
67
+
68
+ def _lazy_load_documents(self) -> Iterator[Document]:
69
+ if not hasattr(self._client, "list_documents"):
70
+ raise ValueError(
71
+ "VectorAmpLoader requires query when the client cannot list documents."
72
+ )
73
+
74
+ cursor: Optional[str] = None
75
+ while True:
76
+ page = self._client.list_documents(
77
+ self.dataset_id, limit=self.k, cursor=cursor, status="ready"
78
+ )
79
+ documents = page.get("documents") or page.get("data") or page.get("items") or []
80
+ if not documents:
81
+ break
82
+ for item in documents:
83
+ if not isinstance(item, Mapping):
84
+ continue
85
+ content = self._document_content(item)
86
+ if content is None:
87
+ if not hasattr(self._client, "download_document"):
88
+ continue
89
+ document_id = item.get("id") or item.get("document_id")
90
+ if document_id is None:
91
+ continue
92
+ content = self._client.download_document(
93
+ self.dataset_id, str(document_id)
94
+ ).decode("utf-8", errors="replace")
95
+ metadata = {
96
+ key: value
97
+ for key, value in item.items()
98
+ if key not in {"text", "content", "page_content", "doc_value"}
99
+ }
100
+ if self.metadata:
101
+ metadata = {**self.metadata, **metadata}
102
+ doc_id = item.get("id") or item.get("document_id")
103
+ yield Document(
104
+ page_content=str(content),
105
+ metadata=metadata,
106
+ id=str(doc_id) if doc_id is not None else None,
107
+ )
108
+
109
+ cursor = page.get("next_cursor") or page.get("nextCursor")
110
+ if not cursor:
111
+ break
112
+
113
+ @staticmethod
114
+ def _document_content(item: Mapping[str, Any]) -> Any:
115
+ for key in ("text", "content", "page_content", "doc_value"):
116
+ value = item.get(key)
117
+ if value is not None:
118
+ return value
119
+ return None
120
+
121
+ def load(self) -> list[Document]:
122
+ """Return all documents from :meth:`lazy_load`."""
123
+ return list(self.lazy_load())
124
+
125
+ def close(self) -> None:
126
+ """Close owned HTTP resources."""
127
+ if self._external_client is None and hasattr(self._client, "close"):
128
+ self._client.close()
File without changes