-
Notifications
You must be signed in to change notification settings - Fork 449
Expand file tree
/
Copy pathdocument_index_writer.py
More file actions
312 lines (278 loc) · 11.4 KB
/
Copy pathdocument_index_writer.py
File metadata and controls
312 lines (278 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
"""Shared backend-owned OpenSearch document indexing.
Langflow can generate chunks and embeddings, but it must not hold credentials
that can write arbitrary documents. This writer is the single backend path for
indexing chunks into the documents index.
"""
from __future__ import annotations
import datetime
import json
from dataclasses import dataclass, field
from typing import Any
from utils.embedding_fields import ensure_embedding_field_exists
from utils.embeddings import create_index_body
from utils.group_acl import unique_acl_principal_labels, unique_acl_principals
from utils.logging_config import get_logger
logger = get_logger(__name__)
@dataclass
class DocumentIndexContext:
document_id: str
filename: str
mimetype: str
embedding_model: str
owner: str | None = None
owner_name: str | None = None
owner_email: str | None = None
file_size: int | None = None
connector_type: str | None = None
source_url: str | None = None
connector_file_id: str | None = None
allowed_users: list[str] = field(default_factory=list)
allowed_groups: list[str] = field(default_factory=list)
allowed_principals: list[str] = field(default_factory=list)
allowed_principal_labels: list[dict[str, Any]] = field(default_factory=list)
ingest_run_id: str | None = None
is_sample_data: bool = False
index_name: str | None = None
parser: str | None = None
chunk_size: int | None = None
chunk_overlap: int | None = None
@dataclass
class DocumentIndexChunk:
chunk_id: str
text: str
vector: list[float]
page: int | None = None
metadata: dict[str, Any] = field(default_factory=dict)
class DocumentIndexWriter:
"""Write document chunks with a trusted backend OpenSearch client."""
def __init__(self, opensearch_client: Any | None = None):
self.opensearch_client = opensearch_client
def _get_write_client(self) -> Any:
from config.settings import clients
client = self.opensearch_client or clients.opensearch
if client is None:
raise RuntimeError(
"Backend OpenSearch write client is unavailable; cannot index document chunks"
)
return client
async def index_chunks(
self,
context: DocumentIndexContext,
chunks: list[DocumentIndexChunk],
*,
final: bool = False,
refresh: bool | str = False,
) -> dict[str, Any]:
"""Index one batch of chunks.
Repeated calls with the same chunk ids are idempotent because the write
operation is an index/upsert.
"""
from config.settings import get_index_name
if not chunks:
if final:
await self._refresh(context.index_name or get_index_name())
return {"indexed_chunks": 0, "ingest_run_id": context.ingest_run_id}
first_vector = chunks[0].vector
if not first_vector:
raise ValueError("Cannot index chunks with empty embeddings")
dimensions = len(first_vector)
client = self._get_write_client()
index_name = context.index_name or get_index_name()
embedding_field = await self._ensure_index_and_embedding_field(
client,
index_name=index_name,
embedding_model=context.embedding_model,
dimensions=dimensions,
)
now = datetime.datetime.now(datetime.UTC).isoformat()
bulk_body: list[dict[str, Any]] = []
for chunk in chunks:
if len(chunk.vector) != dimensions:
raise ValueError(
"Embedding dimension mismatch in batch: "
f"expected {dimensions}, got {len(chunk.vector)} for {chunk.chunk_id}"
)
bulk_body.append({"index": {"_index": index_name, "_id": chunk.chunk_id}})
bulk_body.append(
self._build_chunk_document(
context=context,
chunk=chunk,
embedding_field=embedding_field,
indexed_time=now,
)
)
result = await client.bulk(body=bulk_body, refresh=refresh)
self._raise_for_bulk_errors(result)
if final:
await self._refresh(index_name)
logger.info(
"Indexed document chunks",
index_name=index_name,
document_id=context.document_id,
ingest_run_id=context.ingest_run_id,
chunk_count=len(chunks),
final=final,
)
return {
"indexed_chunks": len(chunks),
"ingest_run_id": context.ingest_run_id,
"document_id": context.document_id,
}
async def delete_ingest_run(self, ingest_run_id: str, *, index_name: str | None = None) -> int:
"""Delete partially indexed chunks for a failed callback run."""
if not ingest_run_id:
return 0
from config.settings import get_index_name
client = self._get_write_client()
resolved_index = index_name or get_index_name()
body = {"query": {"term": {"ingest_run_id": ingest_run_id}}}
response = await client.delete_by_query(
index=resolved_index,
body=body,
refresh=True,
conflicts="proceed",
)
deleted = int(response.get("deleted", 0)) if isinstance(response, dict) else 0
logger.info(
"Deleted failed ingest run chunks",
index_name=resolved_index,
ingest_run_id=ingest_run_id,
deleted=deleted,
)
return deleted
async def _ensure_index_and_embedding_field(
self,
client: Any,
*,
index_name: str,
embedding_model: str,
dimensions: int,
) -> str:
if not await client.indices.exists(index=index_name):
await client.indices.create(
index=index_name,
body=await create_index_body(embedding_model, dimensions),
)
return await ensure_embedding_field_exists(
client,
embedding_model,
index_name,
dimensions,
)
def _build_chunk_document(
self,
*,
context: DocumentIndexContext,
chunk: DocumentIndexChunk,
embedding_field: str,
indexed_time: str,
) -> dict[str, Any]:
metadata = self._normalized_metadata(chunk.metadata)
document_id = context.document_id or str(metadata.get("document_id") or chunk.chunk_id)
filename = context.filename or str(metadata.get("filename") or "")
mimetype = context.mimetype or str(metadata.get("mimetype") or "")
doc: dict[str, Any] = {
"document_id": document_id,
"filename": filename,
"mimetype": mimetype,
"page": chunk.page if chunk.page is not None else metadata.get("page", 0),
"text": chunk.text,
embedding_field: chunk.vector,
"embedding_model": context.embedding_model,
"embedding_dimensions": len(chunk.vector),
"file_size": context.file_size
if context.file_size is not None
else metadata.get("file_size"),
"connector_type": context.connector_type or metadata.get("connector_type") or "local",
"source_url": context.source_url or metadata.get("source_url") or "",
"allowed_users": list(context.allowed_users),
"allowed_groups": list(context.allowed_groups),
"allowed_principals": unique_acl_principals(context.allowed_principals),
"allowed_principal_labels": unique_acl_principal_labels(
context.allowed_principal_labels
),
"indexed_time": indexed_time,
"metadata": metadata.get("metadata", {}),
}
parser = context.parser or metadata.get("parser")
if parser:
doc["parser"] = parser
for field_name in ("chunk_size", "chunk_overlap"):
context_value = getattr(context, field_name)
value = context_value if context_value is not None else metadata.get(field_name)
if value is None:
continue
try:
doc[field_name] = int(value)
except (TypeError, ValueError):
# Skip assignment if coercion fails to avoid type conflicts
pass
if context.owner is not None:
doc["owner"] = context.owner
if context.owner_name is not None:
doc["owner_name"] = context.owner_name
if context.owner_email is not None:
doc["owner_email"] = context.owner_email
if context.ingest_run_id:
doc["ingest_run_id"] = context.ingest_run_id
if context.connector_file_id:
doc["connector_file_id"] = context.connector_file_id
elif metadata.get("connector_file_id"):
doc["connector_file_id"] = metadata["connector_file_id"]
if context.is_sample_data:
doc["is_sample_data"] = "true"
for time_field in ("created_time", "modified_time"):
if metadata.get(time_field):
doc[time_field] = metadata[time_field]
return doc
@staticmethod
def _normalized_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
normalized = dict(metadata or {})
for key in (
"allowed_users",
"allowed_groups",
"allowed_principals",
"allowed_principal_labels",
):
value = normalized.get(key)
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, json.JSONDecodeError):
continue
if isinstance(parsed, list):
normalized[key] = parsed
if "filesize" in normalized and "file_size" not in normalized:
normalized["file_size"] = normalized["filesize"]
return normalized
@staticmethod
def _raise_for_bulk_errors(result: Any) -> None:
if not isinstance(result, dict) or not result.get("errors"):
return
# Keep only the items that actually failed (a bulk response interleaves
# successes and failures) and carry their full OpenSearch error body so
# the cause — e.g. a mapper_parsing_exception naming the offending field
# — survives in the raised error. The caller logs this once with request
# context; this helper only raises so the failure isn't logged twice.
failures = []
for item in result.get("items", []):
action = item.get("index") or item.get("create") or item.get("update") or item
if not action.get("error"):
continue
failures.append(
{
"id": action.get("_id"),
"status": action.get("status"),
"error": action.get("error"),
}
)
if len(failures) >= 5:
break
if not failures:
# `errors` was set but no item carried an error body (rare/contradictory);
# fall back to the first few raw items so the message keeps some detail.
failures = result.get("items", [])[:3]
raise RuntimeError(f"OpenSearch bulk indexing failed: {failures}")
async def _refresh(self, index_name: str) -> None:
client = self._get_write_client()
await client.indices.refresh(index=index_name)