# # Copyright 2024 The InfiniFlow Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Chunk Service Module. Provides [`ChunkService`](rag/svr/task_executor_refactor/chunk_service.py:50) for document chunking, post-processing (keywords, questions, metadata, tags), MinIO upload, and chunk insertion into document store. This module orchestrates the chunk building pipeline by delegating to: - [`chunk_builder`](rag/svr/task_executor_refactor/chunk_builder.py): Parser selection and document chunking - [`chunk_post_processor`](rag/svr/task_executor_refactor/chunk_post_processor.py): Post-processing functions """ import asyncio import copy import logging from datetime import datetime from functools import partial from timeit import default_timer as timer from typing import Any, Dict, List import xxhash from common import settings from common.connection_utils import timeout from common.constants import PAGERANK_FLD, TAG_FLD from common.misc_utils import thread_pool_exec from common.float_utils import normalize_overlapped_percent from rag.nlp import search from rag.svr.task_executor_refactor.task_context import TaskContext from rag.utils.base64_image import image2id from api.db.services.task_service import TaskService from rag.svr.task_executor_refactor.constants import GRAPH_RAPTOR_FAKE_DOC_ID # Re-export for backward compatibility from rag.svr.task_executor_refactor.chunk_builder import ( get_parser, run_chunking, extract_outline, ) from rag.svr.task_executor_refactor.chunk_post_processor import ( extract_keywords, generate_questions, generate_metadata, apply_tags, ) class ChunkService: """Service for document chunking and post-processing. This service handles: - Document chunking via parser modules (delegated to chunk_builder) - MinIO upload of chunk images - Keyword extraction (delegated to chunk_post_processor) - Question generation (delegated to chunk_post_processor) - Metadata generation (delegated to chunk_post_processor) - Content tagging (delegated to chunk_post_processor) - Table of contents generation - Chunk insertion into document store All intermediate results are recorded via RecordingContext for comparison. """ def __init__( self, ctx: TaskContext, ): """Initialize ChunkService. Args: ctx: TaskContext containing task configuration and execution resources. """ self._task_context = ctx @timeout(60 * 80, 1) async def build_chunks( self, storage_binary: bytes, ) -> List[Dict[str, Any]]: """Build chunks from document binary. This is the main entry point for chunk building. It orchestrates: 1. File size validation 2. Parser selection and chunking (delegated to chunk_builder) 3. Outline extraction (delegated to chunk_builder) 4. MinIO upload 5. Post-processing (delegated to chunk_post_processor) Args: storage_binary: Binary content of the document. Returns: List of chunk dictionaries ready for embedding. """ ctx = self._task_context # Validate file size if ctx.size > settings.DOC_MAXIMUM_SIZE: self._progress(prog=-1, msg="File size exceeds( <= %dMb )" % (int(settings.DOC_MAXIMUM_SIZE / 1024 / 1024))) self._task_context.recording_context.record("file_size_exceeded", True) return [] ctx.recording_context.record("file_size_exceeded", False) ctx.recording_context.record("parser_id", ctx.parser_id) # Get parser chunker = get_parser(ctx.parser_id) # record config for compare chunk_config = { "parser_id": ctx.parser_id, "chunk_token_num": ctx.parser_config.get("chunk_token_num", 128), "overlapped_percent": normalize_overlapped_percent( ctx.parser_config.get("overlapped_percent", 0) ), "delimiter": ctx.parser_config.get("delimiter", "\n!?。;!?"), "from_page": ctx.from_page, "to_page": ctx.to_page, "language": ctx.language, "layout_recognizer": ctx.parser_config.get("layout_recognizer"), } ctx.recording_context.record("chunk_config", chunk_config) # Run chunking (delegated) cks = await run_chunking(chunker, storage_binary, ctx) # Record raw chunks self._task_context.recording_context.record("raw_chunks", cks) # Extract outline (delegated) await extract_outline(cks, ctx) # Prepare docs and upload to MinIO docs = await self._prepare_docs_and_upload(cks) # Record docs after prep self._task_context.recording_context.record("docs_after_prep", docs) # Post-processing (delegated to chunk_post_processor) if ctx.parser_config.get("auto_keywords", 0): await extract_keywords(docs, ctx) keywords = [d for d in docs if d.get("important_kwd")] self._task_context.recording_context.record("keywords_extracted", keywords) if ctx.parser_config.get("auto_questions", 0): await generate_questions(docs, ctx) questions = [d for d in docs if d.get("question_kwd")] self._task_context.recording_context.record("questions_generated", questions) if ctx.parser_config.get("enable_metadata", False) and ( ctx.parser_config.get("metadata") or ctx.parser_config.get("built_in_metadata") ): await generate_metadata(docs, ctx) metadata_list = [d for d in docs if d.get("metadata_obj")] self._task_context.recording_context.record("metadata_list_generated", metadata_list) if ctx.kb_parser_config.get("tag_kb_ids", []): await apply_tags(docs, ctx) tags_applied = [d for d in docs if d.get(TAG_FLD)] self._task_context.recording_context.record("tags_applied", tags_applied) # Record final chunks self._task_context.recording_context.record("final_chunks", docs) final_chunk_ids = [c.get("id") for c in docs if isinstance(c, dict) and "id" in c] self._task_context.recording_context.record("final_chunk_ids_count", len(final_chunk_ids)) return docs async def _prepare_docs_and_upload(self, cks: List[Dict]) -> List[Dict]: """Prepare docs and upload images to MinIO.""" ctx = self._task_context docs = [] doc = { "doc_id": ctx.doc_id, "kb_id": str(ctx.kb_id) } if ctx.pagerank: doc[PAGERANK_FLD] = int(ctx.pagerank) st = timer() @timeout(60) async def upload_to_minio(document, chunk): try: d = copy.deepcopy(document) d.update(chunk) d["id"] = xxhash.xxh64( (chunk["content_with_weight"] + str(d["doc_id"])).encode("utf-8", "surrogatepass")).hexdigest() d["create_time"] = str(datetime.now()).replace("T", " ")[:19] d["create_timestamp_flt"] = datetime.now().timestamp() if d.get("img_id"): docs.append(d) return if not d.get("image"): _ = d.pop("image", None) d["img_id"] = "" docs.append(d) return await image2id(d, partial(settings.STORAGE_IMPL.put, tenant_id=ctx.tenant_id), d["id"], ctx.kb_id) docs.append(d) except Exception: logging.exception( "Saving image of chunk {}/{}/{} got exception".format(ctx.location, ctx.name, d["id"])) raise tasks = [] for ck in cks: tasks.append(asyncio.create_task(upload_to_minio(doc, ck))) try: await asyncio.gather(*tasks, return_exceptions=False) except Exception as e: logging.error(f"MINIO PUT({ctx.name}) got exception: {e}") for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) raise el = timer() - st logging.info("MINIO PUT({}) cost {:.3f} s".format(ctx.name, el)) return docs def _progress(self, prog=None, msg=None): """Progress callback helper.""" if prog is not None or msg is not None: self._task_context.progress_cb(prog=prog, msg=msg) # ========================================================================= # Insert Service Methods (merged from insert_service.py) # ========================================================================= async def insert_chunks( self, task_id: str, task_tenant_id: str, task_dataset_id: str, chunks: List[Dict[str, Any]], doc_bulk_size: int = None, ) -> bool: """Insert chunks into document store. Args: task_id: Task identifier. task_tenant_id: Tenant ID. task_dataset_id: Dataset/knowledge base ID. chunks: List of chunk dictionaries to insert. doc_bulk_size: Batch size for document store inserts. Returns: True if all chunks were inserted successfully, False otherwise. """ doc_bulk_size = doc_bulk_size or settings.DOC_BULK_SIZE # Create mother chunks (summary chunks) mothers = self._create_mother_chunks(chunks) # Insert mother chunks if not await self._insert_mother_chunks(task_id, task_tenant_id, task_dataset_id, mothers, doc_bulk_size): return False # Insert main chunks return await self._insert_main_chunks(task_id, task_tenant_id, task_dataset_id, chunks, doc_bulk_size) @classmethod def _create_mother_chunks(cls, chunks: List[Dict]) -> List[Dict]: """Create mother chunks from summary fields. Mother chunks are summary/abstract chunks that are stored separately. """ mothers = [] mother_ids = set() for ck in chunks: mom = ck.get("mom") or ck.get("mom_with_weight") or "" if not mom: continue mom_id = xxhash.xxh64(mom.encode("utf-8")).hexdigest() ck["mom_id"] = mom_id if mom_id in mother_ids: continue mother_ids.add(mom_id) mom_ck = copy.deepcopy(ck) mom_ck["id"] = mom_id mom_ck["content_with_weight"] = mom mom_ck["available_int"] = 0 # Keep only essential fields allowed_fields = [ "id", "content_with_weight", "doc_id", "docnm_kwd", "kb_id", "available_int", "position_int", "create_timestamp_flt", "page_num_int", "top_int" ] for fld in list(mom_ck.keys()): if fld not in allowed_fields: del mom_ck[fld] mothers.append(mom_ck) return mothers async def _insert_mother_chunks( self, task_id: str, task_tenant_id: str, task_dataset_id: str, mothers: List[Dict], doc_bulk_size: int, ) -> bool: """Insert mother chunks in batches.""" for b in range(0, len(mothers), doc_bulk_size): await self._intercept_doc_store_insert( mothers[b:b + doc_bulk_size], search.index_name(task_tenant_id), task_dataset_id ) if self._task_context.has_canceled_func(task_id): self._task_context.progress_cb(-1, msg="Task has been canceled.") return False return True async def _intercept_doc_store_delete(self, condition: dict, index_name: str, task_dataset_id: str) -> Any: if self._task_context.write_interceptor: return self._task_context.write_interceptor.intercept("docStoreConn.delete") else: return await thread_pool_exec(settings.docStoreConn.delete, condition, index_name, task_dataset_id) async def _intercept_doc_store_insert(self, chunks: list, index_name: str, task_dataset_id: str) -> Any: if self._task_context.write_interceptor: if self._task_context.doc_id == GRAPH_RAPTOR_FAKE_DOC_ID: # raptor - non-determinisic return self._task_context.write_interceptor.intercept("docStoreConn.insert", []) return self._task_context.write_interceptor.intercept("docStoreConn.insert") else: return await thread_pool_exec(settings.docStoreConn.insert, chunks, index_name, task_dataset_id) async def _insert_main_chunks( self, task_id: str, task_tenant_id: str, task_dataset_id: str, chunks: List[Dict], doc_bulk_size: int, ) -> bool: """Insert main chunks in batches with cancellation handling.""" for b in range(0, len(chunks), doc_bulk_size): doc_store_result = await self._intercept_doc_store_insert( chunks[b:b + doc_bulk_size], search.index_name(task_tenant_id), task_dataset_id ) if self._task_context.has_canceled_func(task_id): # Roll back partial RAPTOR summary inserts await self._rollback_raptor_chunks( task_id, task_tenant_id, task_dataset_id, chunks, b, doc_bulk_size ) self._task_context.progress_cb(-1, msg="Task has been canceled.") return False if b % 128 == 0: self._task_context.progress_cb(prog=0.8 + 0.1 * (b + 1) / len(chunks),msg="") if doc_store_result: error_message = ( f"Insert chunk error: {doc_store_result}, " "please check log file and Elasticsearch/Infinity status!" ) self._task_context.progress_cb(-1, msg=error_message) raise Exception(error_message) # Update chunk IDs in task chunk_ids = [chunk["id"] for chunk in chunks[:b + doc_bulk_size]] if not await self._update_task_chunk_ids(task_id, chunk_ids): # Roll back on failure await self._rollback_insertion(task_tenant_id, task_dataset_id, chunk_ids) self._task_context.progress_cb( -1, msg=f"Chunk updates failed since task {task_id} is unknown." ) return False return True async def _rollback_raptor_chunks( self, task_id: str, task_tenant_id: str, task_dataset_id: str, chunks: List[Dict], up_to_batch: int, doc_bulk_size: int, ): """Roll back partial RAPTOR summary inserts after cancellation.""" raptor_ids = [ c["id"] for c in chunks[:up_to_batch + doc_bulk_size] if c.get("raptor_kwd") == "raptor" ] if raptor_ids: try: await self._intercept_doc_store_delete( {"id": raptor_ids}, search.index_name(task_tenant_id), task_dataset_id ) logging.info( "insert_chunks: rolled back %d partial RAPTOR chunks after cancellation (task=%s)", len(raptor_ids), task_id, ) except Exception: logging.exception( "insert_chunks: failed to roll back partial RAPTOR chunks after cancellation (task=%s)", task_id, ) async def _update_task_chunk_ids(self, task_id: str, chunk_ids: List[str]) -> bool: """Update chunk IDs in the task record.""" from peewee import DoesNotExist try: if self._task_context.write_interceptor: if self._task_context.doc_id == GRAPH_RAPTOR_FAKE_DOC_ID: self._task_context.write_interceptor.intercept("TaskService.update_chunk_ids", True) else: self._task_context.write_interceptor.intercept("TaskService.update_chunk_ids") else: TaskService.update_chunk_ids(task_id, " ".join(chunk_ids)) return True except DoesNotExist: logging.warning(f"do_handle_task update_chunk_ids failed since task {task_id} is unknown.") return False async def _rollback_insertion( self, task_tenant_id: str, task_dataset_id: str, chunk_ids: List[str], ): """Roll back an insertion by deleting chunks and images.""" await self._intercept_doc_store_delete( {"id": chunk_ids}, search.index_name(task_tenant_id), task_dataset_id ) # Delete associated images tasks = [] for chunk_id in chunk_ids: tasks.append(asyncio.create_task(self._delete_image(task_dataset_id, chunk_id))) try: await asyncio.gather(*tasks, return_exceptions=False) except Exception as e: logging.error(f"delete_image failed: {e}") for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) raise async def _delete_image(self, kb_id: str, chunk_id: str): """Delete a chunk's image from storage.""" try: async with self._task_context.minio_limiter: settings.STORAGE_IMPL.delete(kb_id, chunk_id) except Exception: logging.exception(f"Deleting image of chunk {chunk_id} got exception") raise