oracle-haystack 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
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from haystack_integrations.components.retrievers.oracle.embedding_retriever import OracleEmbeddingRetriever
6
+
7
+ __all__ = ["OracleEmbeddingRetriever"]
@@ -0,0 +1,119 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ from haystack import component, default_from_dict, default_to_dict
8
+ from haystack.dataclasses import Document
9
+ from haystack.document_stores.types import FilterPolicy
10
+ from haystack.document_stores.types.filter_policy import apply_filter_policy
11
+
12
+ from haystack_integrations.document_stores.oracle import OracleDocumentStore
13
+
14
+
15
+ @component
16
+ class OracleEmbeddingRetriever:
17
+ """
18
+ Retrieves documents from an OracleDocumentStore using vector similarity.
19
+
20
+ Use inside a Haystack pipeline after a text embedder::
21
+
22
+ pipeline.add_component("embedder", SentenceTransformersTextEmbedder())
23
+ pipeline.add_component("retriever", OracleEmbeddingRetriever(
24
+ document_store=store, top_k=5
25
+ ))
26
+ pipeline.connect("embedder.embedding", "retriever.query_embedding")
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ document_store: OracleDocumentStore,
33
+ filters: dict[str, Any] | None = None,
34
+ top_k: int = 10,
35
+ filter_policy: FilterPolicy = FilterPolicy.REPLACE,
36
+ ) -> None:
37
+ if not isinstance(document_store, OracleDocumentStore):
38
+ msg = "document_store must be an instance of OracleDocumentStore"
39
+ raise TypeError(msg)
40
+ self.document_store = document_store
41
+ self.filters = filters or {}
42
+ self.top_k = top_k
43
+ self.filter_policy = FilterPolicy.from_str(filter_policy) if isinstance(filter_policy, str) else filter_policy
44
+
45
+ @component.output_types(documents=list[Document])
46
+ def run(
47
+ self,
48
+ query_embedding: list[float],
49
+ filters: dict[str, Any] | None = None,
50
+ top_k: int | None = None,
51
+ ) -> dict[str, list[Document]]:
52
+ """
53
+ Retrieve documents by vector similarity.
54
+
55
+ Args:
56
+ query_embedding: Dense float vector from an embedder component.
57
+ filters: Runtime filters, merged with constructor filters according to filter_policy.
58
+ top_k: Override the constructor top_k for this call.
59
+
60
+ Returns:
61
+ ``{"documents": [Document, ...]}``
62
+ """
63
+ filters = apply_filter_policy(self.filter_policy, self.filters, filters)
64
+ docs = self.document_store._embedding_retrieval(
65
+ query_embedding,
66
+ filters=filters,
67
+ top_k=top_k if top_k is not None else self.top_k,
68
+ )
69
+ return {"documents": docs}
70
+
71
+ @component.output_types(documents=list[Document])
72
+ async def run_async(
73
+ self,
74
+ query_embedding: list[float],
75
+ filters: dict[str, Any] | None = None,
76
+ top_k: int | None = None,
77
+ ) -> dict[str, list[Document]]:
78
+ """Async variant of :meth:`run`."""
79
+ filters = apply_filter_policy(self.filter_policy, self.filters, filters)
80
+ docs = await self.document_store._embedding_retrieval_async(
81
+ query_embedding,
82
+ filters=filters,
83
+ top_k=top_k if top_k is not None else self.top_k,
84
+ )
85
+ return {"documents": docs}
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ """
89
+ Serializes the component to a dictionary.
90
+
91
+ :returns:
92
+ Dictionary with serialized data.
93
+ """
94
+ return default_to_dict(
95
+ self,
96
+ document_store=self.document_store.to_dict(),
97
+ filters=self.filters,
98
+ top_k=self.top_k,
99
+ filter_policy=self.filter_policy.value,
100
+ )
101
+
102
+ @classmethod
103
+ def from_dict(cls, data: dict[str, Any]) -> "OracleEmbeddingRetriever":
104
+ """
105
+ Deserializes the component from a dictionary.
106
+
107
+ :param data:
108
+ Dictionary to deserialize from.
109
+ :returns:
110
+ Deserialized component.
111
+ """
112
+ params = data.get("init_parameters", {})
113
+ if "document_store" in params:
114
+ params["document_store"] = OracleDocumentStore.from_dict(params["document_store"])
115
+ # Pipelines serialized with old versions of the component might not
116
+ # have the filter_policy field.
117
+ if filter_policy := params.get("filter_policy"):
118
+ params["filter_policy"] = FilterPolicy.from_str(filter_policy)
119
+ return default_from_dict(cls, data)
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from haystack_integrations.document_stores.oracle.document_store import (
6
+ OracleConnectionConfig,
7
+ OracleDocumentStore,
8
+ )
9
+
10
+ __all__ = ["OracleConnectionConfig", "OracleDocumentStore"]
@@ -0,0 +1,524 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import array as _array
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import re
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from typing import Any, Literal
13
+
14
+ import oracledb
15
+ from haystack import default_from_dict, default_to_dict
16
+ from haystack.dataclasses import Document
17
+ from haystack.document_stores.errors import DuplicateDocumentError
18
+ from haystack.document_stores.types import DuplicatePolicy
19
+ from haystack.utils import Secret, deserialize_secrets_inplace
20
+
21
+ from .filters import FilterTranslator
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _SAFE_TABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_$#]{0,127}$")
26
+
27
+
28
+ @dataclass
29
+ class OracleConnectionConfig:
30
+ """
31
+ Connection parameters for Oracle Database.
32
+
33
+ Supports both thin (direct TCP) and thick (wallet / ADB-S) modes.
34
+ Thin mode requires no Oracle Instant Client; thick mode is activated
35
+ automatically when *wallet_location* is provided.
36
+ """
37
+
38
+ user: Secret
39
+ password: Secret
40
+ dsn: Secret
41
+ wallet_location: str | None = None
42
+ wallet_password: Secret | None = None
43
+ min_connections: int = 1
44
+ max_connections: int = 5
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ """
48
+ Serializes the component to a dictionary.
49
+
50
+ :returns:
51
+ Dictionary with serialized data.
52
+ """
53
+ return {
54
+ "user": self.user.to_dict(),
55
+ "password": self.password.to_dict(),
56
+ "dsn": self.dsn.to_dict(),
57
+ "wallet_location": self.wallet_location,
58
+ "wallet_password": self.wallet_password.to_dict() if self.wallet_password else None,
59
+ "min_connections": self.min_connections,
60
+ "max_connections": self.max_connections,
61
+ }
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict[str, Any]) -> "OracleConnectionConfig":
65
+ """
66
+ Deserializes the component from a dictionary.
67
+
68
+ :param data:
69
+ Dictionary to deserialize from.
70
+ :returns:
71
+ Deserialized component.
72
+ """
73
+ deserialize_secrets_inplace(data, keys=["user", "password", "dsn", "wallet_password"])
74
+ return cls(**data)
75
+
76
+
77
+ class OracleDocumentStore:
78
+ """
79
+ Haystack DocumentStore backed by Oracle AI Vector Search.
80
+
81
+ Requires Oracle Database 23ai or later (for VECTOR data type and
82
+ IF NOT EXISTS DDL support).
83
+
84
+ Usage::
85
+
86
+ from haystack.utils import Secret
87
+ from haystack_integrations.document_stores.oracle import (
88
+ OracleDocumentStore, OracleConnectionConfig,
89
+ )
90
+
91
+ store = OracleDocumentStore(
92
+ connection_config=OracleConnectionConfig(
93
+ user=Secret.from_env_var("ORACLE_USER"),
94
+ password=Secret.from_env_var("ORACLE_PASSWORD"),
95
+ dsn=Secret.from_env_var("ORACLE_DSN"),
96
+ ),
97
+ embedding_dim=1536,
98
+ )
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ connection_config: OracleConnectionConfig,
105
+ table_name: str = "haystack_documents",
106
+ embedding_dim: int,
107
+ distance_metric: Literal["COSINE", "EUCLIDEAN", "DOT"] = "COSINE",
108
+ create_table_if_not_exists: bool = True,
109
+ create_index: bool = False,
110
+ hnsw_neighbors: int = 32,
111
+ hnsw_ef_construction: int = 200,
112
+ hnsw_accuracy: int = 95,
113
+ hnsw_parallel: int = 4,
114
+ ) -> None:
115
+ if not _SAFE_TABLE_NAME.match(table_name):
116
+ msg = (
117
+ f"Invalid table_name {table_name!r}. Must be a valid Oracle identifier "
118
+ "(letters, digits, _, $, # — max 128 chars, cannot start with a digit)."
119
+ )
120
+ raise ValueError(msg)
121
+ if embedding_dim <= 0:
122
+ msg = f"embedding_dim must be a positive integer, got {embedding_dim}"
123
+ raise ValueError(msg)
124
+
125
+ self.connection_config = connection_config
126
+ self.table_name = table_name
127
+ self.embedding_dim = embedding_dim
128
+ self.distance_metric = distance_metric
129
+ self.create_table_if_not_exists = create_table_if_not_exists
130
+ self.create_index = create_index
131
+ self.hnsw_neighbors = hnsw_neighbors
132
+ self.hnsw_ef_construction = hnsw_ef_construction
133
+ self.hnsw_accuracy = hnsw_accuracy
134
+ self.hnsw_parallel = hnsw_parallel
135
+
136
+ self._pool: oracledb.ConnectionPool | None = None
137
+ self._pool_lock = threading.Lock()
138
+
139
+ if create_table_if_not_exists:
140
+ self._ensure_table()
141
+ if create_index:
142
+ self.create_hnsw_index()
143
+
144
+ def _get_pool(self) -> oracledb.ConnectionPool:
145
+ if self._pool is not None:
146
+ return self._pool
147
+ with self._pool_lock:
148
+ if self._pool is not None:
149
+ return self._pool
150
+
151
+ cfg = self.connection_config
152
+ password = cfg.password.resolve_value()
153
+
154
+ connect_kwargs: dict[str, Any] = {
155
+ "user": cfg.user.resolve_value(),
156
+ "password": password,
157
+ "dsn": cfg.dsn.resolve_value(),
158
+ "min": cfg.min_connections,
159
+ "max": cfg.max_connections,
160
+ "increment": 1,
161
+ }
162
+ if cfg.wallet_location:
163
+ connect_kwargs["config_dir"] = cfg.wallet_location
164
+ connect_kwargs["wallet_location"] = cfg.wallet_location
165
+ connect_kwargs["wallet_password"] = (
166
+ cfg.wallet_password.resolve_value() if cfg.wallet_password else password
167
+ )
168
+
169
+ self._pool = oracledb.create_pool(**connect_kwargs)
170
+ return self._pool
171
+
172
+ def _get_connection(self) -> oracledb.Connection:
173
+ return self._get_pool().acquire()
174
+
175
+ def __del__(self) -> None:
176
+ if self._pool is not None:
177
+ try:
178
+ self._pool.close()
179
+ except Exception:
180
+ logger.warning("Failed to close Oracle connection pool during cleanup.", exc_info=True)
181
+
182
+ def _ensure_table(self) -> None:
183
+ sql = f"""
184
+ CREATE TABLE IF NOT EXISTS {self.table_name} (
185
+ id VARCHAR2(64) PRIMARY KEY,
186
+ text CLOB,
187
+ metadata JSON,
188
+ embedding VECTOR({self.embedding_dim}, FLOAT32)
189
+ )
190
+ """
191
+ with self._get_connection() as conn, conn.cursor() as cur:
192
+ cur.execute(sql)
193
+ conn.commit()
194
+
195
+ def create_hnsw_index(self) -> None:
196
+ """
197
+ Create an HNSW vector index on the embedding column.
198
+
199
+ Safe to call multiple times — uses IF NOT EXISTS.
200
+ """
201
+ sql = f"""
202
+ CREATE VECTOR INDEX IF NOT EXISTS {self.table_name}_vidx
203
+ ON {self.table_name}(embedding)
204
+ ORGANIZATION INMEMORY NEIGHBOR GRAPH
205
+ WITH TARGET ACCURACY {self.hnsw_accuracy}
206
+ DISTANCE {self.distance_metric}
207
+ PARAMETERS (type HNSW, neighbors {self.hnsw_neighbors},
208
+ efConstruction {self.hnsw_ef_construction})
209
+ PARALLEL {self.hnsw_parallel}
210
+ """
211
+ with self._get_connection() as conn, conn.cursor() as cur:
212
+ cur.execute(sql)
213
+ conn.commit()
214
+
215
+ async def create_hnsw_index_async(self) -> None:
216
+ """Async variant of create_hnsw_index."""
217
+ await asyncio.to_thread(self.create_hnsw_index)
218
+
219
+ def write_documents(
220
+ self,
221
+ documents: list[Document],
222
+ policy: DuplicatePolicy = DuplicatePolicy.NONE,
223
+ ) -> int:
224
+ """
225
+ Writes documents to the document store.
226
+
227
+ :param documents: A list of Documents to write to the document store.
228
+ :param policy: The duplicate policy to use when writing documents.
229
+ :raises DuplicateDocumentError: If a document with the same id already exists in the document store
230
+ and the policy is set to `DuplicatePolicy.FAIL` or `DuplicatePolicy.NONE`.
231
+ :returns: The number of documents written to the document store.
232
+ """
233
+ if not isinstance(documents, list):
234
+ msg = "write_documents expects a list of Document objects."
235
+ raise ValueError(msg)
236
+ if documents and not isinstance(documents[0], Document):
237
+ msg = "write_documents expects a list of Document objects."
238
+ raise ValueError(msg)
239
+ if not documents:
240
+ return 0
241
+ if policy in (DuplicatePolicy.NONE, DuplicatePolicy.FAIL):
242
+ return self._insert_documents(documents)
243
+ if policy == DuplicatePolicy.SKIP:
244
+ return self._skip_duplicate_documents(documents)
245
+ if policy == DuplicatePolicy.OVERWRITE:
246
+ return self._upsert_documents(documents)
247
+ msg = f"Unknown DuplicatePolicy: {policy}"
248
+ raise ValueError(msg)
249
+
250
+ @staticmethod
251
+ def _to_row(doc: Document) -> tuple[str, str | None, str, _array.array | None]:
252
+ """
253
+ Convert a Document to (id, text, metadata_json, embedding_array).
254
+
255
+ Haystack IDs are stored verbatim in a VARCHAR2(64) column, so any
256
+ string ID (UUID, SHA-256 hash, or custom) is accepted without conversion.
257
+ """
258
+ doc_id = doc.id
259
+ text = doc.content
260
+ meta = json.dumps(doc.meta or {})
261
+ emb: _array.array | None = None
262
+ if doc.embedding is not None:
263
+ emb = _array.array("f", doc.embedding)
264
+ return doc_id, text, meta, emb
265
+
266
+ @staticmethod
267
+ def _to_named_row(doc: Document) -> dict[str, Any]:
268
+ doc_id, text, meta, emb = OracleDocumentStore._to_row(doc)
269
+ return {"doc_id": doc_id, "doc_text": text, "doc_meta": meta, "doc_emb": emb}
270
+
271
+ def _insert_documents(self, documents: list[Document]) -> int:
272
+ sql = f"""
273
+ INSERT INTO {self.table_name} (id, text, metadata, embedding)
274
+ VALUES (:doc_id, :doc_text, :doc_meta, :doc_emb)
275
+ """
276
+ rows = [OracleDocumentStore._to_named_row(d) for d in documents]
277
+ try:
278
+ with self._get_connection() as conn, conn.cursor() as cur:
279
+ cur.executemany(sql, rows)
280
+ conn.commit()
281
+ except oracledb.IntegrityError as exc:
282
+ msg = f"Document already exists. Use DuplicatePolicy.OVERWRITE or SKIP. Original error: {exc}"
283
+ raise DuplicateDocumentError(msg) from exc
284
+ return len(rows)
285
+
286
+ def _skip_duplicate_documents(self, documents: list[Document]) -> int:
287
+ # For a MERGE with WHEN NOT MATCHED only, Oracle reports 0 rows affected
288
+ # for existing documents and 1 for each new insert. oracledb sums these
289
+ # across executemany iterations, so cur.rowcount equals the number of
290
+ # newly written documents.
291
+ sql = f"""
292
+ MERGE INTO {self.table_name} t
293
+ USING (SELECT :doc_id AS id FROM dual) s ON (t.id = s.id)
294
+ WHEN NOT MATCHED THEN
295
+ INSERT (id, text, metadata, embedding)
296
+ VALUES (s.id, :doc_text, :doc_meta, :doc_emb)
297
+ """
298
+ rows = [OracleDocumentStore._to_named_row(d) for d in documents]
299
+ with self._get_connection() as conn, conn.cursor() as cur:
300
+ cur.executemany(sql, rows)
301
+ written = cur.rowcount
302
+ conn.commit()
303
+ return written
304
+
305
+ def _upsert_documents(self, documents: list[Document]) -> int:
306
+ sql = f"""
307
+ MERGE INTO {self.table_name} t
308
+ USING (SELECT :doc_id AS id FROM dual) s ON (t.id = s.id)
309
+ WHEN MATCHED THEN
310
+ UPDATE SET t.text = :doc_text, t.metadata = :doc_meta, t.embedding = :doc_emb
311
+ WHEN NOT MATCHED THEN
312
+ INSERT (id, text, metadata, embedding)
313
+ VALUES (s.id, :doc_text, :doc_meta, :doc_emb)
314
+ """
315
+ rows = [OracleDocumentStore._to_named_row(d) for d in documents]
316
+ with self._get_connection() as conn, conn.cursor() as cur:
317
+ cur.executemany(sql, rows)
318
+ written = cur.rowcount
319
+ conn.commit()
320
+ return written
321
+
322
+ async def write_documents_async(
323
+ self,
324
+ documents: list[Document],
325
+ policy: DuplicatePolicy = DuplicatePolicy.NONE,
326
+ ) -> int:
327
+ """
328
+ Asynchronously writes documents to the document store.
329
+
330
+ :param documents: A list of Documents to write to the document store.
331
+ :param policy: The duplicate policy to use when writing documents.
332
+ :raises DuplicateDocumentError: If a document with the same id already exists in the document store
333
+ and the policy is set to `DuplicatePolicy.FAIL` or `DuplicatePolicy.NONE`.
334
+ :returns: The number of documents written to the document store.
335
+ """
336
+ return await asyncio.to_thread(self.write_documents, documents, policy)
337
+
338
+ @staticmethod
339
+ def _build_where(filters: dict[str, Any] | None) -> tuple[str, dict[str, Any]]:
340
+ if not filters:
341
+ return "", {}
342
+ params: dict[str, Any] = {}
343
+ counter = [0]
344
+ fragment = FilterTranslator().translate(filters, params, counter)
345
+ return f"WHERE {fragment}", params
346
+
347
+ def filter_documents(self, filters: dict[str, Any] | None = None) -> list[Document]:
348
+ """
349
+ Returns the documents that match the filters provided.
350
+
351
+ For a detailed specification of the filters,
352
+ refer to the [documentation](https://docs.haystack.deepset.ai/docs/metadata-filtering)
353
+
354
+ :param filters: The filters to apply to the document list.
355
+ :returns: A list of Documents that match the given filters.
356
+ """
357
+ where, params = OracleDocumentStore._build_where(filters)
358
+ sql = f"SELECT id, text, metadata FROM {self.table_name} {where}"
359
+ with self._get_connection() as conn, conn.cursor() as cur:
360
+ cur.execute(sql, params)
361
+ rows = cur.fetchall()
362
+ return [OracleDocumentStore._row_to_document(r) for r in rows]
363
+
364
+ async def filter_documents_async(self, filters: dict[str, Any] | None = None) -> list[Document]:
365
+ """
366
+ Asynchronously returns the documents that match the filters provided.
367
+
368
+ For a detailed specification of the filters,
369
+ refer to the [documentation](https://docs.haystack.deepset.ai/docs/metadata-filtering)
370
+
371
+ :param filters: The filters to apply to the document list.
372
+ :returns: A list of Documents that match the given filters.
373
+ """
374
+ return await asyncio.to_thread(self.filter_documents, filters)
375
+
376
+ def delete_documents(self, document_ids: list[str]) -> None:
377
+ """
378
+ Deletes documents that match the provided `document_ids` from the document store.
379
+
380
+ :param document_ids: the document ids to delete
381
+ """
382
+ if not document_ids:
383
+ return
384
+ placeholders = ", ".join(f":p{i}" for i in range(len(document_ids)))
385
+ sql = f"DELETE FROM {self.table_name} WHERE id IN ({placeholders})"
386
+ params = {f"p{i}": doc_id for i, doc_id in enumerate(document_ids)}
387
+ with self._get_connection() as conn, conn.cursor() as cur:
388
+ cur.execute(sql, params)
389
+ conn.commit()
390
+
391
+ async def delete_documents_async(self, document_ids: list[str]) -> None:
392
+ """
393
+ Asynchronously deletes documents that match the provided `document_ids` from the document store.
394
+
395
+ :param document_ids: the document ids to delete
396
+ """
397
+ await asyncio.to_thread(self.delete_documents, document_ids)
398
+
399
+ def count_documents(self) -> int:
400
+ """
401
+ Returns how many documents are present in the document store.
402
+
403
+ :returns:
404
+ Number of documents in the document store.
405
+ """
406
+ sql = f"SELECT COUNT(*) FROM {self.table_name}"
407
+ with self._get_connection() as conn, conn.cursor() as cur:
408
+ cur.execute(sql)
409
+ row = cur.fetchone()
410
+ return row[0] if row else 0
411
+
412
+ async def count_documents_async(self) -> int:
413
+ """
414
+ Asynchronously returns how many documents are present in the document store.
415
+
416
+ :returns:
417
+ Number of documents in the document store.
418
+ """
419
+ return await asyncio.to_thread(self.count_documents)
420
+
421
+ def _embedding_retrieval(
422
+ self,
423
+ query_embedding: list[float],
424
+ *,
425
+ filters: dict[str, Any] | None = None,
426
+ top_k: int = 10,
427
+ ) -> list[Document]:
428
+ # Oracle vector_distance() returns lower values for more similar vectors
429
+ # across all metrics (COSINE, EUCLIDEAN, DOT) → always sort ASC.
430
+ order = "ASC"
431
+ where, params = OracleDocumentStore._build_where(filters)
432
+ sql = f"""
433
+ SELECT id, text, metadata,
434
+ vector_distance(embedding, :query_vec, {self.distance_metric}) AS score
435
+ FROM {self.table_name}
436
+ {where}
437
+ ORDER BY score {order}
438
+ FETCH APPROX FIRST :top_k ROWS ONLY
439
+ """
440
+ params["query_vec"] = _array.array("f", query_embedding)
441
+ params["top_k"] = top_k
442
+ with self._get_connection() as conn, conn.cursor() as cur:
443
+ cur.execute(sql, params)
444
+ rows = cur.fetchall()
445
+ return [OracleDocumentStore._row_to_document(r, with_score=True) for r in rows]
446
+
447
+ async def _embedding_retrieval_async(
448
+ self,
449
+ query_embedding: list[float],
450
+ *,
451
+ filters: dict[str, Any] | None = None,
452
+ top_k: int = 10,
453
+ ) -> list[Document]:
454
+ return await asyncio.to_thread(
455
+ self._embedding_retrieval,
456
+ query_embedding,
457
+ filters=filters,
458
+ top_k=top_k,
459
+ )
460
+
461
+ @staticmethod
462
+ def _row_to_document(row: tuple, *, with_score: bool = False) -> Document:
463
+ if with_score:
464
+ raw_id, text, metadata_raw, score = row
465
+ else:
466
+ raw_id, text, metadata_raw, score = *row, None
467
+
468
+ # oracledb returns CLOB/JSON as LOB objects — read them to strings
469
+ if hasattr(text, "read"):
470
+ text = text.read()
471
+ if hasattr(metadata_raw, "read"):
472
+ metadata_raw = metadata_raw.read()
473
+
474
+ if isinstance(metadata_raw, str):
475
+ meta = json.loads(metadata_raw)
476
+ elif isinstance(metadata_raw, dict):
477
+ meta = metadata_raw
478
+ else:
479
+ meta = {}
480
+
481
+ return Document(
482
+ id=raw_id,
483
+ content=text,
484
+ meta=meta,
485
+ score=float(score) if score is not None else None,
486
+ embedding=None,
487
+ blob=None,
488
+ )
489
+
490
+ def to_dict(self) -> dict[str, Any]:
491
+ """
492
+ Serializes the component to a dictionary.
493
+
494
+ :returns:
495
+ Dictionary with serialized data.
496
+ """
497
+ return default_to_dict(
498
+ self,
499
+ connection_config=self.connection_config.to_dict(),
500
+ table_name=self.table_name,
501
+ embedding_dim=self.embedding_dim,
502
+ distance_metric=self.distance_metric,
503
+ create_table_if_not_exists=self.create_table_if_not_exists,
504
+ create_index=self.create_index,
505
+ hnsw_neighbors=self.hnsw_neighbors,
506
+ hnsw_ef_construction=self.hnsw_ef_construction,
507
+ hnsw_accuracy=self.hnsw_accuracy,
508
+ hnsw_parallel=self.hnsw_parallel,
509
+ )
510
+
511
+ @classmethod
512
+ def from_dict(cls, data: dict[str, Any]) -> "OracleDocumentStore":
513
+ """
514
+ Deserializes the component from a dictionary.
515
+
516
+ :param data:
517
+ Dictionary to deserialize from.
518
+ :returns:
519
+ Deserialized component.
520
+ """
521
+ params = data.get("init_parameters", {})
522
+ if "connection_config" in params:
523
+ params["connection_config"] = OracleConnectionConfig.from_dict(params["connection_config"])
524
+ return default_from_dict(cls, data)
@@ -0,0 +1,147 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from datetime import datetime
6
+ from typing import Any, ClassVar
7
+
8
+ from haystack.errors import FilterError
9
+
10
+ _RANGE_OPS = {">", ">=", "<", "<="}
11
+
12
+
13
+ class FilterTranslator:
14
+ """
15
+ Translates Haystack 2.x filter dicts into Oracle SQL WHERE fragments.
16
+
17
+ Example input:
18
+ {"operator": "AND", "conditions": [
19
+ {"field": "meta.author", "operator": "==", "value": "Alice"},
20
+ {"field": "meta.year", "operator": ">", "value": 2020},
21
+ ]}
22
+
23
+ Example output SQL fragment:
24
+ (JSON_VALUE(metadata, '$.author') = :p0
25
+ AND TO_NUMBER(JSON_VALUE(metadata, '$.year')) > :p1)
26
+
27
+ Params dict is mutated in-place; caller passes an empty dict and uses it
28
+ for cursor.execute / cursor.executemany bindings.
29
+ """
30
+
31
+ _OP_MAP: ClassVar[dict[str, str]] = {
32
+ "==": "=",
33
+ "!=": "!=",
34
+ ">": ">",
35
+ ">=": ">=",
36
+ "<": "<",
37
+ "<=": "<=",
38
+ }
39
+
40
+ def translate(self, filters: dict[str, Any], params: dict[str, Any], counter: list[int]) -> str:
41
+ """
42
+ Translate the given filter dict into an SQL fragment, adding any necessary parameters to the params dict.
43
+ """
44
+ op = filters.get("operator")
45
+
46
+ # Logical nodes
47
+ if op in ("AND", "OR", "NOT"):
48
+ if "conditions" not in filters:
49
+ msg = f"'conditions' key missing in logical filter: {filters}"
50
+ raise FilterError(msg)
51
+ if op == "AND":
52
+ parts = [self.translate(c, params, counter) for c in filters["conditions"]]
53
+ return "(" + " AND ".join(parts) + ")"
54
+ if op == "OR":
55
+ parts = [self.translate(c, params, counter) for c in filters["conditions"]]
56
+ return "(" + " OR ".join(parts) + ")"
57
+ # NOT
58
+ inner = self.translate(filters["conditions"][0], params, counter)
59
+ return f"(NOT {inner})"
60
+
61
+ # Comparison leaf — validate required keys first
62
+ if "field" not in filters:
63
+ msg = f"'field' key missing in comparison filter: {filters}"
64
+ raise FilterError(msg)
65
+ if "operator" not in filters:
66
+ msg = f"'operator' key missing in comparison filter: {filters}"
67
+ raise FilterError(msg)
68
+ if "value" not in filters:
69
+ msg = f"'value' key missing in comparison filter: {filters}"
70
+ raise FilterError(msg)
71
+
72
+ if not isinstance(op, str) or op not in {*self._OP_MAP, "in", "not in"}:
73
+ msg = f"Unsupported filter operator: {op!r}"
74
+ raise FilterError(msg)
75
+
76
+ field: str = filters["field"]
77
+ value: Any = filters["value"]
78
+
79
+ if op in ("in", "not in"):
80
+ if not isinstance(value, list):
81
+ msg = f"'in' / 'not in' filter values must be a list, got {type(value).__name__!r}"
82
+ raise FilterError(msg)
83
+ col = FilterTranslator._field_to_sql(field, value[0] if value else value)
84
+ placeholders = []
85
+ for v in value:
86
+ pname = f"p{counter[0]}"
87
+ counter[0] += 1
88
+ params[pname] = v
89
+ placeholders.append(f":{pname}")
90
+ if op == "in":
91
+ return f"{col} IN ({', '.join(placeholders)})"
92
+ # NOT IN: include rows where the column is NULL, matching Python's
93
+ # `None not in [...]` == True semantics.
94
+ return f"({col} IS NULL OR {col} NOT IN ({', '.join(placeholders)}))"
95
+
96
+ if op in _RANGE_OPS and (isinstance(value, (str, list)) and not _is_iso_date(value)):
97
+ msg = f"Operator {op!r} requires a numeric or ISO-date value, got {type(value).__name__!r}: {value!r}"
98
+ raise FilterError(msg)
99
+
100
+ col = FilterTranslator._field_to_sql(field, value)
101
+
102
+ # NULL-safe equality / inequality
103
+ if op == "==" and value is None:
104
+ return f"{col} IS NULL"
105
+ if op == "!=" and value is None:
106
+ return f"{col} IS NOT NULL"
107
+
108
+ pname = f"p{counter[0]}"
109
+ counter[0] += 1
110
+ params[pname] = value
111
+
112
+ if op == "!=":
113
+ # Include rows where the column is NULL, matching Python's
114
+ # `None != x` == True semantics.
115
+ return f"({col} != :{pname} OR {col} IS NULL)"
116
+
117
+ sql_op = self._OP_MAP[op]
118
+ return f"{col} {sql_op} :{pname}"
119
+
120
+ @staticmethod
121
+ def _field_to_sql(field: str, value: Any) -> str:
122
+ if field == "id":
123
+ return "id"
124
+ if field == "content":
125
+ return "text"
126
+ if field.startswith("meta."):
127
+ key = field[len("meta.") :]
128
+ json_path = f"JSON_VALUE(metadata, '$.{key}')"
129
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
130
+ return f"TO_NUMBER({json_path})"
131
+ return json_path
132
+ # Fallback: treat as top-level JSON key
133
+ json_path = f"JSON_VALUE(metadata, '$.{field}')"
134
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
135
+ return f"TO_NUMBER({json_path})"
136
+ return json_path
137
+
138
+
139
+ def _is_iso_date(value: Any) -> bool:
140
+ """Return True if *value* is a string that Python recognises as a valid ISO-8601 datetime."""
141
+ if not isinstance(value, str):
142
+ return False
143
+ try:
144
+ datetime.fromisoformat(value)
145
+ return True
146
+ except ValueError:
147
+ return False
File without changes
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: oracle-haystack
3
+ Version: 0.1.0
4
+ Summary: Oracle AI Vector Search DocumentStore integration for Haystack
5
+ Project-URL: Source Code, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/oracle
6
+ Project-URL: Bug Tracker, https://github.com/deepset-ai/haystack-core-integrations/issues
7
+ Author-email: deepset GmbH <info@deepset.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: OCI,RAG,document store,haystack,oracle,vector search
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: haystack-ai>=2.26.1
20
+ Requires-Dist: oracledb<3.0.0,>=2.1.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.9.0; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # oracle-haystack
29
+
30
+ [![PyPI - Version](https://img.shields.io/pypi/v/oracle-haystack.svg)](https://pypi.org/project/oracle-haystack)
31
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/oracle-haystack.svg)](https://pypi.org/project/oracle-haystack)
32
+
33
+ Haystack DocumentStore backed by [Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/), available in Oracle Database 23ai and later.
34
+
35
+ - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/oracle/CHANGELOG.md)
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install oracle-haystack
43
+ ```
44
+
45
+ Requires Python 3.10+ and Oracle Database 23ai (or later). No Oracle Instant Client is needed for direct TCP connections (thin mode).
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ from haystack.utils import Secret
51
+ from haystack_integrations.document_stores.oracle import OracleConnectionConfig, OracleDocumentStore
52
+ from haystack_integrations.components.retrievers.oracle import OracleEmbeddingRetriever
53
+
54
+ # Configure the connection
55
+ config = OracleConnectionConfig(
56
+ user=Secret.from_env_var("ORACLE_USER"),
57
+ password=Secret.from_env_var("ORACLE_PASSWORD"),
58
+ dsn=Secret.from_env_var("ORACLE_DSN"),
59
+ )
60
+
61
+ # Create the document store
62
+ store = OracleDocumentStore(
63
+ connection_config=config,
64
+ table_name="my_documents",
65
+ embedding_dim=768,
66
+ distance_metric="COSINE",
67
+ create_table_if_not_exists=True,
68
+ )
69
+
70
+ # Write documents
71
+ from haystack.dataclasses import Document
72
+ store.write_documents([
73
+ Document(content="Oracle 23ai supports native vector search."),
74
+ ])
75
+
76
+ # Retrieve by embedding
77
+ retriever = OracleEmbeddingRetriever(document_store=store, top_k=5)
78
+ results = retriever.run(query_embedding=[0.1] * 768)
79
+ print(results["documents"])
80
+ ```
81
+
82
+ ### Connecting to Oracle Autonomous Database (ADB-S / wallet)
83
+
84
+ ```python
85
+ config = OracleConnectionConfig(
86
+ user=Secret.from_env_var("ORACLE_USER"),
87
+ password=Secret.from_env_var("ORACLE_PASSWORD"),
88
+ dsn=Secret.from_env_var("ORACLE_DSN"),
89
+ wallet_location="/path/to/wallet",
90
+ wallet_password=Secret.from_env_var("WALLET_PASSWORD"),
91
+ )
92
+ ```
93
+
94
+ ### Optional HNSW index
95
+
96
+ Pass `create_index=True` when constructing the store to build an HNSW vector index, which dramatically speeds up approximate nearest-neighbour search on large collections:
97
+
98
+ ```python
99
+ store = OracleDocumentStore(
100
+ connection_config=config,
101
+ table_name="my_documents",
102
+ embedding_dim=768,
103
+ create_index=True,
104
+ hnsw_neighbors=32,
105
+ hnsw_ef_construction=200,
106
+ hnsw_accuracy=95,
107
+ )
108
+ ```
109
+
110
+ ## Contributing
111
+
112
+ Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
113
+
114
+ ### Running tests
115
+
116
+ #### Unit tests
117
+
118
+ ```bash
119
+ PYTHONPATH=src hatch run test:unit -vvv
120
+ ```
121
+
122
+ #### Integration tests against a live Oracle instance
123
+
124
+ Set `ORACLE_USER`, `ORACLE_PASSWORD`, and `ORACLE_DSN` environment variables to point at your Oracle 23ai instance, then:
125
+
126
+ ```bash
127
+ PYTHONPATH=src hatch run test:integration -vvv
128
+ ```
129
+
130
+ #### Integration tests via Docker (local Oracle 23ai Free)
131
+
132
+ A `docker-compose.yml` is provided that runs [`gvenzl/oracle-free:23-slim`](https://hub.docker.com/r/gvenzl/oracle-free) (Oracle Database 23ai Free edition).
133
+
134
+ ```bash
135
+ docker compose up -d --wait
136
+ ```
137
+
138
+ `--wait` blocks until the Oracle healthcheck passes (the first boot takes 2–4 minutes while Oracle initialises its data files).
139
+
140
+ Run the full integration test suite:
141
+
142
+ ```bash
143
+ PYTHONPATH=src hatch run test:integration -vvv
144
+ ```
@@ -0,0 +1,11 @@
1
+ haystack_integrations/components/retrievers/oracle/__init__.py,sha256=xTR2hHWAP0kL5BY3FAdDJwVquReJJwwTQL8jLoicceo,259
2
+ haystack_integrations/components/retrievers/oracle/embedding_retriever.py,sha256=RiHEAs38O9isIyyTcbIjx9G_8ngnRG1aeREnviF2lnE,4292
3
+ haystack_integrations/components/retrievers/oracle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ haystack_integrations/document_stores/oracle/__about__.py,sha256=LvXvcqIsISI-Jl3STUWpFpoRFUa3MSjcbkaw4vC42Sc,133
5
+ haystack_integrations/document_stores/oracle/__init__.py,sha256=CSjJlELEy6va2CaFrsbCj_Y9dMx841-NfFDhLlcN_UU,301
6
+ haystack_integrations/document_stores/oracle/document_store.py,sha256=ysjSXucIHHMfsJt44y_o7m8TSmvEELeT7Kw84-QkrgE,19764
7
+ haystack_integrations/document_stores/oracle/filters.py,sha256=Jmuvwf1puOd5Wej8eIzBCHrL9XIIVFFN5Al7f5-1qSI,5481
8
+ haystack_integrations/document_stores/oracle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ oracle_haystack-0.1.0.dist-info/METADATA,sha256=UyoJmZntP2YfETls1jtKfJvWP2ndOPzTBIiq6U9hiKc,4711
10
+ oracle_haystack-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ oracle_haystack-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any