langchain-google-spanner 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.
- langchain_google_spanner/__init__.py +37 -0
- langchain_google_spanner/chat_message_history.py +194 -0
- langchain_google_spanner/loader.py +454 -0
- langchain_google_spanner/py.typed +0 -0
- langchain_google_spanner/vector_store.py +1222 -0
- langchain_google_spanner/version.py +15 -0
- langchain_google_spanner-0.1.0.dist-info/LICENSE +202 -0
- langchain_google_spanner-0.1.0.dist-info/METADATA +347 -0
- langchain_google_spanner-0.1.0.dist-info/RECORD +11 -0
- langchain_google_spanner-0.1.0.dist-info/WHEEL +5 -0
- langchain_google_spanner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from langchain_google_spanner.chat_message_history import SpannerChatMessageHistory
|
|
16
|
+
from langchain_google_spanner.vector_store import (
|
|
17
|
+
DistanceStrategy,
|
|
18
|
+
QueryParameters,
|
|
19
|
+
SecondaryIndex,
|
|
20
|
+
SpannerVectorStore,
|
|
21
|
+
TableColumn,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .loader import SpannerDocumentSaver, SpannerLoader
|
|
25
|
+
from .version import __version__
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"__version__",
|
|
29
|
+
"SpannerChatMessageHistory",
|
|
30
|
+
"SpannerVectorStore",
|
|
31
|
+
"SpannerDocumentSaver",
|
|
32
|
+
"SpannerLoader",
|
|
33
|
+
"TableColumn",
|
|
34
|
+
"SecondaryIndex",
|
|
35
|
+
"QueryParameters",
|
|
36
|
+
"DistanceStrategy",
|
|
37
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Cloud Spanner-based chat message history"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from google.cloud import spanner
|
|
21
|
+
from google.cloud.spanner_admin_database_v1.types import DatabaseDialect # type: ignore
|
|
22
|
+
from google.cloud.spanner_v1 import param_types
|
|
23
|
+
from google.cloud.spanner_v1.data_types import JsonObject
|
|
24
|
+
from langchain_core.chat_history import BaseChatMessageHistory
|
|
25
|
+
from langchain_core.messages import BaseMessage, messages_from_dict
|
|
26
|
+
|
|
27
|
+
from .version import __version__
|
|
28
|
+
|
|
29
|
+
USER_AGENT_CHAT = "langchain-google-spanner-python:chat_history" + __version__
|
|
30
|
+
|
|
31
|
+
OPERATION_TIMEOUT_SECONDS = 240
|
|
32
|
+
|
|
33
|
+
COLUMN_FAMILY = "langchain"
|
|
34
|
+
COLUMN_NAME = "history"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def client_with_user_agent(
|
|
38
|
+
client: Optional[spanner.Client], user_agent: str
|
|
39
|
+
) -> spanner.Client:
|
|
40
|
+
if not client:
|
|
41
|
+
client = spanner.Client()
|
|
42
|
+
client_agent = client._client_info.user_agent
|
|
43
|
+
if not client_agent:
|
|
44
|
+
client._client_info.user_agent = user_agent
|
|
45
|
+
elif user_agent not in client_agent:
|
|
46
|
+
client._client_info.user_agent = " ".join([client_agent, user_agent])
|
|
47
|
+
return client
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SpannerChatMessageHistory(BaseChatMessageHistory):
|
|
51
|
+
"""Chat message history that stores history in Spanner.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
instance_id: The Spanner instance to use for chat message history.
|
|
55
|
+
database_id: The Spanner database to use for chat message history.
|
|
56
|
+
table_name: The Spanner table to use for chat message history.
|
|
57
|
+
session_id: Optional. The existing session ID.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
instance_id: str,
|
|
63
|
+
database_id: str,
|
|
64
|
+
session_id: str,
|
|
65
|
+
table_name: str,
|
|
66
|
+
client: Optional[spanner.Client] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.instance_id = instance_id
|
|
69
|
+
self.database_id = database_id
|
|
70
|
+
self.session_id = session_id
|
|
71
|
+
self.table_name = table_name
|
|
72
|
+
self.client = client_with_user_agent(client, USER_AGENT_CHAT)
|
|
73
|
+
self.instance = self.client.instance(instance_id)
|
|
74
|
+
if not self.instance.exists():
|
|
75
|
+
raise Exception("Instance doesn't exist.")
|
|
76
|
+
self.database = self.instance.database(database_id)
|
|
77
|
+
if not self.database.exists():
|
|
78
|
+
raise Exception("Database doesn't exist.")
|
|
79
|
+
self.database.reload()
|
|
80
|
+
self.dialect = self.database.database_dialect
|
|
81
|
+
self._verify_schema()
|
|
82
|
+
|
|
83
|
+
def _verify_schema(self) -> None:
|
|
84
|
+
"""Verify table exists with required schema for SpannerChatMessageHistory class.
|
|
85
|
+
Use helper method MSSQLEngine.create_chat_history_table(...) to create
|
|
86
|
+
table with valid schema.
|
|
87
|
+
"""
|
|
88
|
+
# check table exists
|
|
89
|
+
column_names = [] # type: List[str]
|
|
90
|
+
with self.database.snapshot() as snapshot:
|
|
91
|
+
results = snapshot.execute_sql(
|
|
92
|
+
f"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.columns WHERE table_name = '{self.table_name}'"
|
|
93
|
+
)
|
|
94
|
+
for row in results:
|
|
95
|
+
column_names.append(*row)
|
|
96
|
+
|
|
97
|
+
# check that all required columns are present
|
|
98
|
+
required_columns = ["id", "session_id", "created_at", "message"]
|
|
99
|
+
if len(column_names) == 0:
|
|
100
|
+
raise AttributeError(
|
|
101
|
+
f"Table '{self.table_name}' does not exist. Please create "
|
|
102
|
+
"it before initializing SpannerChatMessageHistory. See "
|
|
103
|
+
"SpannerEngine.create_chat_history_table() for a helper method."
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
if not (all(x in column_names for x in required_columns)):
|
|
107
|
+
google_schema = f"""CREATE TABLE IF NOT EXISTS {self.table_name} (
|
|
108
|
+
id STRING(36) DEFAULT (GENERATE_UUID()),
|
|
109
|
+
created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
|
|
110
|
+
session_id STRING(MAX) NOT NULL,
|
|
111
|
+
message JSON NOT NULL,
|
|
112
|
+
) PRIMARY KEY (session_id, created_at ASC, id)"""
|
|
113
|
+
pg_schema = f"""CREATE TABLE IF NOT EXISTS {self.table_name} (
|
|
114
|
+
id varchar(36) DEFAULT (spanner.generate_uuid()),
|
|
115
|
+
created_at SPANNER.COMMIT_TIMESTAMP NOT NULL,
|
|
116
|
+
session_id TEXT NOT NULL,
|
|
117
|
+
message JSONB NOT NULL,
|
|
118
|
+
PRIMARY KEY (session_id, created_at, id)"""
|
|
119
|
+
ddl = (
|
|
120
|
+
pg_schema
|
|
121
|
+
if self.dialect == DatabaseDialect.POSTGRESQL
|
|
122
|
+
else google_schema
|
|
123
|
+
)
|
|
124
|
+
raise IndexError(
|
|
125
|
+
f"Table '{self.table_name}' has incorrect schema. Got "
|
|
126
|
+
f"column names '{column_names}' but required column names "
|
|
127
|
+
f"'{required_columns}'.\nPlease create table with following schema:"
|
|
128
|
+
f"{ddl};"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def create_chat_history_table(self) -> None:
|
|
132
|
+
google_schema = f"""CREATE TABLE IF NOT EXISTS {self.table_name} (
|
|
133
|
+
id STRING(36) DEFAULT (GENERATE_UUID()),
|
|
134
|
+
created_at TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
|
|
135
|
+
session_id STRING(MAX) NOT NULL,
|
|
136
|
+
message JSON NOT NULL,
|
|
137
|
+
) PRIMARY KEY (session_id, created_at ASC, id)"""
|
|
138
|
+
|
|
139
|
+
pg_schema = f"""CREATE TABLE IF NOT EXISTS {self.table_name} (
|
|
140
|
+
id varchar(36) DEFAULT (spanner.generate_uuid()),
|
|
141
|
+
created_at SPANNER.COMMIT_TIMESTAMP NOT NULL,
|
|
142
|
+
session_id TEXT NOT NULL,
|
|
143
|
+
message JSONB NOT NULL,
|
|
144
|
+
PRIMARY KEY (session_id, created_at, id)
|
|
145
|
+
);"""
|
|
146
|
+
|
|
147
|
+
ddl = pg_schema if self.dialect == DatabaseDialect.POSTGRESQL else google_schema
|
|
148
|
+
database = self.client.instance(self.instance_id).database(self.database_id)
|
|
149
|
+
operation = database.update_ddl([ddl])
|
|
150
|
+
operation.result(OPERATION_TIMEOUT_SECONDS)
|
|
151
|
+
return operation
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def messages(self) -> List[BaseMessage]: # type: ignore
|
|
155
|
+
"""Retrieve the messages from Cloud Spanner"""
|
|
156
|
+
place_holder = "$1" if self.dialect == DatabaseDialect.POSTGRESQL else "@p1"
|
|
157
|
+
query = f"SELECT message FROM {self.table_name} WHERE session_id = {place_holder} ORDER BY created_at;"
|
|
158
|
+
param = {"p1": self.session_id}
|
|
159
|
+
param_type = {"p1": param_types.STRING}
|
|
160
|
+
|
|
161
|
+
with self.database.snapshot() as snapshot:
|
|
162
|
+
results = snapshot.execute_sql(
|
|
163
|
+
query,
|
|
164
|
+
params=param,
|
|
165
|
+
param_types=param_type,
|
|
166
|
+
)
|
|
167
|
+
items = [] # type: List[dict]
|
|
168
|
+
for row in results:
|
|
169
|
+
items.append({"data": row[0], "type": row[0]["type"]})
|
|
170
|
+
messages = messages_from_dict(items)
|
|
171
|
+
return messages
|
|
172
|
+
|
|
173
|
+
def add_message(self, message: BaseMessage) -> None:
|
|
174
|
+
"""Append the message to the record in Cloud Spanner"""
|
|
175
|
+
with self.database.batch() as batch:
|
|
176
|
+
batch.insert(
|
|
177
|
+
table=self.table_name,
|
|
178
|
+
columns=("session_id", "created_at", "message"),
|
|
179
|
+
values=[
|
|
180
|
+
(
|
|
181
|
+
self.session_id,
|
|
182
|
+
spanner.COMMIT_TIMESTAMP,
|
|
183
|
+
JsonObject(message.dict()),
|
|
184
|
+
),
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def clear(self) -> None:
|
|
189
|
+
"""Clear session memory from Cloud Spanner"""
|
|
190
|
+
place_holder = "$1" if self.dialect == DatabaseDialect.POSTGRESQL else "@p1"
|
|
191
|
+
query = f"DELETE FROM {self.table_name} WHERE session_id = {place_holder};"
|
|
192
|
+
param = {"p1": self.session_id}
|
|
193
|
+
param_type = {"p1": param_types.STRING}
|
|
194
|
+
self.database.execute_partitioned_dml(query, param, param_type)
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# Copyright 2024 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Dict, Iterator, List, Optional, Union
|
|
19
|
+
|
|
20
|
+
from google.cloud.spanner import Client, KeySet # type: ignore
|
|
21
|
+
from google.cloud.spanner_admin_database_v1.types import DatabaseDialect # type: ignore
|
|
22
|
+
from google.cloud.spanner_v1.data_types import JsonObject # type: ignore
|
|
23
|
+
from langchain_community.document_loaders.base import BaseLoader
|
|
24
|
+
from langchain_core.documents import Document
|
|
25
|
+
|
|
26
|
+
from .version import __version__
|
|
27
|
+
|
|
28
|
+
USER_AGENT_LOADER = "langchain-google-spanner-python:document_loader" + __version__
|
|
29
|
+
USER_AGENT_SAVER = "langchain-google-spanner-python:document_saver" + __version__
|
|
30
|
+
|
|
31
|
+
OPERATION_TIMEOUT_SECONDS = 240
|
|
32
|
+
MUTATION_BATCH_SIZE = 1000
|
|
33
|
+
|
|
34
|
+
CONTENT_COL_NAME = "page_content"
|
|
35
|
+
METADATA_COL_NAME = "langchain_metadata"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Column:
|
|
40
|
+
name: str
|
|
41
|
+
data_type: str
|
|
42
|
+
nullable: bool = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def client_with_user_agent(client: Optional[Client], user_agent: str) -> Client:
|
|
46
|
+
if not client:
|
|
47
|
+
client = Client()
|
|
48
|
+
|
|
49
|
+
client_agent = client._client_info.user_agent
|
|
50
|
+
if not client_agent:
|
|
51
|
+
client._client_info.user_agent = user_agent
|
|
52
|
+
elif user_agent not in client_agent:
|
|
53
|
+
client._client_info.user_agent = " ".join([client_agent, user_agent])
|
|
54
|
+
return client
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_row_to_doc(
|
|
58
|
+
format: str,
|
|
59
|
+
content_columns: List[str],
|
|
60
|
+
metadata_columns: List[str],
|
|
61
|
+
metadata_json_column: str,
|
|
62
|
+
row: Dict,
|
|
63
|
+
) -> Document:
|
|
64
|
+
page_content = ""
|
|
65
|
+
if format == "text":
|
|
66
|
+
page_content = " ".join(str(row[c]) for c in content_columns if row[c])
|
|
67
|
+
elif format == "YAML":
|
|
68
|
+
page_content = "\n".join(f"{c}: {str(row[c])}" for c in content_columns)
|
|
69
|
+
elif format == "JSON":
|
|
70
|
+
j = {}
|
|
71
|
+
for c in content_columns:
|
|
72
|
+
j[c] = row[c]
|
|
73
|
+
page_content = json.dumps(j)
|
|
74
|
+
elif format == "CSV":
|
|
75
|
+
page_content = ", ".join(str(row[c]) for c in content_columns if row[c])
|
|
76
|
+
|
|
77
|
+
metadata: Dict[str, Any] = {}
|
|
78
|
+
if metadata_json_column in metadata_columns and row.get(metadata_json_column):
|
|
79
|
+
metadata = row[metadata_json_column]
|
|
80
|
+
for c in metadata_columns:
|
|
81
|
+
if c != metadata_json_column:
|
|
82
|
+
metadata[c] = row[c]
|
|
83
|
+
|
|
84
|
+
return Document(page_content=page_content, metadata=metadata)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_doc_to_row(
|
|
88
|
+
table_fields: List[str],
|
|
89
|
+
doc: Document,
|
|
90
|
+
content_column: str,
|
|
91
|
+
metadata_json_column: str,
|
|
92
|
+
parse_json: bool = True,
|
|
93
|
+
) -> tuple:
|
|
94
|
+
"""
|
|
95
|
+
Load document to row.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
table_fields: Spanner table fields names.
|
|
99
|
+
doc: Document that is used.
|
|
100
|
+
content_column: Name of the content column.
|
|
101
|
+
metadata_json_column: Name of the special JSON column.
|
|
102
|
+
parse_json: Parse json column to string or leave it as JSON object. String format is needed to for Spanner inserts.
|
|
103
|
+
JSON object is used to compare with Spanner reads.
|
|
104
|
+
"""
|
|
105
|
+
doc_metadata = doc.metadata.copy()
|
|
106
|
+
row = []
|
|
107
|
+
for col in table_fields:
|
|
108
|
+
if (
|
|
109
|
+
col != content_column
|
|
110
|
+
and col != metadata_json_column
|
|
111
|
+
and col in doc_metadata
|
|
112
|
+
):
|
|
113
|
+
row.append(doc_metadata[col])
|
|
114
|
+
del doc_metadata[col]
|
|
115
|
+
if col == content_column:
|
|
116
|
+
row.append(doc.page_content)
|
|
117
|
+
|
|
118
|
+
if metadata_json_column in table_fields:
|
|
119
|
+
metadata_json = {}
|
|
120
|
+
if metadata_json_column in doc_metadata:
|
|
121
|
+
metadata_json = doc_metadata[metadata_json_column]
|
|
122
|
+
del doc_metadata[metadata_json_column]
|
|
123
|
+
metadata_json = {**metadata_json, **doc_metadata}
|
|
124
|
+
j = json.dumps(metadata_json) if parse_json else metadata_json
|
|
125
|
+
row.append(j) # type: ignore
|
|
126
|
+
|
|
127
|
+
return tuple(row)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _batch(datas: List[Any], size: int = 1) -> Iterator[List[Any]]:
|
|
131
|
+
data_length = len(datas)
|
|
132
|
+
for current in range(0, data_length, size):
|
|
133
|
+
yield datas[current : min(current + size, data_length)]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SpannerLoader(BaseLoader):
|
|
137
|
+
"""Loads data from Google Cloud Spanner."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
instance_id: str,
|
|
142
|
+
database_id: str,
|
|
143
|
+
query: str,
|
|
144
|
+
content_columns: List[str] = [],
|
|
145
|
+
metadata_columns: List[str] = [],
|
|
146
|
+
format: str = "text",
|
|
147
|
+
databoost: bool = False,
|
|
148
|
+
metadata_json_column: str = METADATA_COL_NAME,
|
|
149
|
+
staleness: Union[float, datetime.datetime] = 0.0,
|
|
150
|
+
client: Optional[Client] = None,
|
|
151
|
+
):
|
|
152
|
+
"""Initialize Spanner document loader.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
instance_id: The Spanner instance to load data from.
|
|
156
|
+
database_id: The Spanner database to load data from.
|
|
157
|
+
query: A GoogleSQL or PostgreSQL query. Users must match dialect to their database.
|
|
158
|
+
content_columns: The list of column(s) or field(s) to use for a Document's page content.
|
|
159
|
+
Page content is the default field for embeddings generation.
|
|
160
|
+
metadata_columns: The list of column(s) or field(s) to use for metadata.
|
|
161
|
+
format: Set the format of page content if using multiple columns or fields.
|
|
162
|
+
Format included: 'text', 'JSON', 'YAML', 'CSV'.
|
|
163
|
+
databoost: Use data boost on read. Note: needs extra IAM permissions and higher cost.
|
|
164
|
+
metadata_json_column: The name of the JSON column to use as the metadata's base dictionary.
|
|
165
|
+
staleness: The time bound for stale read. Takes either a datetime or float.
|
|
166
|
+
client: The connection object to use. This can be used to customize project id and credentials.
|
|
167
|
+
"""
|
|
168
|
+
self.instance_id = instance_id
|
|
169
|
+
self.database_id = database_id
|
|
170
|
+
self.query = query
|
|
171
|
+
self.content_columns = content_columns
|
|
172
|
+
self.metadata_columns = metadata_columns
|
|
173
|
+
self.format = format
|
|
174
|
+
self.metadata_json_column = metadata_json_column
|
|
175
|
+
self.databoost = databoost
|
|
176
|
+
self.client = client_with_user_agent(client, USER_AGENT_LOADER)
|
|
177
|
+
self.staleness = staleness
|
|
178
|
+
|
|
179
|
+
formats = ["JSON", "text", "YAML", "CSV"]
|
|
180
|
+
if self.format not in formats:
|
|
181
|
+
raise Exception("Use one of 'text', 'JSON', 'YAML', 'CSV'.")
|
|
182
|
+
|
|
183
|
+
instance = self.client.instance(instance_id)
|
|
184
|
+
if not instance.exists():
|
|
185
|
+
raise Exception("Instance doesn't exist.")
|
|
186
|
+
|
|
187
|
+
database = instance.database(database_id)
|
|
188
|
+
if not database.exists():
|
|
189
|
+
raise Exception("Database doesn't exist.")
|
|
190
|
+
|
|
191
|
+
def load(self) -> List[Document]:
|
|
192
|
+
"""
|
|
193
|
+
Load langchain documents from a Spanner database.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
(List[langchain_core.documents.Document]): a list of Documents with metadata
|
|
197
|
+
from specific columns.
|
|
198
|
+
"""
|
|
199
|
+
return list(self.lazy_load())
|
|
200
|
+
|
|
201
|
+
def lazy_load(self) -> Iterator[Document]:
|
|
202
|
+
"""
|
|
203
|
+
A lazy loader for langchain documents from a Spanner database. Use lazy load to avoid
|
|
204
|
+
caching all documents in memory at once.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
(Iterator[langchain_core.documents.Document]): a list of Documents with metadata
|
|
208
|
+
from specific columns.
|
|
209
|
+
"""
|
|
210
|
+
instance = self.client.instance(self.instance_id)
|
|
211
|
+
db = instance.database(self.database_id)
|
|
212
|
+
timestamp = (
|
|
213
|
+
self.staleness.replace(tzinfo=datetime.timezone.utc)
|
|
214
|
+
if type(self.staleness) is datetime.datetime
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
duration = (
|
|
218
|
+
datetime.timedelta(seconds=self.staleness)
|
|
219
|
+
if type(self.staleness) is float
|
|
220
|
+
else None
|
|
221
|
+
)
|
|
222
|
+
snapshot = db.batch_snapshot(exact_staleness=duration, read_timestamp=timestamp)
|
|
223
|
+
partitions = snapshot.generate_query_batches(
|
|
224
|
+
sql=self.query, data_boost_enabled=self.databoost
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
for partition in partitions:
|
|
228
|
+
r = snapshot.process_query_batch(partition)
|
|
229
|
+
results = r.to_dict_list()
|
|
230
|
+
if len(results) == 0:
|
|
231
|
+
break
|
|
232
|
+
column_names = [f.name for f in r.fields]
|
|
233
|
+
content_columns = self.content_columns or [column_names[0]]
|
|
234
|
+
metadata_columns = self.metadata_columns or [
|
|
235
|
+
col for col in column_names if col not in content_columns
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
for row in results:
|
|
239
|
+
yield _load_row_to_doc(
|
|
240
|
+
self.format,
|
|
241
|
+
content_columns,
|
|
242
|
+
metadata_columns,
|
|
243
|
+
self.metadata_json_column,
|
|
244
|
+
row,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class SpannerDocumentSaver:
|
|
249
|
+
"""Save docs to Google Cloud Spanner."""
|
|
250
|
+
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
instance_id: str,
|
|
254
|
+
database_id: str,
|
|
255
|
+
table_name: str,
|
|
256
|
+
content_column: str = CONTENT_COL_NAME,
|
|
257
|
+
metadata_columns: List[str] = [],
|
|
258
|
+
metadata_json_column: str = METADATA_COL_NAME,
|
|
259
|
+
primary_key: Optional[str] = None,
|
|
260
|
+
client: Optional[Client] = None,
|
|
261
|
+
):
|
|
262
|
+
"""Initialize Spanner document saver.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
instance_id: The Spanner instance to load data to.
|
|
266
|
+
database_id: The Spanner database to load data to.
|
|
267
|
+
table_name: The table name to load data to.
|
|
268
|
+
content_column: The name of the content column. Defaulted to the first column.
|
|
269
|
+
metadata_columns: This is for user to opt-in a selection of columns to use. Defaulted to use
|
|
270
|
+
all columns.
|
|
271
|
+
metadata_json_column: The name of the special JSON column. Defaulted to use "langchain_metadata".
|
|
272
|
+
client: The connection object to use. This can be used to customized project id and credentials.
|
|
273
|
+
"""
|
|
274
|
+
self.instance_id = instance_id
|
|
275
|
+
self.database_id = database_id
|
|
276
|
+
self.table_name = table_name
|
|
277
|
+
self.content_column = content_column
|
|
278
|
+
self.metadata_columns = metadata_columns
|
|
279
|
+
self.metadata_json_column = metadata_json_column
|
|
280
|
+
self.primary_key = primary_key or self.content_column
|
|
281
|
+
self.client = client_with_user_agent(client, USER_AGENT_SAVER)
|
|
282
|
+
|
|
283
|
+
instance = self.client.instance(instance_id)
|
|
284
|
+
if not instance.exists():
|
|
285
|
+
raise Exception("Instance doesn't exist.")
|
|
286
|
+
|
|
287
|
+
database = instance.database(database_id)
|
|
288
|
+
if not database.exists():
|
|
289
|
+
raise Exception("Database doesn't exist.")
|
|
290
|
+
|
|
291
|
+
database.reload()
|
|
292
|
+
self.dialect = database.database_dialect
|
|
293
|
+
|
|
294
|
+
table = database.table(table_name)
|
|
295
|
+
if not table.exists():
|
|
296
|
+
raise Exception(
|
|
297
|
+
"Table doesn't exist. Create table with SpannerDocumentSaver.init_document_table function."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
self._table_fields = [self.primary_key]
|
|
301
|
+
for n in table.schema:
|
|
302
|
+
if n.name != metadata_json_column and n.name != self.primary_key:
|
|
303
|
+
self._table_fields.append(n.name)
|
|
304
|
+
self._table_fields.append(metadata_json_column)
|
|
305
|
+
|
|
306
|
+
def add_documents(self, documents: List[Document]):
|
|
307
|
+
"""Add documents to the Spanner table."""
|
|
308
|
+
db = self.client.instance(self.instance_id).database(self.database_id)
|
|
309
|
+
values = [
|
|
310
|
+
_load_doc_to_row(
|
|
311
|
+
self._table_fields, doc, self.content_column, self.metadata_json_column
|
|
312
|
+
)
|
|
313
|
+
for doc in documents
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
for values_batch in _batch(values, MUTATION_BATCH_SIZE):
|
|
317
|
+
with db.batch() as batch:
|
|
318
|
+
batch.insert(
|
|
319
|
+
table=self.table_name,
|
|
320
|
+
columns=self._table_fields,
|
|
321
|
+
values=values_batch,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def delete(self, documents: List[Document]):
|
|
325
|
+
"""Delete documents from the table."""
|
|
326
|
+
database = self.client.instance(self.instance_id).database(self.database_id)
|
|
327
|
+
# load documents to row
|
|
328
|
+
docs = [
|
|
329
|
+
_load_doc_to_row(
|
|
330
|
+
self._table_fields,
|
|
331
|
+
doc,
|
|
332
|
+
self.content_column,
|
|
333
|
+
self.metadata_json_column,
|
|
334
|
+
False,
|
|
335
|
+
)
|
|
336
|
+
for doc in documents
|
|
337
|
+
]
|
|
338
|
+
keys = [[doc[0]] for doc in docs]
|
|
339
|
+
docs_keys = KeySet(keys=keys)
|
|
340
|
+
snapshot = database.batch_snapshot()
|
|
341
|
+
partitions = snapshot.generate_read_batches(
|
|
342
|
+
table=self.table_name,
|
|
343
|
+
columns=tuple(self._table_fields),
|
|
344
|
+
keyset=docs_keys,
|
|
345
|
+
partition_size_bytes=5000000,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
for partition in partitions:
|
|
349
|
+
keys_to_delete = []
|
|
350
|
+
for row in snapshot.process_read_batch(partition):
|
|
351
|
+
# compare whole document
|
|
352
|
+
if tuple(row) in docs:
|
|
353
|
+
keys_to_delete.append([row[0]])
|
|
354
|
+
with database.batch() as batch:
|
|
355
|
+
docs_to_delete = KeySet(keys=keys_to_delete)
|
|
356
|
+
batch.delete(self.table_name, docs_to_delete)
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def init_document_table(
|
|
360
|
+
instance_id: str,
|
|
361
|
+
database_id: str,
|
|
362
|
+
table_name: str,
|
|
363
|
+
content_column: str = CONTENT_COL_NAME,
|
|
364
|
+
metadata_columns: List[Column] = [],
|
|
365
|
+
primary_key: str = "",
|
|
366
|
+
store_metadata: bool = True,
|
|
367
|
+
metadata_json_column: str = METADATA_COL_NAME,
|
|
368
|
+
):
|
|
369
|
+
"""
|
|
370
|
+
Create a new table to store docs with a custom schema.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
instance_id: The Spanner instance to load data to.
|
|
374
|
+
database_id: The Spanner database to load data to.
|
|
375
|
+
table_name: The table name to load data to.
|
|
376
|
+
content_column: The name of the content column.
|
|
377
|
+
metadata_columns: The metadata columns for custom schema.
|
|
378
|
+
primary_key: The name of the primary key.
|
|
379
|
+
store_metadata: If true, extra metadata will be stored in the "langchain_metadata" column.
|
|
380
|
+
Defaulted to true.
|
|
381
|
+
metadata_json_column: The name of the special JSON column. Defaulted to use "langchain_metadata".
|
|
382
|
+
"""
|
|
383
|
+
client = Client()
|
|
384
|
+
primary_key = primary_key or content_column
|
|
385
|
+
metadata_json_column = metadata_json_column if store_metadata else ""
|
|
386
|
+
|
|
387
|
+
instance = client.instance(instance_id)
|
|
388
|
+
if not instance.exists():
|
|
389
|
+
raise Exception("Instance doesn't exist.")
|
|
390
|
+
|
|
391
|
+
database = instance.database(database_id)
|
|
392
|
+
if not database.exists():
|
|
393
|
+
raise Exception("Database doesn't exist.")
|
|
394
|
+
|
|
395
|
+
# create table with custom schema
|
|
396
|
+
SpannerDocumentSaver.create_table(
|
|
397
|
+
client,
|
|
398
|
+
instance_id,
|
|
399
|
+
database_id,
|
|
400
|
+
table_name,
|
|
401
|
+
primary_key,
|
|
402
|
+
metadata_json_column,
|
|
403
|
+
content_column,
|
|
404
|
+
metadata_columns,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def create_table(
|
|
409
|
+
client: Client,
|
|
410
|
+
instance_id: str,
|
|
411
|
+
database_id: str,
|
|
412
|
+
table_name: str,
|
|
413
|
+
primary_key: str,
|
|
414
|
+
metadata_json_column: str,
|
|
415
|
+
content_column: str,
|
|
416
|
+
metadata_columns: List[Column],
|
|
417
|
+
):
|
|
418
|
+
"""
|
|
419
|
+
Create a new table in Spanner database.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
client: The connection object to use.
|
|
423
|
+
instance_id: The Spanner instance to load data to.
|
|
424
|
+
database_id: The Spanner database to load data to.
|
|
425
|
+
table_name: The table name to load data to.
|
|
426
|
+
primary_key: The name of the primary key for the table.
|
|
427
|
+
metadata_json_column: The name of the special JSON column.
|
|
428
|
+
content_column: The name of the content column.
|
|
429
|
+
metadata_columns: The metadata columns for custom schema.
|
|
430
|
+
"""
|
|
431
|
+
database = client.instance(instance_id).database(database_id)
|
|
432
|
+
database.reload()
|
|
433
|
+
dialect = database.database_dialect
|
|
434
|
+
|
|
435
|
+
ddl = f"CREATE TABLE {table_name} ("
|
|
436
|
+
if dialect == DatabaseDialect.POSTGRESQL:
|
|
437
|
+
ddl += f"{content_column} VARCHAR(1024) NOT NULL,"
|
|
438
|
+
for col in metadata_columns:
|
|
439
|
+
null_string = " NOT NULL" if col.nullable else ""
|
|
440
|
+
ddl += f"{col.name} {col.data_type}{null_string},"
|
|
441
|
+
if metadata_json_column:
|
|
442
|
+
ddl += f"{metadata_json_column} JSONb NOT NULL,"
|
|
443
|
+
ddl += f"PRIMARY KEY ({primary_key}));"
|
|
444
|
+
else:
|
|
445
|
+
ddl += f"{content_column} STRING(1024) NOT NULL,"
|
|
446
|
+
for col in metadata_columns:
|
|
447
|
+
null_string = " NOT NULL" if col.nullable else ""
|
|
448
|
+
ddl += f"{col.name} {col.data_type}{null_string},"
|
|
449
|
+
if metadata_json_column:
|
|
450
|
+
ddl += f"{metadata_json_column} JSON NOT NULL,"
|
|
451
|
+
ddl += f") PRIMARY KEY ({primary_key})"
|
|
452
|
+
|
|
453
|
+
operation = database.update_ddl([ddl])
|
|
454
|
+
operation.result(OPERATION_TIMEOUT_SECONDS)
|
|
File without changes
|