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.
- haystack_integrations/components/retrievers/oracle/__init__.py +7 -0
- haystack_integrations/components/retrievers/oracle/embedding_retriever.py +119 -0
- haystack_integrations/components/retrievers/oracle/py.typed +0 -0
- haystack_integrations/document_stores/oracle/__about__.py +5 -0
- haystack_integrations/document_stores/oracle/__init__.py +10 -0
- haystack_integrations/document_stores/oracle/document_store.py +524 -0
- haystack_integrations/document_stores/oracle/filters.py +147 -0
- haystack_integrations/document_stores/oracle/py.typed +0 -0
- oracle_haystack-0.1.0.dist-info/METADATA +144 -0
- oracle_haystack-0.1.0.dist-info/RECORD +11 -0
- oracle_haystack-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)
|
|
File without changes
|
|
@@ -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
|
+
[](https://pypi.org/project/oracle-haystack)
|
|
31
|
+
[](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,,
|