appQQQ commited on
Commit
966ee18
·
verified ·
1 Parent(s): 718f7a2

chore: upload app/services/ingestion.py

Browse files
Files changed (1) hide show
  1. app/services/ingestion.py +319 -0
app/services/ingestion.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """文档摄入编排器.
2
+
3
+ 完整流水线:
4
+ 1. 保存上传文件到 /data/uploads/{doc_id}/{filename}
5
+ 2. 计算 SHA256, 查 SQLite 是否有同 sha 的 ready 文档
6
+ - 有 -> 直接返回 {doc_id, status: "duplicate"}
7
+ 3. 解析 (智能路由: primary -> fallback)
8
+ 4. 分块 (层次化 + 可选语义 + 可选上下文预置)
9
+ 5. 向量化 (BGE-M3 三路)
10
+ 6. 入库:
11
+ - ChromaDB: dense + (colbert)
12
+ - 旁路: sparse (BM25-style)
13
+ - SQLite: document 行 + chunk 行
14
+ 7. 调度 persist.push 异步同步
15
+ 8. 返回 {doc_id, chunk_count, status: "ready"}
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hashlib
21
+ import logging
22
+ import time
23
+ import uuid
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from app.config import settings
31
+ from app.core.errors import IngestionFailedError
32
+ from app.core.paths import upload_dir
33
+ from app.models import db
34
+ from app.models.schemas import IngestResult
35
+ from app.services.chunking import (
36
+ Chunk,
37
+ chunk_document,
38
+ chunk_to_embed_text,
39
+ contextualize_chunks,
40
+ )
41
+ from app.services.embedding import get_embedder
42
+ from app.services.parsers import parse_with_fallback
43
+ from app.services.parsers.base_parser import ParsedDocument
44
+ from app.services.persist import schedule_push
45
+ from app.services.vector_store import upsert_chunks
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ # 单并发摄入队列 (避免大 PDF 同时跑爆 RAM)
51
+ _ingest_lock = asyncio.Lock()
52
+
53
+
54
+ @dataclass
55
+ class _IngestContext:
56
+ doc_id: str
57
+ file_path: Path
58
+ filename: str
59
+ mime: str
60
+ size: int
61
+ sha256: str
62
+ started: float
63
+
64
+
65
+ async def _save_upload(*, content: bytes, filename: str) -> tuple[Path, str, int, str]:
66
+ """保存上传字节到 /data/uploads/{doc_id}/{filename}, 返回 (path, mime, size, sha256)."""
67
+ doc_id = uuid.uuid4().hex
68
+ dest_dir = upload_dir() / doc_id
69
+ dest_dir.mkdir(parents=True, exist_ok=True)
70
+ # 防路径穿越
71
+ safe_name = Path(filename).name or "upload"
72
+ dest = dest_dir / safe_name
73
+ dest.write_bytes(content)
74
+
75
+ # MIME 简单嗅探
76
+ mime = _sniff_mime(safe_name, content[:2048])
77
+ sha = hashlib.sha256(content).hexdigest()
78
+ return dest, mime, len(content), sha
79
+
80
+
81
+ def _sniff_mime(filename: str, head: bytes) -> str:
82
+ ext = Path(filename).suffix.lower()
83
+ return {
84
+ ".pdf": "application/pdf",
85
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
86
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
87
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
88
+ ".png": "image/png",
89
+ ".jpg": "image/jpeg",
90
+ ".jpeg": "image/jpeg",
91
+ ".tiff": "image/tiff",
92
+ ".html": "text/html",
93
+ }.get(ext, "application/octet-stream")
94
+
95
+
96
+ async def ingest_bytes(
97
+ *,
98
+ content: bytes,
99
+ filename: str,
100
+ ) -> IngestResult:
101
+ """公开入口. 处理上传的原始字节."""
102
+ async with _ingest_lock:
103
+ return await _ingest_locked(content, filename)
104
+
105
+
106
+ async def _ingest_locked(content: bytes, filename: str) -> IngestResult:
107
+ started = time.time()
108
+ # 1. 保存
109
+ file_path, mime, size, sha = await _save_upload(content=content, filename=filename)
110
+ doc_id = file_path.parent.name # 用 upload 子目录名当 doc_id (一致)
111
+
112
+ logger.info("Ingest start: %s (%d bytes, sha=%s)", filename, size, sha[:12])
113
+
114
+ # 2. SHA256 幂等检查
115
+ existing = db.doc_find_by_sha256(sha)
116
+ if existing is not None:
117
+ logger.info("Duplicate by sha256: existing doc_id=%s", existing["id"])
118
+ # 删除刚写入的副本, 复用旧 doc
119
+ try:
120
+ file_path.unlink(missing_ok=True)
121
+ except Exception: # noqa: BLE001
122
+ pass
123
+ return IngestResult(doc_id=existing["id"], chunk_count=existing.get("chunk_count", 0), status="duplicate")
124
+
125
+ # 3. 写入 SQLite (状态 uploading -> parsing)
126
+ db.doc_insert({
127
+ "id": doc_id,
128
+ "filename": Path(filename).name,
129
+ "mime": mime,
130
+ "size": size,
131
+ "sha256": sha,
132
+ "status": "parsing",
133
+ "progress": 0,
134
+ "progress_label": "开始解析...",
135
+ "parser": settings.parser_primary,
136
+ "chunk_size": settings.chunk_size,
137
+ "chunk_overlap": settings.chunk_overlap,
138
+ "semantic_chunking": settings.semantic_chunking,
139
+ "contextual_retrieval": settings.contextual_retrieval,
140
+ "created_at": started,
141
+ "updated_at": started,
142
+ "meta": {},
143
+ })
144
+
145
+ try:
146
+ # 4. 解析
147
+ db.doc_update_status(doc_id, "parsing", progress=10, progress_label="解析 PDF/Word 结构...")
148
+ parsed = await parse_with_fallback(file_path)
149
+ page_count = parsed.meta.get("page_count")
150
+ db.doc_update_status(doc_id, "parsing", page_count=page_count, progress=30, progress_label="解析完成, 准备分块")
151
+
152
+ # 5. 分块
153
+ chunking = chunk_document(parsed, doc_id=doc_id)
154
+ logger.info(
155
+ "Chunked: %d parents, %d children for %s",
156
+ len(chunking.parents), len(chunking.children), filename,
157
+ )
158
+ db.doc_update_status(
159
+ doc_id, "parsing",
160
+ progress=45,
161
+ progress_label=f"分块 {len(chunking.children)} 个 chunk",
162
+ )
163
+
164
+ # 6. 上下文预置 (可选, 走 LLM, 慢但提升召回)
165
+ if settings.contextual_retrieval and chunking.children:
166
+ db.doc_update_status(
167
+ doc_id, "embedding",
168
+ progress=50,
169
+ progress_label="上下文预标注 (走 LLM)...",
170
+ )
171
+ await contextualize_chunks(
172
+ chunking.children, doc_title=Path(filename).name, max_concurrency=4
173
+ )
174
+
175
+ # 7. 向量化
176
+ db.doc_update_status(doc_id, "embedding", progress=55, progress_label="向量化中...")
177
+ embedder = get_embedder()
178
+ embed_texts = [chunk_to_embed_text(c) for c in chunking.children]
179
+ if not embed_texts:
180
+ logger.warning("No chunks to embed for %s", filename)
181
+ db.doc_update_status(doc_id, "ready", chunk_count=0, progress=100, progress_label="完成 (空文档)")
182
+ return IngestResult(doc_id=doc_id, chunk_count=0, status="ready")
183
+
184
+ # batch 编码
185
+ BATCH = 32
186
+ all_dense: list[np.ndarray] = []
187
+ all_sparse: list[dict[int, float]] = []
188
+ all_colbert: list[np.ndarray] = []
189
+ n_total = len(embed_texts)
190
+ for i in range(0, n_total, BATCH):
191
+ batch = embed_texts[i : i + BATCH]
192
+ out = await embedder.encode(batch)
193
+ all_dense.append(out["dense"])
194
+ all_sparse.extend(out.get("sparse", []))
195
+ all_colbert.extend(out.get("colbert", []))
196
+ done = min(i + BATCH, n_total)
197
+ # 55% -> 95%
198
+ pct = 55 + int((done / n_total) * 40)
199
+ db.doc_update_status(
200
+ doc_id, "embedding",
201
+ progress=pct,
202
+ progress_label=f"向量化 {done}/{n_total}",
203
+ )
204
+ logger.info(
205
+ "Embedded %d/%d chunks", done, n_total
206
+ )
207
+
208
+ dense_arr = np.vstack(all_dense) if all_dense else np.zeros((0, 1024), dtype=np.float32)
209
+
210
+ # 8. 入库 ChromaDB + sparse 旁路
211
+ ids = [c.id for c in chunking.children]
212
+ documents = [c.text for c in chunking.children]
213
+ metadatas = [
214
+ {
215
+ "doc_id": c.doc_id,
216
+ "parent_id": c.parent_id or "",
217
+ "chunk_index": c.chunk_index,
218
+ "page_no": c.page_no or 0,
219
+ "heading": c.heading or "",
220
+ "context_prefix": c.context_prefix or "",
221
+ }
222
+ for c in chunking.children
223
+ ]
224
+ upsert_chunks(
225
+ ids=ids,
226
+ embeddings=dense_arr,
227
+ documents=documents,
228
+ metadatas=metadatas,
229
+ sparse_weights=all_sparse or None,
230
+ colbert_vecs=all_colbert or None,
231
+ )
232
+
233
+ # 9. 写入 SQLite chunks
234
+ db.chunk_insert_bulk([
235
+ {
236
+ "id": c.id,
237
+ "doc_id": c.doc_id,
238
+ "parent_id": c.parent_id,
239
+ "chunk_index": c.chunk_index,
240
+ "text": c.text,
241
+ "token_count": c.token_count,
242
+ "page_no": c.page_no,
243
+ "heading": c.heading,
244
+ "context_prefix": c.context_prefix,
245
+ "created_at": c.created_at,
246
+ }
247
+ for c in chunking.children
248
+ ])
249
+
250
+ # 10. 文档就绪
251
+ elapsed_ms = int((time.time() - started) * 1000)
252
+ db.doc_update_status(
253
+ doc_id, "ready",
254
+ chunk_count=len(chunking.children),
255
+ page_count=page_count,
256
+ progress=100,
257
+ progress_label="就绪",
258
+ )
259
+ logger.info(
260
+ "Ingest done: %s doc_id=%s chunks=%d %dms",
261
+ filename, doc_id, len(chunking.children), elapsed_ms,
262
+ )
263
+
264
+ # 11. 调度持久化推送
265
+ await schedule_push()
266
+
267
+ return IngestResult(
268
+ doc_id=doc_id,
269
+ chunk_count=len(chunking.children),
270
+ status="ready",
271
+ )
272
+
273
+ except Exception as e: # noqa: BLE001
274
+ logger.exception("Ingest failed for %s", filename)
275
+ try:
276
+ db.doc_update_status(doc_id, "failed", error=str(e)[:500])
277
+ except Exception: # noqa: BLE001
278
+ pass
279
+ raise IngestionFailedError(
280
+ f"Ingest failed: {e}", code="ingest_failed", detail={"doc_id": doc_id}
281
+ ) from e
282
+
283
+
284
+ async def delete_document(doc_id: str) -> bool:
285
+ """完整删除: ChromaDB + 旁路 sparse + SQLite + uploads/."""
286
+ # 先查, 拿文件路径
287
+ doc = db.doc_get(doc_id)
288
+ if doc is None:
289
+ return False
290
+
291
+ # 从 ChromaDB / sparse 旁路删
292
+ try:
293
+ from app.services.vector_store import delete_by_doc
294
+ delete_by_doc(doc_id)
295
+ except Exception as e: # noqa: BLE001
296
+ logger.warning("ChromaDB delete failed for %s: %s", doc_id, e)
297
+
298
+ # SQLite chunks + document
299
+ db.chunk_delete_by_doc(doc_id)
300
+ db.doc_delete(doc_id)
301
+
302
+ # 删上传原文件
303
+ if doc.get("sha256"):
304
+ # doc_id 即子目录名
305
+ up_dir = upload_dir() / doc_id
306
+ if up_dir.exists():
307
+ import shutil
308
+ shutil.rmtree(up_dir, ignore_errors=True)
309
+
310
+ await schedule_push()
311
+ return True
312
+
313
+
314
+ def list_documents(limit: int = 100) -> list[dict[str, Any]]:
315
+ return db.doc_list(limit=limit)
316
+
317
+
318
+ def get_document(doc_id: str) -> dict[str, Any] | None:
319
+ return db.doc_get(doc_id)