mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-09-08 10:14:35 +08:00
Refactor: Task Executor (#15154)
### What problem does this PR solve?
1. Break huge function into smaller pieces
2. Add unit test for the smaller pieces function
3. Layer-ed design
a. infra layer - task_context.py, recording_context.py,
write_operation_interceptor.py, ...
b. service layer - *_service.py
c. business layer - task_handler.py
4. Default behavior: use "refactor-ed version" - can switch to original
version by change env variable
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] Refactoring
- [x] Performance Improvement
---------
Co-authored-by: Liu An <asiro@qq.com>
Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
This commit is contained in:
@@ -49,7 +49,7 @@ class Pipeline(Graph):
|
||||
message += "[CANCEL]"
|
||||
try:
|
||||
bin = REDIS_CONN.get(log_key)
|
||||
obj = json.loads(bin.encode("utf-8"))
|
||||
obj = json.loads(bin.encode("utf-8")) if bin else []
|
||||
if obj:
|
||||
if obj[-1]["component_id"] == component_name:
|
||||
obj[-1]["trace"].append(
|
||||
|
||||
@@ -26,9 +26,9 @@ from common.connection_utils import timeout
|
||||
from rag.flow.base import ProcessBase, ProcessParamBase
|
||||
from rag.flow.parser.pdf_chunk_metadata import finalize_pdf_chunk
|
||||
from rag.flow.tokenizer.schema import TokenizerFromUpstream
|
||||
from rag.svr.task_executor_limiter import embed_limiter
|
||||
from rag.nlp import rag_tokenizer
|
||||
from common import settings
|
||||
from rag.svr.task_executor import embed_limiter
|
||||
from common.token_utils import truncate
|
||||
|
||||
from common.misc_utils import thread_pool_exec
|
||||
|
||||
@@ -8,9 +8,11 @@ Task
|
||||
- Decide levels yourself to keep a coherent hierarchy. Keep peers at the same depth.
|
||||
|
||||
Output
|
||||
- Return a valid JSON array only (no extra text).
|
||||
- Each element must be {"level": "1|2|3", "title": <original title string>}.
|
||||
- title must be the original title string.
|
||||
- Return a valid JSON array only (no extra text, no markdown code blocks).
|
||||
- Each element MUST be a JSON object with exactly this structure: {"level": "1", "title": "some title"}.
|
||||
- title must be the original title string exactly.
|
||||
- DO NOT return arrays of arrays like [["1", "title"]] or other formats.
|
||||
- The output must be parseable by json.loads() directly.
|
||||
|
||||
Examples
|
||||
|
||||
|
||||
@@ -887,6 +887,23 @@ async def run_toc_from_text(chunks, chat_mdl, callback=None):
|
||||
if not toc_with_levels:
|
||||
return []
|
||||
|
||||
# Normalize TOC items to ensure consistent dict format
|
||||
normalized_levels = []
|
||||
for item in toc_with_levels:
|
||||
if isinstance(item, dict):
|
||||
# Already in correct format
|
||||
normalized_levels.append(item)
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
# Convert ["level", "title"] or similar to dict
|
||||
normalized_levels.append({"level": str(item[0]), "title": str(item[1])})
|
||||
else:
|
||||
logging.warning(f"Unexpected TOC item format (type={type(item).__name__}), skipping: {item}")
|
||||
|
||||
toc_with_levels = normalized_levels
|
||||
if not toc_with_levels:
|
||||
logging.warning("No valid TOC items after normalization.")
|
||||
return []
|
||||
|
||||
# Merge structure and content (by index)
|
||||
prune = len(toc_with_levels) > 512
|
||||
max_lvl = "0"
|
||||
|
||||
+192
-42
@@ -12,9 +12,13 @@
|
||||
# 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.
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from rag.svr.task_executor_refactor.task_manager import TaskManager
|
||||
from rag.svr.task_executor_refactor.recording_context import timed_with_recording, get_recording_context, \
|
||||
RecordingContext, set_recording_context, NullRecordingContext
|
||||
|
||||
start_ts = time.time()
|
||||
|
||||
# LiteLLM fetches a model cost map from GitHub during import unless this is set.
|
||||
@@ -89,7 +93,13 @@ from rag.utils.redis_conn import REDIS_CONN, RedisDistributedLock
|
||||
from rag.graphrag.utils import chat_limiter
|
||||
from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc
|
||||
from common.exceptions import TaskCanceledException
|
||||
from common.asyncio_utils import LoopLocalSemaphore
|
||||
from rag.svr.task_executor_limiter import (
|
||||
task_limiter,
|
||||
chunk_limiter,
|
||||
embed_limiter,
|
||||
minio_limiter,
|
||||
kg_limiter,
|
||||
)
|
||||
from common import settings
|
||||
from common.constants import PAGERANK_FLD, TAG_FLD, SVR_CONSUMER_GROUP_NAME
|
||||
from rag.utils.table_es_metadata import (
|
||||
@@ -97,6 +107,7 @@ from rag.utils.table_es_metadata import (
|
||||
merge_table_parser_config_from_kb,
|
||||
table_parser_strip_doc_metadata_keys,
|
||||
)
|
||||
from rag.nlp import search as nlp_search
|
||||
|
||||
BATCH_SIZE = 64
|
||||
|
||||
@@ -129,9 +140,10 @@ TASK_TYPE_TO_PIPELINE_TASK_TYPE = {
|
||||
}
|
||||
|
||||
UNACKED_ITERATOR = None
|
||||
# Task type and executor index (consistent with SAAS version)
|
||||
TASK_TYPE = "common"
|
||||
TE_IDX = "0"
|
||||
|
||||
CONSUMER_NO = "0" if len(sys.argv) < 2 else sys.argv[1]
|
||||
CONSUMER_NAME = "task_executor_" + CONSUMER_NO
|
||||
BOOT_AT = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
PENDING_TASKS = 0
|
||||
LAG_TASKS = 0
|
||||
@@ -140,18 +152,9 @@ FAILED_TASKS = 0
|
||||
|
||||
CURRENT_TASKS = {}
|
||||
|
||||
MAX_CONCURRENT_TASKS = int(os.environ.get('MAX_CONCURRENT_TASKS', "5"))
|
||||
MAX_CONCURRENT_CHUNK_BUILDERS = int(os.environ.get('MAX_CONCURRENT_CHUNK_BUILDERS', "1"))
|
||||
MAX_CONCURRENT_MINIO = int(os.environ.get('MAX_CONCURRENT_MINIO', '10'))
|
||||
task_limiter = LoopLocalSemaphore(MAX_CONCURRENT_TASKS)
|
||||
chunk_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
embed_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
minio_limiter = LoopLocalSemaphore(MAX_CONCURRENT_MINIO)
|
||||
kg_limiter = LoopLocalSemaphore(2)
|
||||
WORKER_HEARTBEAT_TIMEOUT = int(os.environ.get('WORKER_HEARTBEAT_TIMEOUT', '120'))
|
||||
stop_event = threading.Event()
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
logging.info("Received interrupt signal, shutting down...")
|
||||
stop_event.set()
|
||||
@@ -197,7 +200,8 @@ async def collect():
|
||||
global CONSUMER_NAME, DONE_TASKS, FAILED_TASKS
|
||||
global UNACKED_ITERATOR
|
||||
|
||||
svr_queue_names = settings.get_svr_queue_names()
|
||||
svr_queue_names = settings.get_svr_queue_names(TASK_TYPE)
|
||||
|
||||
redis_msg = None
|
||||
try:
|
||||
if not UNACKED_ITERATOR:
|
||||
@@ -261,12 +265,16 @@ async def get_storage_binary(bucket, name):
|
||||
return await thread_pool_exec(settings.STORAGE_IMPL.get, bucket, name)
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
@timeout(60 * 80, 1)
|
||||
async def build_chunks(task, progress_callback):
|
||||
if task["size"] > settings.DOC_MAXIMUM_SIZE:
|
||||
set_progress(task["id"], prog=-1, msg="File size exceeds( <= %dMb )" %
|
||||
(int(settings.DOC_MAXIMUM_SIZE / 1024 / 1024)))
|
||||
get_recording_context().record("file_size_exceeded", True)
|
||||
return []
|
||||
get_recording_context().record("file_size_exceeded", False)
|
||||
get_recording_context().record("parser_id", task["parser_id"])
|
||||
|
||||
chunker = FACTORY[task["parser_id"].lower()]
|
||||
try:
|
||||
@@ -299,6 +307,23 @@ async def build_chunks(task, progress_callback):
|
||||
f"roles_keys={list((parser_config_for_chunk.get('table_column_roles') or {}).keys())}"
|
||||
)
|
||||
|
||||
# Record chunk configuration for comparison
|
||||
from common.float_utils import normalize_overlapped_percent
|
||||
chunk_config = {
|
||||
"parser_id": task["parser_id"],
|
||||
"chunk_token_num": parser_config_for_chunk.get("chunk_token_num", 128),
|
||||
"overlapped_percent": normalize_overlapped_percent(
|
||||
parser_config_for_chunk.get("overlapped_percent", 0)
|
||||
),
|
||||
"delimiter": parser_config_for_chunk.get("delimiter", "\n!?。;!?"),
|
||||
"from_page": task["from_page"],
|
||||
"to_page": task["to_page"],
|
||||
"language": task["language"],
|
||||
"layout_recognizer": parser_config_for_chunk.get("layout_recognizer"),
|
||||
}
|
||||
get_recording_context().record("chunk_config", chunk_config)
|
||||
get_recording_context().record("parser_config_after_merge", parser_config_for_chunk)
|
||||
|
||||
try:
|
||||
async with chunk_limiter:
|
||||
task_language = task.get("language") or "Chinese"
|
||||
@@ -322,15 +347,22 @@ async def build_chunks(task, progress_callback):
|
||||
logging.exception("Chunking {}/{} got exception".format(task["location"], task["name"]))
|
||||
raise
|
||||
|
||||
# Record raw chunks for comparison
|
||||
get_recording_context().record("raw_chunks", cks)
|
||||
|
||||
# Extract and persist PDF outline if the parser attached it.
|
||||
outline_data = cks[0].get("__outline__") if cks else None
|
||||
get_recording_context().record("outline_data", outline_data)
|
||||
|
||||
if cks and cks[0].get("__outline__"):
|
||||
outline = cks[0].pop("__outline__")
|
||||
try:
|
||||
DocMetadataService.update_document_metadata(
|
||||
ret = DocMetadataService.update_document_metadata(
|
||||
task["doc_id"],
|
||||
update_metadata_to({"outline": outline},
|
||||
DocMetadataService.get_document_metadata(task["doc_id"]) or {})
|
||||
)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
logging.info("Persisted PDF outline (%d entries) for doc %s", len(outline), task["doc_id"])
|
||||
except Exception as e:
|
||||
logging.warning("Failed to persist PDF outline for doc %s: %s", task["doc_id"], e)
|
||||
@@ -385,6 +417,9 @@ async def build_chunks(task, progress_callback):
|
||||
el = timer() - st
|
||||
logging.info("MINIO PUT({}) cost {:.3f} s".format(task["name"], el))
|
||||
|
||||
# Record docs after MinIO upload
|
||||
get_recording_context().record("docs_after_prep", docs)
|
||||
|
||||
if task["parser_config"].get("auto_keywords", 0):
|
||||
st = timer()
|
||||
progress_callback(msg="Start to generate keywords for every chunk ...")
|
||||
@@ -419,6 +454,10 @@ async def build_chunks(task, progress_callback):
|
||||
raise
|
||||
progress_callback(msg="Keywords generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record keywords extraction count
|
||||
keywords = [d for d in docs if d.get("important_kwd")]
|
||||
get_recording_context().record("keywords_extracted", keywords)
|
||||
|
||||
if task["parser_config"].get("auto_questions", 0):
|
||||
st = timer()
|
||||
progress_callback(msg="Start to generate questions for every chunk ...")
|
||||
@@ -452,6 +491,10 @@ async def build_chunks(task, progress_callback):
|
||||
raise
|
||||
progress_callback(msg="Question generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record question generation
|
||||
questions = [d for d in docs if d.get("question_kwd")]
|
||||
get_recording_context().record("questions_generated", questions)
|
||||
|
||||
if task["parser_config"].get("enable_metadata", False) and (task["parser_config"].get("metadata") or task["parser_config"].get("built_in_metadata")):
|
||||
st = timer()
|
||||
progress_callback(msg="Start to generate meta-data for every chunk ...")
|
||||
@@ -510,9 +553,14 @@ async def build_chunks(task, progress_callback):
|
||||
existing_meta = DocMetadataService.get_document_metadata(task["doc_id"])
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
DocMetadataService.update_document_metadata(task["doc_id"], metadata)
|
||||
ret = DocMetadataService.update_document_metadata(task["doc_id"], metadata)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
progress_callback(msg="Question generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record metadata generation count
|
||||
metadata_list = [d for d in docs if d.get("metadata_obj")]
|
||||
get_recording_context().record("metadata_list_generated", metadata_list)
|
||||
|
||||
if task["kb_parser_config"].get("tag_kb_ids", []):
|
||||
progress_callback(msg="Start to tag for every chunk ...")
|
||||
kb_ids = task["kb_parser_config"]["tag_kb_ids"]
|
||||
@@ -578,9 +626,19 @@ async def build_chunks(task, progress_callback):
|
||||
raise
|
||||
progress_callback(msg="Tagging {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record tags applied
|
||||
tags_applied = [d for d in docs if d.get(TAG_FLD)]
|
||||
get_recording_context().record("tags_applied", tags_applied)
|
||||
|
||||
# Record final chunks for comparison
|
||||
get_recording_context().record("final_chunks", docs)
|
||||
final_chunk_ids = [c.get("id") for c in docs if isinstance(c, dict) and "id" in c]
|
||||
get_recording_context().record("final_chunk_ids_count", len(final_chunk_ids))
|
||||
|
||||
return docs
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
def build_TOC(task, docs, progress_callback):
|
||||
progress_callback(msg="Start to generate table of content ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(task["tenant_id"], LLMType.CHAT, task["llm_id"])
|
||||
@@ -634,6 +692,7 @@ def init_kb(row, vector_size: int):
|
||||
return settings.docStoreConn.create_idx(idxnm, row.get("kb_id", ""), vector_size, parser_id)
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
async def embedding(docs, mdl, parser_config=None, callback=None):
|
||||
if parser_config is None:
|
||||
parser_config = {}
|
||||
@@ -686,6 +745,7 @@ async def embedding(docs, mdl, parser_config=None, callback=None):
|
||||
return tk_count, vector_size
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
async def run_dataflow(task: dict):
|
||||
from api.db.services.canvas_service import UserCanvasService
|
||||
from rag.flow.pipeline import Pipeline
|
||||
@@ -708,32 +768,47 @@ async def run_dataflow(task: dict):
|
||||
pipeline = Pipeline(dsl, tenant_id=task["tenant_id"], doc_id=doc_id, task_id=task_id, flow_id=dataflow_id)
|
||||
chunks = await pipeline.run(file=task["file"]) if task.get("file") else await pipeline.run()
|
||||
if doc_id == CANVAS_DEBUG_DOC_ID:
|
||||
get_recording_context().record("dataflow_debug_result", "canvas_debug_mode")
|
||||
get_recording_context().record("dataflow_chunks", chunks)
|
||||
return
|
||||
|
||||
if not chunks:
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
get_recording_context().record("pipeline_output_count", 0)
|
||||
get_recording_context().record("pipeline_output_type", "empty")
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
embedding_token_consumption = chunks.get("embedding_token_consumption", 0)
|
||||
# The output key may exist with an empty payload; check presence, not truthiness.
|
||||
if "chunks" in chunks:
|
||||
chunks = copy.deepcopy(chunks["chunks"])
|
||||
output_type = "chunks"
|
||||
elif "json" in chunks:
|
||||
chunks = copy.deepcopy(chunks["json"])
|
||||
output_type = "json"
|
||||
elif "markdown" in chunks:
|
||||
chunks = [{"text": [chunks["markdown"]]}] if chunks["markdown"] else []
|
||||
output_type = "markdown"
|
||||
elif "text" in chunks:
|
||||
chunks = [{"text": [chunks["text"]]}] if chunks["text"] else []
|
||||
output_type = "text"
|
||||
elif "html" in chunks:
|
||||
chunks = [{"text": [chunks["html"]]}] if chunks["html"] else []
|
||||
output_type = "html"
|
||||
else:
|
||||
chunks = []
|
||||
output_type = "empty"
|
||||
|
||||
get_recording_context().record("pipeline_output_type", output_type)
|
||||
get_recording_context().record("pipeline_output_count", len(chunks))
|
||||
|
||||
# An empty normalized payload means "nothing parsed", so stop before embedding/indexing.
|
||||
if not chunks:
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
keys = [k for o in chunks for k in list(o.keys())]
|
||||
@@ -763,6 +838,8 @@ async def run_dataflow(task: dict):
|
||||
if i % (len(texts) // settings.EMBEDDING_BATCH_SIZE / 100 + 1) == 1:
|
||||
set_progress(task_id, prog=prog, msg=f"{i + 1} / {len(texts) // settings.EMBEDDING_BATCH_SIZE}")
|
||||
vects = np.vstack(vects_batches) if vects_batches else np.array([])
|
||||
get_recording_context().record("embedding_token_consumption", embedding_token_consumption)
|
||||
get_recording_context().record("vector_size", len(vects[0]) if len(vects) > 0 else 0)
|
||||
|
||||
assert len(vects) == len(chunks)
|
||||
for i, ck in enumerate(chunks):
|
||||
@@ -772,8 +849,9 @@ async def run_dataflow(task: dict):
|
||||
raise
|
||||
except Exception as e:
|
||||
set_progress(task_id, prog=-1, msg=f"[ERROR]: {e}")
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
metadata = {}
|
||||
@@ -814,26 +892,31 @@ async def run_dataflow(task: dict):
|
||||
existing_meta = DocMetadataService.get_document_metadata(doc_id)
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
DocMetadataService.update_document_metadata(doc_id, metadata)
|
||||
get_recording_context().record("run_dataflow_metadata", metadata)
|
||||
ret = DocMetadataService.update_document_metadata(doc_id, metadata)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
|
||||
start_ts = timer()
|
||||
set_progress(task_id, prog=0.82, msg="[DOC Engine]:\nStart to index...")
|
||||
e = await insert_chunks(task_id, task["tenant_id"], task["kb_id"], chunks, partial(set_progress, task_id, 0, 100000000))
|
||||
if not e:
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
time_cost = timer() - start_ts
|
||||
task_time_cost = timer() - task_start_ts
|
||||
set_progress(task_id, prog=1., msg="Indexing done ({:.2f}s). Task done ({:.2f}s)".format(time_cost, task_time_cost))
|
||||
DocumentService.increment_chunk_num(doc_id, task_dataset_id, embedding_token_consumption, len(chunks),
|
||||
ret = DocumentService.increment_chunk_num(doc_id, task_dataset_id, embedding_token_consumption, len(chunks),
|
||||
task_time_cost)
|
||||
get_recording_context().save_func_return_value("DocumentService.increment_chunk_num", ret)
|
||||
logging.info("[Done], chunks({}), token({}), elapsed:{:.2f}".format(len(chunks), embedding_token_consumption,
|
||||
task_time_cost))
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id, task_type=PipelineTaskType.PARSE,
|
||||
get_recording_context().record("dataflow_chunks", chunks)
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id, task_type=PipelineTaskType.PARSE,
|
||||
dsl=str(pipeline))
|
||||
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
|
||||
RAPTOR_METHOD_SEARCH_LIMIT = 10000
|
||||
|
||||
@@ -901,19 +984,18 @@ async def has_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, tree_builde
|
||||
|
||||
async def delete_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, keep_method: str | None = None):
|
||||
"""Delete RAPTOR summaries for doc_id, optionally preserving one method."""
|
||||
from rag.nlp import search as nlp_search
|
||||
|
||||
if keep_method is None:
|
||||
logging.info(
|
||||
"delete_raptor_chunks: removing all RAPTOR summaries (doc=%s tenant=%s kb=%s)",
|
||||
doc_id, tenant_id, kb_id,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": doc_id, "raptor_kwd": ["raptor"]},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
return 0
|
||||
|
||||
field_map = await get_raptor_chunk_field_map(doc_id, tenant_id, kb_id)
|
||||
@@ -929,12 +1011,13 @@ async def delete_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, keep_met
|
||||
"delete_raptor_chunks: removing %d stale RAPTOR chunks (doc=%s tenant=%s kb=%s keep=%s)",
|
||||
len(chunk_ids), doc_id, tenant_id, kb_id, keep_method,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"id": list(chunk_ids)},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
return len(chunk_ids)
|
||||
|
||||
|
||||
@@ -1171,6 +1254,7 @@ async def delete_image(kb_id, chunk_id):
|
||||
raise
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progress_callback):
|
||||
"""
|
||||
Insert chunks into document store (Elasticsearch OR Infinity).
|
||||
@@ -1205,8 +1289,9 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
mothers.append(mom_ck)
|
||||
|
||||
for b in range(0, len(mothers), settings.DOC_BULK_SIZE):
|
||||
await thread_pool_exec(settings.docStoreConn.insert, mothers[b:b + settings.DOC_BULK_SIZE],
|
||||
ret = await thread_pool_exec(settings.docStoreConn.insert, mothers[b:b + settings.DOC_BULK_SIZE],
|
||||
search.index_name(task_tenant_id), task_dataset_id, )
|
||||
get_recording_context().save_func_return_value("docStoreConn.insert", ret)
|
||||
task_canceled = has_canceled(task_id)
|
||||
if task_canceled:
|
||||
progress_callback(-1, msg="Task has been canceled.")
|
||||
@@ -1215,6 +1300,7 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
for b in range(0, len(chunks), settings.DOC_BULK_SIZE):
|
||||
doc_store_result = await thread_pool_exec(settings.docStoreConn.insert, chunks[b:b + settings.DOC_BULK_SIZE],
|
||||
search.index_name(task_tenant_id), task_dataset_id, )
|
||||
get_recording_context().save_func_return_value("docStoreConn.insert", doc_store_result)
|
||||
task_canceled = has_canceled(task_id)
|
||||
if task_canceled:
|
||||
# Roll back partial RAPTOR summary inserts so the next run is not
|
||||
@@ -1225,12 +1311,13 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
]
|
||||
if raptor_ids_to_rollback:
|
||||
try:
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"id": raptor_ids_to_rollback},
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
logging.info(
|
||||
"insert_chunks: rolled back %d partial RAPTOR chunks after cancellation (task=%s)",
|
||||
len(raptor_ids_to_rollback), task_id,
|
||||
@@ -1252,10 +1339,12 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
chunk_ids_str = " ".join(chunk_ids)
|
||||
try:
|
||||
TaskService.update_chunk_ids(task_id, chunk_ids_str)
|
||||
get_recording_context().save_func_return_value("TaskService.update_chunk_ids", None)
|
||||
except DoesNotExist:
|
||||
logging.warning(f"do_handle_task update_chunk_ids failed since task {task_id} is unknown.")
|
||||
doc_store_result = await thread_pool_exec(settings.docStoreConn.delete, {"id": chunk_ids},
|
||||
search.index_name(task_tenant_id), task_dataset_id, )
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", doc_store_result)
|
||||
tasks = []
|
||||
for chunk_id in chunk_ids:
|
||||
tasks.append(asyncio.create_task(delete_image(task_dataset_id, chunk_id)))
|
||||
@@ -1277,7 +1366,8 @@ async def do_handle_task(task):
|
||||
task_type = task.get("task_type", "")
|
||||
|
||||
if task_type == "memory":
|
||||
await handle_save_to_memory_task(task)
|
||||
result = await handle_save_to_memory_task(task)
|
||||
get_recording_context().save_func_return_value("handle_save_to_memory_task", result)
|
||||
return
|
||||
|
||||
if task_type == "dataflow" and task.get("doc_id", "") == CANVAS_DEBUG_DOC_ID:
|
||||
@@ -1355,7 +1445,9 @@ async def do_handle_task(task):
|
||||
},
|
||||
}
|
||||
)
|
||||
if not KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config}):
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
get_recording_context().save_func_return_value("KnowledgebaseService.update_by_id", update_result)
|
||||
if not update_result:
|
||||
progress_callback(prog=-1.0, msg="Internal error: Invalid RAPTOR configuration")
|
||||
return
|
||||
|
||||
@@ -1373,6 +1465,8 @@ async def do_handle_task(task):
|
||||
callback=progress_callback,
|
||||
doc_ids=task.get("doc_ids", []),
|
||||
)
|
||||
get_recording_context().record("raptor_chunks", chunks)
|
||||
get_recording_context().record("raptor_token_count", token_count)
|
||||
if fake_doc_ids := task.get("doc_ids", []):
|
||||
task_doc_id = fake_doc_ids[0] # use the first document ID to represent this task for logging purposes
|
||||
# Either using graphrag or Standard chunking methods
|
||||
@@ -1409,7 +1503,9 @@ async def do_handle_task(task):
|
||||
}
|
||||
}
|
||||
)
|
||||
if not KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config}):
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
get_recording_context().save_func_return_value("KnowledgebaseService.update_by_id", update_result)
|
||||
if not update_result:
|
||||
progress_callback(prog=-1.0, msg="Internal error: Invalid GraphRAG configuration")
|
||||
return
|
||||
|
||||
@@ -1434,6 +1530,7 @@ async def do_handle_task(task):
|
||||
with_community=with_community,
|
||||
)
|
||||
logging.info(f"GraphRAG task result for task {task}:\n{result}")
|
||||
get_recording_context().record("graphrag_result", result)
|
||||
progress_callback(prog=1.0, msg="Knowledge Graph done ({:.2f}s)".format(timer() - start_ts))
|
||||
return
|
||||
elif task_type == "mindmap":
|
||||
@@ -1445,6 +1542,11 @@ async def do_handle_task(task):
|
||||
task['llm_id'] = doc_task_llm_id
|
||||
start_ts = timer()
|
||||
chunks = await build_chunks(task, progress_callback)
|
||||
get_recording_context().record("chunks", chunks)
|
||||
# Record chunk_ids_count for comparison
|
||||
chunk_ids = [c.get("id") for c in chunks if isinstance(c, dict) and "id" in c]
|
||||
get_recording_context().record("chunk_ids_count", len(chunk_ids))
|
||||
# Record chunks array for content comparison (first, middle, last, random)
|
||||
logging.info("Build document {}: {:.2f}s".format(task_document_name, timer() - start_ts))
|
||||
if not chunks:
|
||||
progress_callback(1., msg=f"No chunk built from {task_document_name}")
|
||||
@@ -1461,6 +1563,8 @@ async def do_handle_task(task):
|
||||
logging.exception(error_message)
|
||||
token_count = 0
|
||||
raise
|
||||
get_recording_context().record("token_count", token_count)
|
||||
get_recording_context().record("vector_size", vector_size)
|
||||
progress_message = "Embedding chunks ({:.2f}s)".format(timer() - start_ts)
|
||||
logging.info(progress_message)
|
||||
progress_callback(msg=progress_message)
|
||||
@@ -1479,7 +1583,9 @@ async def do_handle_task(task):
|
||||
|
||||
try:
|
||||
if not await _maybe_insert_chunks(chunks):
|
||||
get_recording_context().record("insertion_result", "failed")
|
||||
return
|
||||
get_recording_context().record("insertion_result", "success")
|
||||
if has_canceled(task_id):
|
||||
progress_callback(-1, msg="Task has been canceled.")
|
||||
return
|
||||
@@ -1487,12 +1593,15 @@ async def do_handle_task(task):
|
||||
if raptor_cleanup_chunks:
|
||||
cleaned_chunks = 0
|
||||
for cleanup_doc_id, keep_method in raptor_cleanup_chunks:
|
||||
cleaned_chunks += await delete_raptor_chunks(
|
||||
ret = await delete_raptor_chunks(
|
||||
cleanup_doc_id,
|
||||
task_tenant_id,
|
||||
task_dataset_id,
|
||||
keep_method=keep_method,
|
||||
)
|
||||
cleaned_chunks += ret
|
||||
get_recording_context().save_func_return_value("delete_raptor_chunks", ret)
|
||||
|
||||
if cleaned_chunks:
|
||||
progress_callback(msg=f"Cleaned up {cleaned_chunks} stale RAPTOR chunks.")
|
||||
|
||||
@@ -1502,7 +1611,8 @@ async def do_handle_task(task):
|
||||
)
|
||||
)
|
||||
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, chunk_count, 0)
|
||||
ret = DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, chunk_count, 0)
|
||||
get_recording_context().save_func_return_value("DocumentService.increment_chunk_num", ret)
|
||||
|
||||
# Table parser (manual): push metadata/both column values to document-level metadata for UI / chat filters
|
||||
if task.get("parser_id", "").lower() == "table":
|
||||
@@ -1525,7 +1635,8 @@ async def do_handle_task(task):
|
||||
f"table_strip_key_count={len(strip_keys)}, agg_keys={list(agg.keys())}"
|
||||
)
|
||||
try:
|
||||
DocMetadataService.update_document_metadata(task_doc_id, merged)
|
||||
ret = DocMetadataService.update_document_metadata(task_doc_id, merged)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
logging.debug("[TABLE_META_DEBUG] update_document_metadata succeeded")
|
||||
except Exception as ue:
|
||||
logging.error(
|
||||
@@ -1546,15 +1657,20 @@ async def do_handle_task(task):
|
||||
if toc_thread:
|
||||
d = await toc_thread
|
||||
if d:
|
||||
get_recording_context().record("toc_chunk", [d])
|
||||
if not await _maybe_insert_chunks([d]):
|
||||
get_recording_context().record("toc_inserted", False)
|
||||
return
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, 0, 1, 0)
|
||||
get_recording_context().record("toc_inserted", True)
|
||||
ret = DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, 0, 1, 0)
|
||||
get_recording_context().save_func_return_value("DocumentService.increment_chunk_num", ret)
|
||||
|
||||
if has_canceled(task_id):
|
||||
progress_callback(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
task_time_cost = timer() - task_start_ts
|
||||
get_recording_context().record("task_status", "completed")
|
||||
progress_callback(prog=1.0, msg="Task done ({:.2f}s)".format(task_time_cost))
|
||||
logging.info(
|
||||
"Chunk doc({}), page({}-{}), chunks({}), token({}), elapsed:{:.2f}".format(
|
||||
@@ -1573,12 +1689,13 @@ async def do_handle_task(task):
|
||||
task_dataset_id,
|
||||
)
|
||||
if exists:
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": task_doc_id},
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
except Exception as e:
|
||||
logging.exception(
|
||||
f"Remove doc({task_doc_id}) from docStore failed when task({task_id}) canceled, exception: {e}")
|
||||
@@ -1596,9 +1713,28 @@ async def handle_task():
|
||||
PipelineTaskType.PARSE) or PipelineTaskType.PARSE
|
||||
task_id = task["id"]
|
||||
try:
|
||||
logging.info(f"handle_task begin for task {json.dumps(task)}")
|
||||
CURRENT_TASKS[task["id"]] = copy.deepcopy(task)
|
||||
await do_handle_task(task)
|
||||
run_mode = os.environ.get("TE_RUN_MODE", "0")
|
||||
logging.info(f"TE_RUN_MODE is {run_mode}")
|
||||
|
||||
# Check if dry-run comparison is enabled via environment variable
|
||||
if run_mode == "1": # dry run mode - compare
|
||||
set_recording_context(RecordingContext())
|
||||
await do_handle_task(task) # original execution
|
||||
# dry run mode
|
||||
logging.info(f"-----dry run task:{task_id}, {task.get('name', '')}, doc id:{task.get('doc_id', '')}")
|
||||
await TaskManager.dry_run_task(task, get_recording_context(), chat_limiter, minio_limiter, chunk_limiter,
|
||||
embed_limiter,kg_limiter, set_progress, has_canceled)
|
||||
elif run_mode == "0": # use refactor-ed version
|
||||
# switch to refactor-ed version
|
||||
logging.info(f"-----run refactor-ed task executor:{task_id}, {task.get('name', '')}, doc id:{task.get('doc_id', '')}")
|
||||
await TaskManager.run_refactored_task(task, chat_limiter, minio_limiter, chunk_limiter,
|
||||
embed_limiter,kg_limiter, set_progress, has_canceled)
|
||||
else: # original version
|
||||
logging.info(f"-----run original task executor:{task_id}, {task.get('name', '')}, doc id:{task.get('doc_id', '')}")
|
||||
set_recording_context(NullRecordingContext())
|
||||
await do_handle_task(task)
|
||||
|
||||
DONE_TASKS += 1
|
||||
CURRENT_TASKS.pop(task_id, None)
|
||||
logging.info(f"handle_task done for task {json.dumps(task)}")
|
||||
@@ -1626,9 +1762,10 @@ async def handle_task():
|
||||
referred_document_id = None
|
||||
if task_type in ["graphrag", "raptor", "mindmap"]:
|
||||
referred_document_id = task["doc_ids"][0]
|
||||
PipelineOperationLogService.record_pipeline_operation(document_id=task["doc_id"], pipeline_id="",
|
||||
ret = PipelineOperationLogService.record_pipeline_operation(document_id=task["doc_id"], pipeline_id="",
|
||||
task_type=pipeline_task_type,
|
||||
task_id=task_id, referred_document_id=referred_document_id)
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.record_pipeline_operation", ret)
|
||||
|
||||
redis_msg.ack()
|
||||
|
||||
@@ -1685,7 +1822,8 @@ async def report_status():
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to report heartbeat: {e}")
|
||||
else:
|
||||
logging.info(f"{CONSUMER_NAME} reported heartbeat: {heartbeat}")
|
||||
logging.debug(f"{CONSUMER_NAME} reported heartbeat: {heartbeat}")
|
||||
pass
|
||||
|
||||
# Clean up own expired heartbeat
|
||||
try:
|
||||
@@ -1752,6 +1890,7 @@ async def main():
|
||||
/____/
|
||||
""")
|
||||
logging.info(f'RAGFlow ingestion version: {get_ragflow_version()}')
|
||||
logging.info(f"ENABLE_DRY_RUN_COMPARISON: {os.environ.get("ENABLE_DRY_RUN_COMPARISON", "0")}")
|
||||
show_configs()
|
||||
settings.init_settings()
|
||||
settings.check_and_install_torch()
|
||||
@@ -1786,6 +1925,17 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Parse command line arguments (consistent with SAAS version)
|
||||
parser = argparse.ArgumentParser(description='Task Executor')
|
||||
parser.add_argument("-i", "--index", type=str, default='0')
|
||||
parser.add_argument("-t", "--type", type=str, default="common", help="[common, graphrag, raptor, resume]")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Update global variables
|
||||
TASK_TYPE = args.type
|
||||
TE_IDX = args.index
|
||||
CONSUMER_NAME = f"task_executor_{TASK_TYPE}_{TE_IDX}"
|
||||
|
||||
faulthandler.enable()
|
||||
init_root_logger(CONSUMER_NAME)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
import os
|
||||
|
||||
from common.asyncio_utils import LoopLocalSemaphore
|
||||
|
||||
MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "5"))
|
||||
MAX_CONCURRENT_CHUNK_BUILDERS = int(os.environ.get("MAX_CONCURRENT_CHUNK_BUILDERS", "1"))
|
||||
MAX_CONCURRENT_MINIO = int(os.environ.get("MAX_CONCURRENT_MINIO", "10"))
|
||||
|
||||
task_limiter = LoopLocalSemaphore(MAX_CONCURRENT_TASKS)
|
||||
chunk_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
embed_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
minio_limiter = LoopLocalSemaphore(MAX_CONCURRENT_MINIO)
|
||||
kg_limiter = LoopLocalSemaphore(2)
|
||||
@@ -0,0 +1,136 @@
|
||||
#
|
||||
# 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 Builder Module.
|
||||
|
||||
Provides parser factory and document chunking logic:
|
||||
- Parser module registration and selection
|
||||
- Document chunking via parser
|
||||
- PDF outline extraction
|
||||
"""
|
||||
|
||||
import logging
|
||||
from timeit import default_timer as timer
|
||||
from typing import Dict, List
|
||||
|
||||
from common.constants import ParserType
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from common.metadata_utils import update_metadata_to
|
||||
from rag.utils.table_es_metadata import merge_table_parser_config_from_kb
|
||||
|
||||
|
||||
def get_parser(parser_id: str):
|
||||
"""Get parser module by ID.
|
||||
|
||||
Args:
|
||||
parser_id: The parser identifier.
|
||||
|
||||
Returns:
|
||||
The parser module for the given parser ID.
|
||||
"""
|
||||
from rag.app import laws, paper, presentation, manual, qa, table, book, resume, picture, naive, one, audio, email, tag
|
||||
|
||||
factory = {
|
||||
"general": naive,
|
||||
ParserType.NAIVE.value: naive,
|
||||
ParserType.PAPER.value: paper,
|
||||
ParserType.BOOK.value: book,
|
||||
ParserType.PRESENTATION.value: presentation,
|
||||
ParserType.MANUAL.value: manual,
|
||||
ParserType.LAWS.value: laws,
|
||||
ParserType.QA.value: qa,
|
||||
ParserType.TABLE.value: table,
|
||||
ParserType.RESUME.value: resume,
|
||||
ParserType.PICTURE.value: picture,
|
||||
ParserType.ONE.value: one,
|
||||
ParserType.AUDIO.value: audio,
|
||||
ParserType.EMAIL.value: email,
|
||||
ParserType.KG.value: naive,
|
||||
ParserType.TAG.value: tag,
|
||||
}
|
||||
return factory[parser_id.lower()]
|
||||
|
||||
|
||||
async def run_chunking(
|
||||
chunker,
|
||||
binary: bytes,
|
||||
ctx: TaskContext,
|
||||
) -> List[Dict]:
|
||||
"""Run document chunking via parser.
|
||||
|
||||
Args:
|
||||
chunker: The parser module to use.
|
||||
binary: Binary content of the document.
|
||||
ctx: TaskContext containing task configuration.
|
||||
|
||||
Returns:
|
||||
List of chunk dictionaries.
|
||||
"""
|
||||
st = timer()
|
||||
try:
|
||||
# Merge table parser config
|
||||
parser_config = merge_table_parser_config_from_kb(ctx.raw_task)
|
||||
|
||||
async with ctx.chunk_limiter:
|
||||
cks = await thread_pool_exec(
|
||||
chunker.chunk,
|
||||
ctx.name,
|
||||
binary=binary,
|
||||
from_page=ctx.from_page,
|
||||
to_page=ctx.to_page,
|
||||
lang=ctx.language,
|
||||
callback=ctx.progress_cb,
|
||||
kb_id=ctx.kb_id,
|
||||
parser_config=parser_config,
|
||||
tenant_id=ctx.tenant_id,
|
||||
)
|
||||
logging.info("Chunking({}) {}/{} done".format(timer() - st, ctx.location, ctx.name))
|
||||
ctx.recording_context.record("parser_config_after_merge", parser_config)
|
||||
return cks
|
||||
except Exception as e:
|
||||
ctx.progress_cb(-1, msg="Internal server error while chunking: %s" % str(e).replace("'", ""))
|
||||
logging.exception("Chunking {}/{} got exception".format(ctx.location, ctx.name))
|
||||
raise
|
||||
|
||||
|
||||
async def extract_outline(cks: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Extract and persist PDF outline if present.
|
||||
|
||||
Args:
|
||||
cks: List of chunk dictionaries.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
outline_data = cks[0].get("__outline__") if cks else None
|
||||
ctx.recording_context.record("outline_data", outline_data)
|
||||
|
||||
if cks and cks[0].get("__outline__"):
|
||||
outline = cks[0].pop("__outline__")
|
||||
try:
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
temp_doc = DocMetadataService.get_document_metadata(ctx.doc_id) or {}
|
||||
DocMetadataService.update_document_metadata(
|
||||
ctx.doc_id,
|
||||
update_metadata_to({"outline": outline}, temp_doc)
|
||||
)
|
||||
|
||||
logging.info("Persisted PDF outline (%d entries) for doc %s", len(outline), ctx.doc_id)
|
||||
except Exception as e:
|
||||
logging.warning("Failed to persist PDF outline for doc %s: %s", ctx.doc_id, e)
|
||||
@@ -0,0 +1,308 @@
|
||||
#
|
||||
# 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 Post-Processor Module.
|
||||
|
||||
Provides post-processing functions for chunks:
|
||||
- Keyword extraction
|
||||
- Question generation
|
||||
- Metadata generation
|
||||
- Content tagging
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from timeit import default_timer as timer
|
||||
from typing import Dict, List
|
||||
|
||||
from common.constants import TAG_FLD, LLMType
|
||||
from common.metadata_utils import turn2jsonschema, update_metadata_to
|
||||
from common import settings
|
||||
from rag.nlp import rag_tokenizer
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
from api.db.joint_services.tenant_model_service import get_model_config_by_type_and_name
|
||||
from rag.prompts.generator import gen_metadata, keyword_extraction, question_proposal, content_tagging
|
||||
from rag.graphrag.utils import get_llm_cache, set_llm_cache
|
||||
|
||||
|
||||
async def extract_keywords(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Extract keywords for chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
st = timer()
|
||||
ctx.progress_cb(msg="Start to generate keywords for every chunk ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(ctx.tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
async def doc_keyword_extraction(chat_mdl, d, topn):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], "keywords", {"topn": topn})
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
async with chat_limiter:
|
||||
cached = await keyword_extraction(chat_mdl, d["content_with_weight"], topn)
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, "keywords", {"topn": topn})
|
||||
if cached:
|
||||
d["important_kwd"] = [k for k in re.split(r"[,,;;、\r\n]+", cached) if k.strip()]
|
||||
d["important_tks"] = rag_tokenizer.tokenize(" ".join(d["important_kwd"]))
|
||||
return
|
||||
|
||||
tasks = []
|
||||
for doc in docs:
|
||||
tasks.append(
|
||||
asyncio.create_task(doc_keyword_extraction(chat_model, doc, ctx.parser_config["auto_keywords"])))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error in doc_keyword_extraction: {}".format(e))
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
ctx.progress_cb(msg="Keywords generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
async def generate_questions(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Generate questions for chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
st = timer()
|
||||
ctx.progress_cb(msg="Start to generate questions for every chunk ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(ctx.tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
async def doc_question_proposal(chat_mdl, d, topn):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], "question", {"topn": topn})
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
async with chat_limiter:
|
||||
cached = await question_proposal(chat_mdl, d["content_with_weight"], topn)
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, "question", {"topn": topn})
|
||||
if cached:
|
||||
d["question_kwd"] = cached.split("\n")
|
||||
d["question_tks"] = rag_tokenizer.tokenize("\n".join(d["question_kwd"]))
|
||||
|
||||
tasks = []
|
||||
for doc in docs:
|
||||
tasks.append(
|
||||
asyncio.create_task(doc_question_proposal(chat_model, doc, ctx.parser_config["auto_questions"])))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error in doc_question_proposal", exc_info=e)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
ctx.progress_cb(msg="Question generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
def build_metadata_config(parser_config: dict) -> list:
|
||||
"""Build the metadata configuration from parser_config.
|
||||
|
||||
Extracts and normalizes ``metadata`` and ``built_in_metadata`` from the
|
||||
parser configuration into a single list or dict that is passed to the LLM
|
||||
cache and generation functions.
|
||||
|
||||
This should be called once per ``generate_metadata`` invocation — the result
|
||||
is identical for every chunk within the same document parse session so
|
||||
extracting it avoids rebuilding inside the per-chunk async task.
|
||||
|
||||
Args:
|
||||
parser_config: Configuration dict from the parser, expected to contain
|
||||
``metadata`` (dict or list) and optionally ``built_in_metadata``
|
||||
(list of metadata item dicts).
|
||||
|
||||
Returns:
|
||||
A list or dict representing the merged metadata configuration.
|
||||
"""
|
||||
metadata_conf = parser_config.get("metadata", [])
|
||||
built_in_metadata = list(parser_config.get("built_in_metadata") or [])
|
||||
if isinstance(metadata_conf, dict):
|
||||
if not isinstance(metadata_conf.get("properties"), dict):
|
||||
metadata_conf = {"type": "object", "properties": {}}
|
||||
if built_in_metadata:
|
||||
metadata_conf = {
|
||||
**metadata_conf,
|
||||
"properties": {
|
||||
**metadata_conf.get("properties", {}),
|
||||
**turn2jsonschema(built_in_metadata).get("properties", {}),
|
||||
},
|
||||
}
|
||||
elif isinstance(metadata_conf, list):
|
||||
metadata_conf = metadata_conf + built_in_metadata
|
||||
else:
|
||||
metadata_conf = built_in_metadata
|
||||
return metadata_conf
|
||||
|
||||
|
||||
async def generate_metadata(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Generate metadata for chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
st = timer()
|
||||
ctx.progress_cb(msg="Start to generate meta-data for every chunk ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(ctx.tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
metadata_conf = build_metadata_config(ctx.parser_config)
|
||||
|
||||
async def gen_metadata_task(chat_mdl, d):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], "metadata",
|
||||
metadata_conf)
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
async with chat_limiter:
|
||||
cached = await gen_metadata(chat_mdl,
|
||||
turn2jsonschema(metadata_conf),
|
||||
d["content_with_weight"])
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, "metadata",
|
||||
metadata_conf)
|
||||
if cached:
|
||||
d["metadata_obj"] = cached
|
||||
|
||||
tasks = []
|
||||
for doc in docs:
|
||||
tasks.append(asyncio.create_task(gen_metadata_task(chat_model, doc)))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error in gen_metadata", exc_info=e)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
|
||||
metadata = {}
|
||||
for doc in docs:
|
||||
if "metadata_obj" in doc:
|
||||
metadata = update_metadata_to(metadata, doc["metadata_obj"])
|
||||
del doc["metadata_obj"]
|
||||
if metadata:
|
||||
existing_meta = DocMetadataService.get_document_metadata(ctx.doc_id)
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
DocMetadataService.update_document_metadata(ctx.doc_id, metadata)
|
||||
ctx.progress_cb(msg="Metadata generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
async def apply_tags(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Apply tags to chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
ctx.progress_cb(msg="Start to tag for every chunk ...")
|
||||
kb_ids = ctx.kb_parser_config["tag_kb_ids"]
|
||||
tenant_id = ctx.tenant_id
|
||||
topn_tags = ctx.kb_parser_config.get("topn_tags", 3)
|
||||
S = 1000
|
||||
st = timer()
|
||||
examples = []
|
||||
all_tags = settings.retriever.all_tags_in_portion(tenant_id, kb_ids, S)
|
||||
chat_model_config = get_model_config_by_type_and_name(tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
docs_to_tag = []
|
||||
for doc in docs:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
if settings.retriever.tag_content(tenant_id, kb_ids, doc, all_tags, topn_tags=topn_tags, S=S) and len(
|
||||
doc.get(TAG_FLD, [])) > 0:
|
||||
examples.append({"content": doc["content_with_weight"], TAG_FLD: doc[TAG_FLD]})
|
||||
else:
|
||||
docs_to_tag.append(doc)
|
||||
|
||||
async def doc_content_tagging(chat_mdl, d, topn_tags):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], all_tags, {"topn": topn_tags})
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
picked_examples = random.choices(examples, k=2) if len(examples) > 2 else examples
|
||||
if not picked_examples:
|
||||
picked_examples.append({"content": "This is an example", TAG_FLD: {'example': 1}})
|
||||
async with chat_limiter:
|
||||
cached = await content_tagging(
|
||||
chat_mdl,
|
||||
d["content_with_weight"],
|
||||
all_tags,
|
||||
picked_examples,
|
||||
topn_tags,
|
||||
)
|
||||
if cached:
|
||||
cached = json.dumps(cached)
|
||||
if cached:
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, all_tags, {"topn": topn_tags})
|
||||
d[TAG_FLD] = json.loads(cached)
|
||||
|
||||
tasks = []
|
||||
for doc in docs_to_tag:
|
||||
tasks.append(asyncio.create_task(doc_content_tagging(chat_model, doc, topn_tags)))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error tagging docs: {}".format(e))
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
ctx.progress_cb(msg="Tagging {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
def count_with_key(docs: List[Dict], key: str) -> int:
|
||||
"""Count docs that have a specific key.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries.
|
||||
key: The key to check for.
|
||||
|
||||
Returns:
|
||||
Count of docs that have the key.
|
||||
"""
|
||||
return sum(1 for d in docs if d.get(key))
|
||||
@@ -0,0 +1,479 @@
|
||||
#
|
||||
# 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.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
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
@@ -0,0 +1,570 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Comparison Logic Module.
|
||||
|
||||
This module provides the [`ContextComparator`](rag/svr/task_executor_refactor/comparator.py:100) class, which compares
|
||||
intermediate results from two [`RecordingContext`](rag/svr/task_executor_refactor/recording_context.py:54) instances:
|
||||
one from production execution and one from dry-run execution.
|
||||
|
||||
The comparison supports various data types with appropriate strategies:
|
||||
- Basic types (int, str, bool): Direct equality comparison
|
||||
- Float numbers: Configurable tolerance range
|
||||
- Lists: Length comparison + ID set comparison + full content comparison (all chunks)
|
||||
- Dicts: Key set comparison + recursive value comparison
|
||||
- None: Equality comparison
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional, Set
|
||||
|
||||
from rag.svr.task_executor_refactor.recording_context import BaseRecordingContext
|
||||
from rag.svr.task_executor_refactor.report_generator import (
|
||||
ComparisonResult,
|
||||
ComparisonReport,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.write_operation_interceptor import ALLOWED_METHOD_NAMES
|
||||
|
||||
|
||||
class ContextComparator:
|
||||
"""Compare two RecordingContext instances for intermediate results.
|
||||
|
||||
This class compares the recorded data from production execution against
|
||||
dry-run execution, generating a detailed report of matches and mismatches.
|
||||
|
||||
Usage:
|
||||
comparator = ContextComparator()
|
||||
report = comparator.compare("task_123", ctx_production, ctx_dry_run)
|
||||
print(report.summary())
|
||||
"""
|
||||
|
||||
# Default tolerance for float comparison
|
||||
DEFAULT_FLOAT_TOLERANCE = 1e-6
|
||||
|
||||
# Keys to strip from dict values before comparison (non-deterministic values)
|
||||
DICT_KEYS_TO_STRIP = {"seconds", "_created_time", "_elapsed_time"}
|
||||
|
||||
# Keys that represent counts and should be compared as numbers
|
||||
COUNT_KEYS = {
|
||||
"outline_entry_count",
|
||||
"tags_applied_count",
|
||||
"final_chunk_count",
|
||||
"final_chunk_ids_count",
|
||||
"chunk_count",
|
||||
"chunk_ids_count",
|
||||
"token_count",
|
||||
"raptor_token_count",
|
||||
}
|
||||
|
||||
# Keys that contain chunk data for comparison
|
||||
CHUNK_KEYS = {
|
||||
"toc_chunk",
|
||||
"raw_chunks",
|
||||
"final_chunks",
|
||||
"chunks",
|
||||
"raptor_chunks",
|
||||
"docs_after_prep",
|
||||
"dataflow_chunks",
|
||||
}
|
||||
|
||||
def __init__(self, float_tolerance: float = None):
|
||||
"""Initialize the Comparator.
|
||||
|
||||
Args:
|
||||
float_tolerance: Tolerance for float comparison.
|
||||
Defaults to DEFAULT_FLOAT_TOLERANCE.
|
||||
"""
|
||||
self.float_tolerance = self.DEFAULT_FLOAT_TOLERANCE if float_tolerance is None else float_tolerance
|
||||
|
||||
def _strip_non_deterministic_fields(self, data: dict) -> dict:
|
||||
"""Remove non-deterministic fields (like 'seconds') from dict values.
|
||||
|
||||
This creates a shallow copy of the data dict with specified keys
|
||||
removed from any nested dict values.
|
||||
|
||||
Args:
|
||||
data: The input dictionary to process.
|
||||
|
||||
Returns:
|
||||
A new dictionary with non-deterministic fields removed.
|
||||
"""
|
||||
import copy
|
||||
result = copy.copy(data)
|
||||
for key, value in result.items():
|
||||
if isinstance(value, dict):
|
||||
# Create a new dict without the non-deterministic keys
|
||||
cleaned = {
|
||||
k: v for k, v in value.items()
|
||||
if k not in self.DICT_KEYS_TO_STRIP
|
||||
}
|
||||
result[key] = cleaned
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _get_key_values_to_compare(prod_data_all:dict):
|
||||
prod_data = dict()
|
||||
for key, value in prod_data_all.items():
|
||||
if key in ALLOWED_METHOD_NAMES:
|
||||
continue
|
||||
if key.endswith("_time"):
|
||||
continue
|
||||
if key.startswith("settings.docStoreConn."):
|
||||
continue
|
||||
prod_data[key] = value
|
||||
return prod_data
|
||||
|
||||
def compare(
|
||||
self,
|
||||
task_id: str,
|
||||
ctx_production: BaseRecordingContext,
|
||||
ctx_dry_run: BaseRecordingContext,
|
||||
comparison_keys: List[str] = None,
|
||||
) -> ComparisonReport:
|
||||
"""Compare two RecordingContext instances.
|
||||
|
||||
Args:
|
||||
task_id: The task identifier.
|
||||
ctx_production: RecordingContext from production execution.
|
||||
ctx_dry_run: RecordingContext from dry-run execution.
|
||||
comparison_keys: Optional list of keys to compare.
|
||||
If None, all keys from both contexts will be compared.
|
||||
|
||||
Returns:
|
||||
A ComparisonReport with the comparison results.
|
||||
"""
|
||||
report = ComparisonReport(task_id=task_id)
|
||||
|
||||
# Get all keys from both contexts
|
||||
prod_data_all = ctx_production.get_all_func_return_values() if ctx_production else {}
|
||||
prod_data = self._get_key_values_to_compare(prod_data_all)
|
||||
dry_run_data_all = ctx_dry_run.get_all_func_return_values() if ctx_dry_run else {}
|
||||
dry_run_data = self._get_key_values_to_compare(dry_run_data_all)
|
||||
|
||||
# Strip non-deterministic fields (like 'seconds') from dict values
|
||||
prod_data = self._strip_non_deterministic_fields(prod_data)
|
||||
dry_run_data = self._strip_non_deterministic_fields(dry_run_data)
|
||||
|
||||
# Determine keys to compare
|
||||
if comparison_keys:
|
||||
keys_to_compare = set(comparison_keys)
|
||||
else:
|
||||
keys_to_compare = set(prod_data.keys()) | set(dry_run_data.keys())
|
||||
|
||||
# Find missing keys
|
||||
prod_keys = set(prod_data.keys())
|
||||
dry_run_keys = set(dry_run_data.keys())
|
||||
|
||||
report.missing_in_production = sorted(dry_run_keys - prod_keys)
|
||||
report.missing_in_dry_run = sorted(prod_keys - dry_run_keys)
|
||||
|
||||
# Compare each key
|
||||
for key in sorted(keys_to_compare):
|
||||
if key in prod_data and key in dry_run_data:
|
||||
result = self.compare_value(key, prod_data[key], dry_run_data[key])
|
||||
report.details.append(result)
|
||||
if result.match:
|
||||
report.matched_keys += 1
|
||||
else:
|
||||
report.mismatched_keys += 1
|
||||
logging.info(f"---prod:{prod_data[key]} diff with dry run:{dry_run_data[key]}")
|
||||
|
||||
report.total_keys = report.matched_keys + report.mismatched_keys
|
||||
return report
|
||||
|
||||
def compare_value(
|
||||
self,
|
||||
key: str,
|
||||
prod_value: Any,
|
||||
dry_run_value: Any,
|
||||
) -> ComparisonResult:
|
||||
"""Compare a single value with appropriate strategy.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_value: Value from production context.
|
||||
dry_run_value: Value from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
# Handle None cases
|
||||
if prod_value is None and dry_run_value is None:
|
||||
return ComparisonResult(key=key, match=True)
|
||||
if prod_value is None or dry_run_value is None:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details="One value is None",
|
||||
)
|
||||
|
||||
# Handle booleans
|
||||
if isinstance(prod_value, bool) and isinstance(dry_run_value, bool):
|
||||
match = prod_value == dry_run_value
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=match,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=None if match else "Boolean values differ",
|
||||
)
|
||||
|
||||
# Handle lists (chunks)
|
||||
if isinstance(prod_value, list) and isinstance(dry_run_value, list):
|
||||
if key in self.CHUNK_KEYS:
|
||||
return self._compare_chunks(key, prod_value, dry_run_value)
|
||||
return self._compare_lists(key, prod_value, dry_run_value)
|
||||
|
||||
# Handle dicts
|
||||
if isinstance(prod_value, dict) and isinstance(dry_run_value, dict):
|
||||
return self._compare_dicts(key, prod_value, dry_run_value)
|
||||
|
||||
# Handle numbers
|
||||
if isinstance(prod_value, (int, float)) and isinstance(dry_run_value, (int, float)):
|
||||
return self._compare_numbers(key, prod_value, dry_run_value)
|
||||
|
||||
# Handle strings
|
||||
if isinstance(prod_value, str) and isinstance(dry_run_value, str):
|
||||
match = prod_value == dry_run_value
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=match,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=None if match else "String values differ",
|
||||
)
|
||||
|
||||
# Default: try direct equality
|
||||
match = prod_value == dry_run_value
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=match,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=None if match else "Values differ",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _compare_lists(cls, key: str, prod_list: list, dry_run_list: list) -> ComparisonResult:
|
||||
"""Compare two lists.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_list: List from production context.
|
||||
dry_run_list: List from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
if len(prod_list) != len(dry_run_list):
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_list),
|
||||
dry_run_value=len(dry_run_list),
|
||||
diff_details=f"Length differs: {len(prod_list)} vs {len(dry_run_list)}",
|
||||
)
|
||||
|
||||
# Try element-wise comparison
|
||||
for i, (p, d) in enumerate(zip(prod_list, dry_run_list)):
|
||||
if p != d:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_list),
|
||||
dry_run_value=len(dry_run_list),
|
||||
diff_details=f"Element {i} differs",
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=len(prod_list),
|
||||
dry_run_value=len(dry_run_list),
|
||||
)
|
||||
|
||||
def _compare_chunks(
|
||||
self,
|
||||
key: str,
|
||||
prod_chunks: list,
|
||||
dry_run_chunks: list,
|
||||
) -> ComparisonResult:
|
||||
"""Compare chunk lists with multi-level strategy.
|
||||
|
||||
Comparison levels:
|
||||
1. Length comparison
|
||||
2. ID set comparison
|
||||
3. Full content comparison (all chunks)
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_chunks: Chunks from production context.
|
||||
dry_run_chunks: Chunks from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
# Level 1: Length comparison
|
||||
if len(prod_chunks) != len(dry_run_chunks):
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_chunks),
|
||||
dry_run_value=len(dry_run_chunks),
|
||||
diff_details=f"Chunk count differs: {len(prod_chunks)} vs {len(dry_run_chunks)}",
|
||||
)
|
||||
|
||||
# Level 2: ID set comparison
|
||||
prod_ids = self._extract_chunk_ids(prod_chunks)
|
||||
dry_run_ids = self._extract_chunk_ids(dry_run_chunks)
|
||||
|
||||
if prod_ids != dry_run_ids:
|
||||
missing_ids = prod_ids - dry_run_ids
|
||||
extra_ids = dry_run_ids - prod_ids
|
||||
details = f"Chunk IDs differ, total prod:{len(prod_ids)}, dry run:{len(dry_run_ids)}"
|
||||
if missing_ids:
|
||||
details += f", missing in dry-run: {len(missing_ids)}"
|
||||
if extra_ids:
|
||||
details += f", extra in dry-run: {len(extra_ids)}"
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_ids),
|
||||
dry_run_value=len(dry_run_ids),
|
||||
diff_details=details,
|
||||
)
|
||||
|
||||
# Level 3: Full content comparison (all chunks)
|
||||
content_diffs = self._compare_all_chunks(prod_chunks, dry_run_chunks)
|
||||
if content_diffs:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_chunks),
|
||||
dry_run_value=len(dry_run_chunks),
|
||||
diff_details=f"Content differs in samples: {'; '.join(content_diffs[:3])}",
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=len(prod_chunks),
|
||||
dry_run_value=len(dry_run_chunks),
|
||||
)
|
||||
|
||||
def _compare_all_chunks(
|
||||
self,
|
||||
prod_chunks: list,
|
||||
dry_run_chunks: list,
|
||||
) -> List[str]:
|
||||
"""Compare ALL chunks from both lists.
|
||||
|
||||
Args:
|
||||
prod_chunks: Chunks from production context.
|
||||
dry_run_chunks: Chunks from dry-run context.
|
||||
|
||||
Returns:
|
||||
List of difference descriptions.
|
||||
"""
|
||||
if not prod_chunks or not dry_run_chunks:
|
||||
return []
|
||||
|
||||
diffs = []
|
||||
n = len(prod_chunks)
|
||||
|
||||
# Check if chunks have valid IDs
|
||||
prod_has_id = any(self._get_chunk_id(c) for c in prod_chunks)
|
||||
dry_run_has_id = any(self._get_chunk_id(c) for c in dry_run_chunks)
|
||||
use_index_matching = not prod_has_id or not dry_run_has_id
|
||||
|
||||
# Build index by chunk ID for matching (only if IDs are available)
|
||||
if not use_index_matching:
|
||||
dry_run_by_id = {self._get_chunk_id(c): c for c in dry_run_chunks}
|
||||
else:
|
||||
dry_run_by_id = None
|
||||
|
||||
# Compare ALL chunks
|
||||
for idx in range(n):
|
||||
prod_chunk = prod_chunks[idx]
|
||||
chunk_id = self._get_chunk_id(prod_chunk)
|
||||
|
||||
if use_index_matching:
|
||||
# Use index position for matching
|
||||
if idx < len(dry_run_chunks):
|
||||
dry_run_chunk = dry_run_chunks[idx]
|
||||
else:
|
||||
dry_run_chunk = None
|
||||
else:
|
||||
# Use ID for matching
|
||||
dry_run_chunk = dry_run_by_id.get(chunk_id)
|
||||
|
||||
if dry_run_chunk is None:
|
||||
diffs.append(f"Chunk {idx} (id={chunk_id}) not found in dry-run")
|
||||
continue
|
||||
|
||||
# Compare content
|
||||
content_diff = self._compare_chunk_content(prod_chunk, dry_run_chunk)
|
||||
if content_diff:
|
||||
diffs.append(f"Chunk {idx} (id={chunk_id}): {content_diff}")
|
||||
|
||||
return diffs
|
||||
|
||||
@classmethod
|
||||
def _compare_chunk_content(cls, prod_chunk: dict, dry_run_chunk: dict) -> Optional[str]:
|
||||
"""Compare content of two chunks.
|
||||
|
||||
Args:
|
||||
prod_chunk: Chunk from production context.
|
||||
dry_run_chunk: Chunk from dry-run context.
|
||||
|
||||
Returns:
|
||||
Difference description or None if matched.
|
||||
"""
|
||||
# Compare key fields
|
||||
key_fields = ["content_with_weight", "content_ltks", "doc_id", "kb_id"]
|
||||
for fld in key_fields:
|
||||
if prod_chunk.get(fld) != dry_run_chunk.get(fld):
|
||||
return f"Field '{fld}' differs, prod_chunk:{prod_chunk.get(fld)}, dry_run_chunk:{dry_run_chunk}"
|
||||
|
||||
# Compare vector fields
|
||||
prod_vec_keys = {k for k in prod_chunk if k.startswith("q_") and k.endswith("_vec")}
|
||||
dry_run_vec_keys = {k for k in dry_run_chunk if k.startswith("q_") and k.endswith("_vec")}
|
||||
|
||||
if prod_vec_keys != dry_run_vec_keys:
|
||||
return f"Vector fields differ: {prod_vec_keys} vs {dry_run_vec_keys}"
|
||||
|
||||
for vec_key in prod_vec_keys:
|
||||
p_vec = prod_chunk.get(vec_key)
|
||||
d_vec = dry_run_chunk.get(vec_key)
|
||||
if p_vec != d_vec:
|
||||
return f"Vector '{vec_key}' differs"
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_chunk_ids(cls, chunks: list) -> Set[str]:
|
||||
"""Extract chunk IDs from a list of chunks.
|
||||
|
||||
Args:
|
||||
chunks: List of chunk dictionaries.
|
||||
|
||||
Returns:
|
||||
Set of chunk IDs.
|
||||
"""
|
||||
ids = set()
|
||||
for c in chunks:
|
||||
if isinstance(c, dict) and "id" in c:
|
||||
ids.add(str(c["id"]))
|
||||
return ids
|
||||
|
||||
@classmethod
|
||||
def _get_chunk_id(cls, chunk: Any) -> str:
|
||||
"""Get chunk ID from a chunk dictionary.
|
||||
|
||||
Args:
|
||||
chunk: A chunk dictionary.
|
||||
|
||||
Returns:
|
||||
Chunk ID as string, or empty string if not found.
|
||||
"""
|
||||
if isinstance(chunk, dict):
|
||||
return str(chunk.get("id", ""))
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _compare_dicts(cls, key: str, prod_dict: dict, dry_run_dict: dict) -> ComparisonResult:
|
||||
"""Compare two dictionaries.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_dict: Dict from production context.
|
||||
dry_run_dict: Dict from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
prod_keys = set(prod_dict.keys())
|
||||
dry_run_keys = set(dry_run_dict.keys())
|
||||
|
||||
if prod_keys != dry_run_keys:
|
||||
missing = prod_keys - dry_run_keys
|
||||
extra = dry_run_keys - prod_keys
|
||||
details = "Keys differ"
|
||||
if missing:
|
||||
details += f", missing in dry-run: {missing}"
|
||||
if extra:
|
||||
details += f", extra in dry-run: {extra}"
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=sorted(prod_keys),
|
||||
dry_run_value=sorted(dry_run_keys),
|
||||
diff_details=details,
|
||||
)
|
||||
|
||||
# Compare values for each key
|
||||
for k in prod_keys:
|
||||
p_val = prod_dict[k]
|
||||
d_val = dry_run_dict[k]
|
||||
if p_val != d_val:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=prod_dict,
|
||||
dry_run_value=dry_run_dict,
|
||||
diff_details=f"Value for key '{k}' differs",
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=prod_dict,
|
||||
dry_run_value=dry_run_dict,
|
||||
)
|
||||
|
||||
def _compare_numbers(
|
||||
self,
|
||||
key: str,
|
||||
prod_value: float,
|
||||
dry_run_value: float,
|
||||
) -> ComparisonResult:
|
||||
"""Compare two numbers with tolerance.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_value: Number from production context.
|
||||
dry_run_value: Number from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
diff = abs(prod_value - dry_run_value)
|
||||
if diff <= self.float_tolerance:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=f"Difference {diff} exceeds tolerance {self.float_tolerance}",
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Shared constants for task executor modules.
|
||||
|
||||
This module exists to break circular imports between task_executor.py and
|
||||
task_executor_refactor modules.
|
||||
"""
|
||||
|
||||
CANVAS_DEBUG_DOC_ID = "dataflow_x"
|
||||
GRAPH_RAPTOR_FAKE_DOC_ID = "graph_raptor_x"
|
||||
@@ -0,0 +1,389 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Dataflow Service Module.
|
||||
|
||||
Provides [`DataflowService`](rag/svr/task_executor_refactor/dataflow_service.py:42) for dataflow
|
||||
pipeline execution.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from timeit import default_timer as timer
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import xxhash
|
||||
from common import settings
|
||||
from rag.svr.task_executor_refactor.embedding_utils import EmbeddingUtils
|
||||
from rag.flow.pipeline import Pipeline
|
||||
|
||||
from api.db.services.canvas_service import UserCanvasService
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from api.db.services.pipeline_operation_log_service import PipelineOperationLogService
|
||||
from api.db.joint_services.tenant_model_service import get_model_config_by_type_and_name
|
||||
from common.constants import LLMType, PipelineTaskType
|
||||
from common.metadata_utils import update_metadata_to
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from rag.nlp import rag_tokenizer, add_positions
|
||||
from rag.svr.task_executor_refactor.constants import CANVAS_DEBUG_DOC_ID
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
|
||||
class BillingHook(abc.ABC):
|
||||
"""Abstract base for billing hooks on pipeline success/error.
|
||||
|
||||
Implementations override the no-op methods to integrate with billing
|
||||
systems (e.g., consume quota on success, release hold on error).
|
||||
"""
|
||||
|
||||
async def on_pipeline_success(self) -> None:
|
||||
"""Called when the dataflow pipeline completes successfully."""
|
||||
|
||||
async def on_pipeline_error(self) -> None:
|
||||
"""Called when the dataflow pipeline encounters an error."""
|
||||
|
||||
|
||||
class DataflowService:
|
||||
"""Service for dataflow pipeline execution.
|
||||
|
||||
This service handles:
|
||||
- Dataflow DSL loading and execution
|
||||
- Chunk embedding for dataflow output
|
||||
- Chunk metadata processing and indexing
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
billing_hook: Optional[BillingHook] = None,
|
||||
embedding_batch_size: int = None,
|
||||
doc_bulk_size: int = None,
|
||||
):
|
||||
"""Initialize DataflowService.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
billing_hook: Optional billing hook for pipeline success/error callbacks.
|
||||
embedding_batch_size: Batch size for embedding operations.
|
||||
doc_bulk_size: Batch size for document store inserts.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
self._billing_hook = billing_hook
|
||||
self._embedding_batch_size = embedding_batch_size or self._get_default_embedding_batch_size()
|
||||
self._doc_bulk_size = doc_bulk_size or self._get_default_bulk_size()
|
||||
|
||||
async def run_dataflow(self) -> None:
|
||||
"""Run a dataflow pipeline."""
|
||||
ctx = self._task_context
|
||||
pipeline = None
|
||||
try:
|
||||
task_start_ts = timer()
|
||||
dataflow_id = ctx.dataflow_id
|
||||
doc_id = ctx.doc_id
|
||||
task_id = ctx.id
|
||||
task_dataset_id = ctx.kb_id
|
||||
|
||||
# Load DSL
|
||||
dsl = await self._load_dsl(dataflow_id)
|
||||
if dsl is None:
|
||||
return
|
||||
|
||||
# Run pipeline
|
||||
pipeline = Pipeline(
|
||||
dsl, tenant_id=ctx.tenant_id, doc_id=doc_id,
|
||||
task_id=task_id, flow_id=dataflow_id
|
||||
)
|
||||
chunks = await pipeline.run(file=ctx.file) if ctx.file else await pipeline.run()
|
||||
|
||||
if doc_id == CANVAS_DEBUG_DOC_ID:
|
||||
ctx.recording_context.record("dataflow_debug_result", "canvas_debug_mode")
|
||||
ctx.recording_context.record("dataflow_chunks", chunks)
|
||||
return
|
||||
|
||||
if not chunks:
|
||||
ctx.recording_context.record("pipeline_output_count", 0)
|
||||
ctx.recording_context.record("pipeline_output_type", "empty")
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
embedding_token_consumption = chunks.get("embedding_token_consumption", 0)
|
||||
output_type = DataflowService._get_output_type(chunks)
|
||||
chunks = self._normalize_chunks(chunks)
|
||||
|
||||
ctx.recording_context.record("pipeline_output_type", output_type)
|
||||
ctx.recording_context.record("pipeline_output_count", len(chunks))
|
||||
|
||||
if not chunks:
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
# Embed chunks if needed
|
||||
keys = [k for o in chunks for k in list(o.keys())]
|
||||
if not any([re.match(r"q_[0-9]+_vec", k) for k in keys]):
|
||||
chunks, embedding_token_consumption = await self._embed_chunks(
|
||||
chunks, embedding_token_consumption
|
||||
)
|
||||
if chunks is None:
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
# Process chunks
|
||||
metadata = self._process_chunks(chunks)
|
||||
|
||||
# Update document metadata
|
||||
if metadata:
|
||||
self._update_document_metadata(doc_id, metadata)
|
||||
|
||||
# Insert chunks
|
||||
start_ts = timer()
|
||||
self._progress(prog=0.82, msg="[DOC Engine]:\nStart to index...")
|
||||
e = await self._insert_chunks(
|
||||
task_id, ctx.tenant_id, ctx.kb_id, chunks
|
||||
)
|
||||
if not e:
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
time_cost = timer() - start_ts
|
||||
task_time_cost = timer() - task_start_ts
|
||||
self._progress(
|
||||
prog=1.,
|
||||
msg="Indexing done ({:.2f}s). Task done ({:.2f}s)".format(time_cost, task_time_cost)
|
||||
)
|
||||
|
||||
# Update document stats
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(
|
||||
doc_id, task_dataset_id, embedding_token_consumption, len(chunks), task_time_cost
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"[Done], chunks({}), token({}), elapsed:{:.2f}".format(
|
||||
len(chunks), embedding_token_consumption, task_time_cost
|
||||
)
|
||||
)
|
||||
ctx.recording_context.record("dataflow_chunks", chunks)
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
|
||||
# Billing hook: pipeline succeeded
|
||||
if self._billing_hook:
|
||||
await self._billing_hook.on_pipeline_success()
|
||||
except Exception:
|
||||
if self._billing_hook:
|
||||
await self._billing_hook.on_pipeline_error()
|
||||
raise
|
||||
|
||||
async def _load_dsl(self, dataflow_id: str) -> Optional[str]:
|
||||
"""Load dataflow DSL from service."""
|
||||
ctx = self._task_context
|
||||
if ctx.task_type == "dataflow":
|
||||
e, cvs = UserCanvasService.get_by_id(dataflow_id)
|
||||
assert e, "User pipeline not found."
|
||||
return cvs.dsl
|
||||
else:
|
||||
e, pipeline_log = PipelineOperationLogService.get_by_id(dataflow_id)
|
||||
assert e, "Pipeline log not found."
|
||||
return pipeline_log.dsl
|
||||
|
||||
@staticmethod
|
||||
def _get_output_type(chunks: Dict) -> str:
|
||||
"""Determine output type from chunks dict."""
|
||||
if "chunks" in chunks:
|
||||
return "chunks"
|
||||
elif "json" in chunks:
|
||||
return "json"
|
||||
elif "markdown" in chunks:
|
||||
return "markdown"
|
||||
elif "text" in chunks:
|
||||
return "text"
|
||||
elif "html" in chunks:
|
||||
return "html"
|
||||
return "empty"
|
||||
|
||||
@classmethod
|
||||
def _normalize_chunks(cls, chunks: Dict) -> List[Dict]:
|
||||
"""Normalize chunks from various output formats."""
|
||||
if "chunks" in chunks:
|
||||
return copy.deepcopy(chunks["chunks"])
|
||||
elif "json" in chunks:
|
||||
return copy.deepcopy(chunks["json"])
|
||||
elif "markdown" in chunks:
|
||||
return [{"text": [chunks["markdown"]]}] if chunks["markdown"] else []
|
||||
elif "text" in chunks:
|
||||
return [{"text": [chunks["text"]]}] if chunks["text"] else []
|
||||
elif "html" in chunks:
|
||||
return [{"text": [chunks["html"]]}] if chunks["html"] else []
|
||||
return []
|
||||
|
||||
async def _embed_chunks(
|
||||
self, chunks: List[Dict], token_consumption: int
|
||||
) -> Tuple[Optional[List[Dict]], int]:
|
||||
"""Embed chunks using the embedding model."""
|
||||
ctx = self._task_context
|
||||
try:
|
||||
self._progress(prog=0.82, msg="\n-------------------------------------\nStart to embedding...")
|
||||
e, kb = self._get_kb_by_id(ctx.kb_id)
|
||||
embedding_id = kb.embd_id
|
||||
embd_model_config = get_model_config_by_type_and_name(
|
||||
ctx.tenant_id, LLMType.EMBEDDING, embedding_id
|
||||
)
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
with LLMBundle(ctx.tenant_id, embd_model_config) as embedding_model:
|
||||
|
||||
# Prepare texts for embedding using EmbeddingUtils
|
||||
texts = EmbeddingUtils.prepare_texts_for_dataflow_embedding(chunks)
|
||||
delta = 0.20 / (len(texts) // self._embedding_batch_size + 1)
|
||||
prog = 0.8
|
||||
|
||||
# Batch encode using EmbeddingUtils
|
||||
vects_batches = []
|
||||
for i in range(0, len(texts), self._embedding_batch_size):
|
||||
batch = texts[i: i + self._embedding_batch_size]
|
||||
async with ctx.embed_limiter:
|
||||
vts, c = await thread_pool_exec(
|
||||
self._encode_batch, batch, embedding_model
|
||||
)
|
||||
vects_batches.append(vts)
|
||||
token_consumption += c
|
||||
prog += delta
|
||||
if i % (len(texts) // self._embedding_batch_size / 100 + 1) == 1:
|
||||
self._progress(
|
||||
prog=prog,
|
||||
msg=f"{i + 1} / {len(texts) // self._embedding_batch_size}"
|
||||
)
|
||||
|
||||
# Stack vectors using EmbeddingUtils
|
||||
vects = EmbeddingUtils.stack_vectors(vects_batches)
|
||||
if len(vects) != len(chunks):
|
||||
raise ValueError(f"Vector count mismatch: {len(vects)} vs {len(chunks)}")
|
||||
|
||||
# Attach vectors using EmbeddingUtils
|
||||
EmbeddingUtils.attach_vectors(chunks, vects)
|
||||
|
||||
return chunks, token_consumption
|
||||
|
||||
except Exception as e:
|
||||
ctx.progress_cb(prog=-1, msg=f"[ERROR]: {e}")
|
||||
return None, token_consumption
|
||||
|
||||
@classmethod
|
||||
async def _encode_batch(cls, txts: List[str], embedding_model) -> Tuple[np.ndarray, int]:
|
||||
"""Batch encode texts using the embedding model with truncation."""
|
||||
truncated = EmbeddingUtils.truncate_texts(txts, embedding_model.max_length)
|
||||
return embedding_model.encode(truncated)
|
||||
|
||||
def _process_chunks(self, chunks: List[Dict]) -> Dict:
|
||||
"""Process chunks for metadata and indexing."""
|
||||
ctx = self._task_context
|
||||
metadata = {}
|
||||
for ck in chunks:
|
||||
ck["doc_id"] = ctx.doc_id
|
||||
ck["kb_id"] = [str(ctx.kb_id)]
|
||||
ck["docnm_kwd"] = ctx.name
|
||||
ck["create_time"] = str(datetime.now()).replace("T", " ")[:19]
|
||||
ck["create_timestamp_flt"] = datetime.now().timestamp()
|
||||
|
||||
if not ck.get("id"):
|
||||
ck["id"] = xxhash.xxh64((ck["text"] + str(ck["doc_id"])).encode("utf-8")).hexdigest()
|
||||
|
||||
if "questions" in ck:
|
||||
if "question_tks" not in ck:
|
||||
ck["question_kwd"] = ck["questions"].split("\n")
|
||||
ck["question_tks"] = rag_tokenizer.tokenize(str(ck["questions"]))
|
||||
del ck["questions"]
|
||||
|
||||
if "keywords" in ck:
|
||||
if "important_tks" not in ck:
|
||||
ck["important_kwd"] = [k for k in re.split(r"[,,;;、\r\n]+", ck["keywords"]) if k.strip()]
|
||||
ck["important_tks"] = rag_tokenizer.tokenize(str(ck["keywords"]))
|
||||
del ck["keywords"]
|
||||
|
||||
if "summary" in ck:
|
||||
if "content_ltks" not in ck:
|
||||
ck["content_ltks"] = rag_tokenizer.tokenize(str(ck["summary"]))
|
||||
ck["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(ck["content_ltks"])
|
||||
del ck["summary"]
|
||||
|
||||
if "metadata" in ck:
|
||||
metadata = update_metadata_to(metadata, ck["metadata"])
|
||||
del ck["metadata"]
|
||||
|
||||
if "content_with_weight" not in ck:
|
||||
ck["content_with_weight"] = ck["text"]
|
||||
del ck["text"]
|
||||
|
||||
if "positions" in ck:
|
||||
add_positions(ck, ck["positions"])
|
||||
del ck["positions"]
|
||||
|
||||
return metadata
|
||||
|
||||
def _update_document_metadata(self, doc_id: str, metadata: Dict) -> None:
|
||||
"""Update document metadata."""
|
||||
existing_meta = DocMetadataService.get_document_metadata(doc_id)
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
self._task_context.recording_context.record("run_dataflow_metadata", metadata)
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
DocMetadataService.update_document_metadata(doc_id, metadata)
|
||||
|
||||
async def _insert_chunks(
|
||||
self, task_id: str, tenant_id: str, kb_id: str, chunks: List[Dict]
|
||||
) -> bool:
|
||||
"""Insert chunks into document store."""
|
||||
from rag.svr.task_executor_refactor.chunk_service import ChunkService
|
||||
chunk_service = ChunkService(self._task_context)
|
||||
return await chunk_service.insert_chunks(task_id, tenant_id, kb_id, chunks)
|
||||
|
||||
def _record_pipeline_log(self, doc_id: str, dataflow_id: str, pipeline) -> None:
|
||||
"""Record pipeline operation log."""
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("PipelineOperationLogService.create")
|
||||
else:
|
||||
PipelineOperationLogService.create(
|
||||
document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_kb_by_id(cls, kb_id: str):
|
||||
"""Get knowledge base by ID."""
|
||||
from api.db.services.knowledgebase_service import KnowledgebaseService
|
||||
return KnowledgebaseService.get_by_id(kb_id)
|
||||
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def _get_default_embedding_batch_size(cls) -> int:
|
||||
"""Get default embedding batch size."""
|
||||
return settings.EMBEDDING_BATCH_SIZE
|
||||
|
||||
@classmethod
|
||||
def _get_default_bulk_size(cls) -> int:
|
||||
"""Get default bulk size."""
|
||||
return settings.DOC_BULK_SIZE
|
||||
@@ -0,0 +1,127 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Embedding Service Module.
|
||||
|
||||
Provides [`EmbeddingService`](rag/svr/task_executor_refactor/embedding_service.py:42) for vector embedding operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
from common import settings
|
||||
from rag.svr.task_executor_refactor.embedding_utils import EmbeddingUtils
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
|
||||
class EmbeddingService:
|
||||
"""Service for vector embedding operations.
|
||||
|
||||
This service handles:
|
||||
- Batch encoding of text chunks
|
||||
- Title + content vector combination
|
||||
- Embedding model rate limiting
|
||||
|
||||
All intermediate results are recorded via RecordingContext for comparison.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
embedding_batch_size: int = None,
|
||||
):
|
||||
"""Initialize EmbeddingService.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
embedding_batch_size: Batch size for embedding operations.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
|
||||
self._embedding_batch_size = embedding_batch_size or settings.EMBEDDING_BATCH_SIZE
|
||||
|
||||
def embed_chunks(
|
||||
self,
|
||||
docs: List[Dict[str, Any]],
|
||||
embedding_model,
|
||||
parser_config: Dict = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Embed a list of chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to embed.
|
||||
embedding_model: The embedding model bundle (LLMBundle).
|
||||
parser_config: Parser configuration for filename embedding weight.
|
||||
|
||||
Returns:
|
||||
Tuple of (token_count, vector_size).
|
||||
"""
|
||||
if parser_config is None:
|
||||
parser_config = {}
|
||||
|
||||
# Prepare text for embedding using EmbeddingUtils
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
|
||||
# Encode titles using EmbeddingUtils for truncation
|
||||
tk_count = 0
|
||||
if len(titles) > 0 and len(titles) == len(contents):
|
||||
vts, c = self._encode_single([titles[0]], embedding_model)
|
||||
tts = np.tile(vts[0], (len(contents), 1))
|
||||
tk_count += c
|
||||
else:
|
||||
tts = None
|
||||
|
||||
# Batch encode contents using EmbeddingUtils
|
||||
vects_batches = []
|
||||
for i in range(0, len(contents), self._embedding_batch_size):
|
||||
batch = contents[i: i + self._embedding_batch_size]
|
||||
vts, c = self._encode_batch(batch, embedding_model)
|
||||
vects_batches.append(vts)
|
||||
tk_count += c
|
||||
if self._task_context.progress_cb:
|
||||
self._task_context.progress_cb(prog=0.7 + 0.2 * (i + 1) / len(contents), msg="")
|
||||
|
||||
# Stack vectors using EmbeddingUtils
|
||||
cnts = EmbeddingUtils.stack_vectors(vects_batches)
|
||||
|
||||
# Combine title and content vectors using EmbeddingUtils
|
||||
title_weight = parser_config.get("filename_embd_weight", EmbeddingUtils.DEFAULT_TITLE_WEIGHT)
|
||||
vects = EmbeddingUtils.combine_title_content_vectors(tts, cnts, title_weight)
|
||||
|
||||
assert len(vects) == len(docs)
|
||||
|
||||
# Attach vectors to docs using EmbeddingUtils
|
||||
vector_size = EmbeddingUtils.attach_vectors(docs, vects)
|
||||
|
||||
return tk_count, vector_size
|
||||
|
||||
def _encode_single(self, texts: List[str], model) -> Tuple[np.ndarray, int]:
|
||||
"""Encode a single batch of texts."""
|
||||
return self._run_encode(texts, model)
|
||||
|
||||
def _encode_batch(self, texts: List[str], model) -> Tuple[np.ndarray, int]:
|
||||
"""Encode a batch of texts with rate limiting and truncation."""
|
||||
# Use EmbeddingUtils for truncation
|
||||
truncated = EmbeddingUtils.truncate_texts(texts, model.max_length)
|
||||
return self._run_encode(truncated, model)
|
||||
|
||||
def _run_encode(self, texts: List[str], model) -> Tuple[np.ndarray, int]:
|
||||
"""Run encoding with rate limiting."""
|
||||
async def _encode():
|
||||
async with self._task_context.embed_limiter:
|
||||
return model.encode(texts)
|
||||
return asyncio.get_event_loop().run_until_complete(_encode())
|
||||
@@ -0,0 +1,223 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Embedding Utils Module.
|
||||
|
||||
Provides utility functions for vector embedding operations to avoid code duplication
|
||||
across different services (e.g., [`EmbeddingService`](rag/svr/task_executor_refactor/embedding_service.py),
|
||||
[`DataflowService`](rag/svr/task_executor_refactor/dataflow_service.py)).
|
||||
|
||||
This module centralizes:
|
||||
- Batch encoding of texts with truncation
|
||||
- Vector stacking from multiple batches
|
||||
- Vector attachment to chunk dictionaries
|
||||
- Title and content vector combination with configurable weights
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common.token_utils import truncate
|
||||
|
||||
|
||||
class EmbeddingUtils:
|
||||
"""Utility class for common embedding operations.
|
||||
|
||||
This class provides static methods for:
|
||||
- Preparing texts for embedding (title/content extraction, HTML normalization)
|
||||
- Batch encoding with truncation
|
||||
- Stacking vector batches
|
||||
- Attaching vectors to chunk dictionaries
|
||||
- Combining title and content vectors with weights
|
||||
"""
|
||||
|
||||
DEFAULT_TITLE_WEIGHT = 0.1
|
||||
DEFAULT_TITLE_PLACEHOLDER = "Title"
|
||||
CONTENT_PLACEHOLDER_FOR_WHITESPACE = "None"
|
||||
|
||||
@classmethod
|
||||
def prepare_texts_for_embedding(
|
||||
cls,
|
||||
docs: List[Dict[str, Any]],
|
||||
use_question_kwd: bool = True,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Prepare title and content texts for embedding.
|
||||
|
||||
Extracts titles from 'docnm_kwd' field and contents from 'question_kwd'
|
||||
(if available and use_question_kwd is True) or 'content_with_weight'.
|
||||
Table HTML tags are normalized to spaces.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries.
|
||||
use_question_kwd: Whether to use 'question_kwd' as content if available.
|
||||
|
||||
Returns:
|
||||
Tuple of (titles, contents) lists.
|
||||
"""
|
||||
titles = []
|
||||
contents = []
|
||||
for d in docs:
|
||||
title = d.get("docnm_kwd", cls.DEFAULT_TITLE_PLACEHOLDER)
|
||||
titles.append(title)
|
||||
|
||||
content = cls._extract_content(d, use_question_kwd=use_question_kwd)
|
||||
content = cls._normalize_table_html(content)
|
||||
content = cls._handle_whitespace(content)
|
||||
|
||||
contents.append(content)
|
||||
return titles, contents
|
||||
|
||||
@classmethod
|
||||
def prepare_texts_for_dataflow_embedding(
|
||||
cls,
|
||||
chunks: List[Dict[str, Any]],
|
||||
) -> List[str]:
|
||||
"""Prepare texts for dataflow embedding.
|
||||
|
||||
Extracts content from 'questions', 'summary', or 'text' fields
|
||||
(in priority order).
|
||||
|
||||
Args:
|
||||
chunks: List of chunk dictionaries from dataflow output.
|
||||
|
||||
Returns:
|
||||
List of text strings for embedding.
|
||||
"""
|
||||
texts = []
|
||||
for chunk in chunks:
|
||||
text = chunk.get("questions", chunk.get("summary", chunk.get("text", "")))
|
||||
texts.append(text)
|
||||
return texts
|
||||
|
||||
@classmethod
|
||||
def truncate_texts(cls, texts: List[str], max_length: int) -> List[str]:
|
||||
"""Truncate texts to the specified maximum length.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to truncate.
|
||||
max_length: Maximum length for each text (will subtract 10 for safety margin).
|
||||
|
||||
Returns:
|
||||
List of truncated text strings.
|
||||
"""
|
||||
safe_max_length = max_length - 10
|
||||
return [truncate(text, safe_max_length) for text in texts]
|
||||
|
||||
@classmethod
|
||||
def stack_vectors(cls, vects_batches: List[np.ndarray]) -> np.ndarray:
|
||||
"""Stack a list of vector batches into a single array.
|
||||
|
||||
Args:
|
||||
vects_batches: List of numpy arrays from batch encoding.
|
||||
|
||||
Returns:
|
||||
Stacked numpy array, or empty array if no batches provided.
|
||||
"""
|
||||
return np.vstack(vects_batches) if vects_batches else np.array([])
|
||||
|
||||
@classmethod
|
||||
def attach_vectors(
|
||||
cls,
|
||||
docs: List[Dict[str, Any]],
|
||||
vectors: np.ndarray,
|
||||
vector_key_template: str = "q_%d_vec",
|
||||
) -> int:
|
||||
"""Attach vectors to chunk dictionaries.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to modify in-place.
|
||||
vectors: Numpy array of vectors to attach.
|
||||
vector_key_template: Format string for the vector key (default: "q_%d_vec").
|
||||
|
||||
Returns:
|
||||
The size of each vector (assumes uniform size).
|
||||
"""
|
||||
vector_size = 0
|
||||
if len(vectors) != len(docs):
|
||||
raise ValueError(f"vectors/docs length mismatch: {len(vectors)} != {len(docs)}")
|
||||
for i, doc in enumerate(docs):
|
||||
vector = vectors[i].tolist()
|
||||
vector_size = len(vector)
|
||||
key = vector_key_template % vector_size
|
||||
doc[key] = vector
|
||||
return vector_size
|
||||
|
||||
@classmethod
|
||||
def combine_title_content_vectors(
|
||||
cls,
|
||||
title_vecs: Optional[np.ndarray],
|
||||
content_vecs: np.ndarray,
|
||||
title_weight: Optional[float] = None,
|
||||
) -> np.ndarray:
|
||||
"""Combine title and content vectors with a configurable weight.
|
||||
|
||||
Args:
|
||||
title_vecs: Title embedding vectors (may be None).
|
||||
content_vecs: Content embedding vectors.
|
||||
title_weight: Weight for title vectors (0.0 to 1.0). Defaults to 0.1.
|
||||
|
||||
Returns:
|
||||
Combined vector array. If title_vecs is None or shapes don't match,
|
||||
returns content_vecs unchanged.
|
||||
"""
|
||||
if title_weight is None:
|
||||
title_weight = cls.DEFAULT_TITLE_WEIGHT
|
||||
if not title_weight:
|
||||
title_weight = cls.DEFAULT_TITLE_WEIGHT
|
||||
|
||||
if (
|
||||
title_vecs is not None
|
||||
and content_vecs.ndim == 2
|
||||
and title_vecs.shape == content_vecs.shape
|
||||
):
|
||||
return title_weight * title_vecs + (1 - title_weight) * content_vecs
|
||||
return content_vecs
|
||||
|
||||
@classmethod
|
||||
def _extract_content(
|
||||
cls,
|
||||
doc: Dict[str, Any],
|
||||
use_question_kwd: bool = True,
|
||||
) -> str:
|
||||
"""Extract content from a chunk dictionary.
|
||||
|
||||
Priority: question_kwd (joined by newline) -> content_with_weight.
|
||||
"""
|
||||
if use_question_kwd:
|
||||
question_kwd = doc.get("question_kwd", [])
|
||||
if question_kwd:
|
||||
return "\n".join(question_kwd)
|
||||
return doc.get("content_with_weight", "")
|
||||
|
||||
@classmethod
|
||||
def _normalize_table_html(cls, text: str) -> str:
|
||||
"""Normalize table HTML tags to spaces.
|
||||
|
||||
Replaces table-related HTML tags (table, td, caption, tr, th) with spaces.
|
||||
"""
|
||||
return re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", text)
|
||||
|
||||
@classmethod
|
||||
def _handle_whitespace(cls, text: str) -> str:
|
||||
"""Replace whitespace-only content with a placeholder.
|
||||
|
||||
Prevents embedding models from receiving empty or meaningless input.
|
||||
"""
|
||||
if not text.strip():
|
||||
return cls.CONTENT_PLACEHOLDER_FOR_WHITESPACE
|
||||
return text
|
||||
@@ -0,0 +1,156 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Post Processor Module.
|
||||
|
||||
Provides [`PostProcessor`](rag/svr/task_executor_refactor/post_processor.py:42) for post-indexing
|
||||
operations like table parser metadata aggregation and TOC insertion.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from common.metadata_utils import update_metadata_to
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
from rag.utils.table_es_metadata import (
|
||||
aggregate_table_manual_doc_metadata,
|
||||
merge_table_parser_config_from_kb,
|
||||
table_parser_strip_doc_metadata_keys,
|
||||
)
|
||||
|
||||
class PostProcessor:
|
||||
"""Service for post-indexing operations.
|
||||
|
||||
This service handles:
|
||||
- Table parser metadata aggregation
|
||||
- Document metadata updates
|
||||
- TOC (Table of Contents) chunk insertion
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
):
|
||||
"""Initialize PostProcessor.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
|
||||
async def process_table_parser_metadata(
|
||||
self,
|
||||
task_doc_id: str,
|
||||
chunks: List[Dict],
|
||||
) -> None:
|
||||
"""Process table parser metadata aggregation.
|
||||
|
||||
Args:
|
||||
task_doc_id: Document ID.
|
||||
chunks: List of chunk dictionaries.
|
||||
"""
|
||||
ctx = self._task_context
|
||||
if ctx.parser_id.lower() != "table":
|
||||
return
|
||||
|
||||
eff_pc = merge_table_parser_config_from_kb(ctx.raw_task)
|
||||
logging.debug(
|
||||
f"[TABLE_META_DEBUG] table post-index: table_column_mode={eff_pc.get('table_column_mode')!r}"
|
||||
)
|
||||
|
||||
if eff_pc.get("table_column_mode") != "manual":
|
||||
return
|
||||
|
||||
try:
|
||||
agg = aggregate_table_manual_doc_metadata(chunks, ctx.raw_task)
|
||||
logging.debug(f"[TABLE_META_DEBUG] aggregated metadata: {agg}")
|
||||
|
||||
strip_keys = table_parser_strip_doc_metadata_keys(eff_pc)
|
||||
existing = DocMetadataService.get_document_metadata(task_doc_id)
|
||||
existing = existing if isinstance(existing, dict) else {}
|
||||
|
||||
preserved = {k: v for k, v in existing.items() if k not in strip_keys}
|
||||
merged = update_metadata_to(dict(preserved), agg)
|
||||
|
||||
logging.debug(
|
||||
f"[TABLE_META_DEBUG] calling update_document_metadata for doc_id={task_doc_id}, "
|
||||
f"meta_fields keys={list(merged.keys())}, "
|
||||
f"table_strip_key_count={len(strip_keys)}, agg_keys={list(agg.keys())}"
|
||||
)
|
||||
|
||||
try:
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
DocMetadataService.update_document_metadata(task_doc_id, merged)
|
||||
logging.debug("[TABLE_META_DEBUG] update_document_metadata succeeded")
|
||||
except Exception as ue:
|
||||
logging.error(
|
||||
"update_document_metadata failed (table parser, doc_id=%s): %s",
|
||||
task_doc_id,
|
||||
ue,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.exception(
|
||||
"Table parser document metadata aggregation failed (doc_id=%s): %s",
|
||||
task_doc_id,
|
||||
e,
|
||||
)
|
||||
|
||||
async def insert_toc_chunk(
|
||||
self,
|
||||
toc_chunk: Optional[Dict],
|
||||
chunk_service,
|
||||
) -> bool:
|
||||
"""Insert TOC chunk into document store.
|
||||
|
||||
Args:
|
||||
toc_chunk: TOC chunk dictionary or None.
|
||||
chunk_service: ChunkService instance for chunk insertion.
|
||||
|
||||
Returns:
|
||||
True if TOC chunk was inserted successfully, False otherwise.
|
||||
"""
|
||||
ctx = self._task_context
|
||||
if toc_chunk is None:
|
||||
return False
|
||||
|
||||
if self._task_context.has_canceled_func(ctx.id):
|
||||
self._task_context.progress_cb(-1, msg="Task has been canceled.")
|
||||
return False
|
||||
|
||||
insert_result = await chunk_service.insert_chunks(ctx.id, ctx.tenant_id, ctx.kb_id, [toc_chunk])
|
||||
|
||||
if not insert_result:
|
||||
self._task_context.recording_context.record("toc_inserted", False)
|
||||
return False
|
||||
|
||||
self._task_context.recording_context.record("toc_inserted", True)
|
||||
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(ctx.doc_id, ctx.kb_id, 0, 1, 0)
|
||||
|
||||
return True
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,468 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Raptor Service Module.
|
||||
|
||||
Provides [`RaptorService`](rag/svr/task_executor_refactor/raptor_service.py:48) for RAPTOR
|
||||
(Recursive Abstractive Processing for Tree-Organized Retrieval) summary generation.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.task_service import GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
from common import settings
|
||||
from common.constants import PAGERANK_FLD
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from common.token_utils import num_tokens_from_string
|
||||
from rag.nlp import rag_tokenizer, search
|
||||
from rag.utils.raptor_utils import (
|
||||
collect_raptor_chunk_ids,
|
||||
collect_raptor_methods,
|
||||
get_raptor_clustering_method,
|
||||
get_raptor_tree_builder,
|
||||
get_skip_reason,
|
||||
make_raptor_summary_chunk_id,
|
||||
should_skip_raptor,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
|
||||
class RaptorService:
|
||||
"""Service for RAPTOR summary generation.
|
||||
|
||||
This service handles:
|
||||
- RAPTOR chunk method detection (checkpoint)
|
||||
- RAPTOR summary generation per document or dataset-level
|
||||
- Stale RAPTOR chunk cleanup
|
||||
- Auto-disable rules for certain file types
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
):
|
||||
"""Initialize RaptorService.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
|
||||
async def run_raptor_for_kb(
|
||||
self,
|
||||
kb_parser_config: Dict,
|
||||
chat_mdl,
|
||||
embd_mdl,
|
||||
vector_size: int,
|
||||
doc_ids: List[str],
|
||||
) -> Tuple[List[Dict], int, List[Tuple[str, Optional[str]]]]:
|
||||
"""Generate RAPTOR summaries for selected documents.
|
||||
|
||||
Args:
|
||||
kb_parser_config: Knowledge base parser configuration.
|
||||
chat_mdl: Chat model bundle for RAPTOR.
|
||||
embd_mdl: Embedding model bundle for RAPTOR.
|
||||
vector_size: Vector dimension size.
|
||||
doc_ids: List of document IDs to process.
|
||||
|
||||
Returns:
|
||||
Tuple of (chunks, token_count, cleanup_raptor_chunks).
|
||||
"""
|
||||
raptor_config = kb_parser_config.get("raptor", {})
|
||||
tree_builder = get_raptor_tree_builder(raptor_config)
|
||||
clustering_method = get_raptor_clustering_method(raptor_config)
|
||||
vctr_nm = "q_%d_vec" % vector_size
|
||||
|
||||
res = []
|
||||
tk_count = 0
|
||||
cleanup_raptor_chunks = []
|
||||
max_errors = int(os.environ.get("RAPTOR_MAX_ERRORS", 3))
|
||||
|
||||
# Collect document info
|
||||
doc_info_by_id = self._collect_doc_info(doc_ids)
|
||||
|
||||
# Determine scope
|
||||
if raptor_config.get("scope", "file") == "file":
|
||||
res, tk_count = await self._run_file_level_raptor(
|
||||
raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
)
|
||||
else:
|
||||
res, tk_count = await self._run_dataset_level_raptor(
|
||||
raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
)
|
||||
|
||||
return res, tk_count, cleanup_raptor_chunks
|
||||
|
||||
@classmethod
|
||||
def _collect_doc_info(cls, doc_ids: List[str]) -> Dict[str, Dict]:
|
||||
"""Collect document info for all doc_ids."""
|
||||
doc_info_by_id = {}
|
||||
for doc_id in set(doc_ids):
|
||||
ok, source_doc = DocumentService.get_by_id(doc_id)
|
||||
if not ok or not source_doc:
|
||||
continue
|
||||
doc_info_by_id[doc_id] = {
|
||||
"name": getattr(source_doc, "name", ""),
|
||||
"type": getattr(source_doc, "type", ""),
|
||||
"parser_id": getattr(source_doc, "parser_id", ""),
|
||||
"parser_config": getattr(source_doc, "parser_config", {}) or {},
|
||||
}
|
||||
return doc_info_by_id
|
||||
|
||||
async def _run_file_level_raptor(
|
||||
self, raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
):
|
||||
"""Run RAPTOR at file level (per document)."""
|
||||
ctx = self._task_context
|
||||
fake_doc_id = GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
if self._task_context.write_interceptor: # dry run mode
|
||||
dataset_methods = set()
|
||||
else:
|
||||
dataset_methods = await self._get_raptor_chunk_methods(fake_doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
remove_dataset_summaries = bool(dataset_methods)
|
||||
has_file_level_target = False
|
||||
|
||||
if dataset_methods:
|
||||
self._task_context.progress_cb(msg="[RAPTOR] will remove dataset-level summaries after file-level summaries are available.")
|
||||
|
||||
for x, doc_id in enumerate(doc_ids):
|
||||
if self._should_skip_raptor(doc_id, doc_info_by_id, raptor_config):
|
||||
self._task_context.progress_cb(prog=(x + 1.) / len(doc_ids))
|
||||
continue
|
||||
if self._task_context.write_interceptor:
|
||||
existing_methods = set()
|
||||
else:
|
||||
existing_methods = await self._get_raptor_chunk_methods(doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
if tree_builder in existing_methods:
|
||||
has_file_level_target = True
|
||||
if existing_methods != {tree_builder}:
|
||||
self._schedule_raptor_cleanup(
|
||||
doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} will remove old RAPTOR summaries after insert.")
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} already has {tree_builder} RAPTOR chunks, skipping.")
|
||||
self._task_context.progress_cb(prog=(x + 1.) / len(doc_ids))
|
||||
continue
|
||||
|
||||
if existing_methods:
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} will migrate RAPTOR summaries to {tree_builder} after insert.")
|
||||
|
||||
chunks = self._load_doc_chunks(doc_id, vctr_nm)
|
||||
if not chunks:
|
||||
continue
|
||||
|
||||
before_generate = len(res)
|
||||
new_chunks, new_tk_count = await self._generate_raptor(
|
||||
chunks, doc_id, raptor_config, chat_mdl, embd_mdl,
|
||||
tree_builder, clustering_method, max_errors, doc_info_by_id
|
||||
)
|
||||
res.extend(new_chunks)
|
||||
tk_count += new_tk_count
|
||||
|
||||
if len(res) > before_generate:
|
||||
has_file_level_target = True
|
||||
if existing_methods:
|
||||
self._schedule_raptor_cleanup(
|
||||
doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
self._task_context.progress_cb(prog=(x + 1.) / len(doc_ids))
|
||||
|
||||
if remove_dataset_summaries:
|
||||
if has_file_level_target:
|
||||
self._schedule_raptor_cleanup(
|
||||
fake_doc_id, None, cleanup_raptor_chunks
|
||||
)
|
||||
else:
|
||||
self._task_context.progress_cb(msg="[RAPTOR] kept dataset-level summaries because no file-level summaries were built.")
|
||||
|
||||
return res, tk_count
|
||||
|
||||
async def _run_dataset_level_raptor(
|
||||
self, raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
):
|
||||
"""Run RAPTOR at dataset level (all documents combined)."""
|
||||
ctx = self._task_context
|
||||
fake_doc_id = GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
migrated_file_docs = 0
|
||||
file_cleanup_doc_ids = []
|
||||
skipped_doc_ids = set()
|
||||
|
||||
for doc_id in set(doc_ids):
|
||||
if self._should_skip_raptor(doc_id, doc_info_by_id, raptor_config):
|
||||
skipped_doc_ids.add(doc_id)
|
||||
continue
|
||||
if self._task_context.write_interceptor:
|
||||
existing_methods = set()
|
||||
else:
|
||||
existing_methods = await self._get_raptor_chunk_methods(doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
if existing_methods:
|
||||
file_cleanup_doc_ids.append(doc_id)
|
||||
migrated_file_docs += 1
|
||||
|
||||
if migrated_file_docs:
|
||||
self._task_context.progress_cb(
|
||||
msg=f"[RAPTOR] will remove file-level summaries for {migrated_file_docs} docs after dataset-level build succeeds."
|
||||
)
|
||||
|
||||
if self._task_context.write_interceptor:
|
||||
existing_methods = set()
|
||||
else:
|
||||
existing_methods = await self._get_raptor_chunk_methods(fake_doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
if tree_builder in existing_methods:
|
||||
if existing_methods != {tree_builder}:
|
||||
self._schedule_raptor_cleanup(
|
||||
fake_doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
self._task_context.progress_cb(msg="[RAPTOR] will remove old dataset-level RAPTOR summaries after insert.")
|
||||
for doc_id in file_cleanup_doc_ids:
|
||||
self._schedule_raptor_cleanup(doc_id, None, cleanup_raptor_chunks)
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] dataset-level {tree_builder} summaries already exist, skipping.")
|
||||
return res, tk_count
|
||||
|
||||
migrate_dataset_summaries = bool(existing_methods)
|
||||
if migrate_dataset_summaries:
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] will migrate dataset-level RAPTOR summaries to {tree_builder} after insert.")
|
||||
|
||||
chunks = self._load_all_doc_chunks(doc_ids, vctr_nm, skipped_doc_ids)
|
||||
if not chunks:
|
||||
if skipped_doc_ids and len(skipped_doc_ids) == len(set(doc_ids)):
|
||||
self._task_context.progress_cb(msg="[RAPTOR] all documents were skipped by RAPTOR auto-disable rules.")
|
||||
return res, tk_count
|
||||
self._task_context.progress_cb(msg="[ERROR] No valid chunks with vectors found. Please ensure documents are parsed with the current embedding model.")
|
||||
return res, tk_count
|
||||
|
||||
before_generate = len(res)
|
||||
new_chunks, new_tk_count = await self._generate_raptor(
|
||||
chunks, fake_doc_id, raptor_config, chat_mdl, embd_mdl,
|
||||
tree_builder, clustering_method, max_errors, doc_info_by_id
|
||||
)
|
||||
res.extend(new_chunks)
|
||||
tk_count += new_tk_count
|
||||
|
||||
if len(res) > before_generate:
|
||||
for doc_id in file_cleanup_doc_ids:
|
||||
self._schedule_raptor_cleanup(doc_id, None, cleanup_raptor_chunks)
|
||||
if migrate_dataset_summaries:
|
||||
self._schedule_raptor_cleanup(
|
||||
fake_doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
|
||||
return res, tk_count
|
||||
|
||||
def _should_skip_raptor(
|
||||
self, doc_id: str, doc_info_by_id: Dict, raptor_config: Dict
|
||||
) -> bool:
|
||||
"""Check if RAPTOR should be skipped for a document."""
|
||||
ctx = self._task_context
|
||||
doc_info = doc_info_by_id.get(doc_id, {})
|
||||
file_type = doc_info.get("type") or ctx.raw_task.get("type", "")
|
||||
parser_id = doc_info.get("parser_id") or ctx.parser_id
|
||||
parser_config = doc_info.get("parser_config") or ctx.parser_config
|
||||
|
||||
if should_skip_raptor(file_type, parser_id, parser_config, raptor_config):
|
||||
skip_reason = get_skip_reason(file_type, parser_id, parser_config)
|
||||
doc_name = doc_info.get("name") or doc_id
|
||||
logging.info("Skipping Raptor for document %s: %s", doc_name, skip_reason)
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} skipped: {skip_reason}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_doc_chunks(self, doc_id: str, vctr_nm: str) -> List[Tuple[str, np.ndarray]]:
|
||||
"""Load chunks for a single document."""
|
||||
ctx = self._task_context
|
||||
chunks = []
|
||||
skipped_chunks = 0
|
||||
|
||||
fields = ["content_with_weight", vctr_nm]
|
||||
for d in settings.retriever.chunk_list(
|
||||
doc_id, ctx.tenant_id, [str(ctx.kb_id)],
|
||||
fields=fields,
|
||||
sort_by_position=True
|
||||
):
|
||||
if vctr_nm not in d or d[vctr_nm] is None:
|
||||
skipped_chunks += 1
|
||||
logging.warning(f"RAPTOR: Chunk missing vector field '{vctr_nm}' in doc {doc_id}, skipping")
|
||||
continue
|
||||
chunks.append((d["content_with_weight"], np.array(d[vctr_nm])))
|
||||
|
||||
if skipped_chunks > 0:
|
||||
self._task_context.progress_cb(
|
||||
msg=f"[WARN] Skipped {skipped_chunks} chunks without vector field '{vctr_nm}' for doc {doc_id}."
|
||||
)
|
||||
if not chunks:
|
||||
logging.warning(f"RAPTOR: No valid chunks with vectors found for doc {doc_id}")
|
||||
self._task_context.progress_cb(msg=f"[WARN] No valid chunks with vectors found for doc {doc_id}, skipping")
|
||||
|
||||
return chunks
|
||||
|
||||
def _load_all_doc_chunks(
|
||||
self, doc_ids: List[str], vctr_nm: str, skipped_doc_ids: Set[str]
|
||||
) -> List[Tuple[str, np.ndarray]]:
|
||||
"""Load chunks for all documents."""
|
||||
ctx = self._task_context
|
||||
chunks = []
|
||||
skipped_chunks = 0
|
||||
|
||||
fields = ["content_with_weight", vctr_nm]
|
||||
for doc_id in doc_ids:
|
||||
if doc_id in skipped_doc_ids:
|
||||
continue
|
||||
for d in settings.retriever.chunk_list(
|
||||
doc_id, ctx.tenant_id, [str(ctx.kb_id)],
|
||||
fields=fields,
|
||||
sort_by_position=True
|
||||
):
|
||||
if vctr_nm not in d or d[vctr_nm] is None:
|
||||
skipped_chunks += 1
|
||||
logging.warning(f"RAPTOR: Chunk missing vector field '{vctr_nm}' in doc {doc_id}, skipping")
|
||||
continue
|
||||
chunks.append((d["content_with_weight"], np.array(d[vctr_nm])))
|
||||
|
||||
if skipped_chunks > 0:
|
||||
self._task_context.progress_cb(
|
||||
msg=f"[WARN] Skipped {skipped_chunks} chunks without vector field '{vctr_nm}'."
|
||||
)
|
||||
|
||||
return chunks
|
||||
|
||||
async def _generate_raptor(
|
||||
self,
|
||||
chunks: List[Tuple[str, np.ndarray]],
|
||||
doc_id: str,
|
||||
raptor_config: Dict,
|
||||
chat_mdl,
|
||||
embd_mdl,
|
||||
tree_builder: str,
|
||||
clustering_method: str,
|
||||
max_errors: int,
|
||||
doc_info_by_id: Dict,
|
||||
) -> Tuple[List[Dict], int]:
|
||||
"""Run RAPTOR and generate summary chunks."""
|
||||
ctx = self._task_context
|
||||
from rag.raptor import RecursiveAbstractiveProcessing4TreeOrganizedRetrieval as Raptor
|
||||
|
||||
raptor_ext_config = raptor_config.get("ext") or {}
|
||||
vctr_nm = "q_%d_vec" % len(chunks[0][1]) if chunks else "q_768_vec"
|
||||
|
||||
raptor = Raptor(
|
||||
raptor_config.get("max_cluster", 64),
|
||||
chat_mdl,
|
||||
embd_mdl,
|
||||
raptor_config["prompt"],
|
||||
raptor_config["max_token"],
|
||||
raptor_config["threshold"],
|
||||
max_errors=max_errors,
|
||||
tree_builder=tree_builder,
|
||||
clustering_method=clustering_method,
|
||||
psi_exact_max_leaves=raptor_ext_config.get("psi_exact_max_leaves", 4096),
|
||||
psi_bucket_size=raptor_ext_config.get("psi_bucket_size", 1024),
|
||||
)
|
||||
|
||||
original_length = len(chunks)
|
||||
processed_chunks, layers = await raptor(
|
||||
chunks, raptor_config["random_seed"], self._task_context.progress_cb, ctx.id
|
||||
)
|
||||
|
||||
effective_doc_name = ctx.name if doc_id == GRAPH_RAPTOR_FAKE_DOC_ID else doc_info_by_id.get(doc_id, {}).get("name") or ctx.name
|
||||
|
||||
doc = {
|
||||
"doc_id": doc_id,
|
||||
"kb_id": [str(ctx.kb_id)],
|
||||
"docnm_kwd": effective_doc_name,
|
||||
"title_tks": rag_tokenizer.tokenize(effective_doc_name),
|
||||
"raptor_kwd": "raptor",
|
||||
"extra": {"raptor_method": tree_builder},
|
||||
}
|
||||
if ctx.pagerank:
|
||||
doc[PAGERANK_FLD] = int(ctx.pagerank)
|
||||
|
||||
# Build index→layer mapping
|
||||
chunk_layer = {}
|
||||
for layer_idx, (layer_start, layer_end) in enumerate(layers):
|
||||
if layer_idx == 0:
|
||||
continue
|
||||
for ci in range(layer_start, layer_end):
|
||||
chunk_layer[ci] = layer_idx
|
||||
|
||||
res = []
|
||||
tk_count = 0
|
||||
for idx, (content, vctr) in enumerate(processed_chunks[original_length:], start=original_length):
|
||||
d = copy.deepcopy(doc)
|
||||
d["id"] = make_raptor_summary_chunk_id(content, doc_id)
|
||||
d["create_time"] = str(datetime.now()).replace("T", " ")[:19]
|
||||
d["create_timestamp_flt"] = datetime.now().timestamp()
|
||||
d[vctr_nm] = vctr.tolist()
|
||||
d["content_with_weight"] = content
|
||||
d["content_ltks"] = rag_tokenizer.tokenize(content)
|
||||
d["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(d["content_ltks"])
|
||||
d["raptor_layer_int"] = chunk_layer.get(idx, 1)
|
||||
res.append(d)
|
||||
tk_count += num_tokens_from_string(content)
|
||||
|
||||
return res, tk_count
|
||||
|
||||
@classmethod
|
||||
def _schedule_raptor_cleanup(cls, doc_id: str, keep_method: Optional[str], cleanup_list: List):
|
||||
"""Queue stale RAPTOR summaries for deletion."""
|
||||
cleanup_plan = (doc_id, keep_method)
|
||||
if cleanup_plan not in cleanup_list:
|
||||
cleanup_list.append(cleanup_plan)
|
||||
|
||||
@classmethod
|
||||
async def _get_raptor_chunk_methods(cls, doc_id: str, tenant_id: str, kb_id: str) -> Set[str]:
|
||||
"""Get RAPTOR chunk methods for a document."""
|
||||
from common.doc_store.doc_store_base import OrderByExpr
|
||||
|
||||
async def search_fields(fields: list, condition: dict, order_by=None):
|
||||
res = await thread_pool_exec(
|
||||
settings.docStoreConn.search,
|
||||
fields, [], condition, [], order_by or OrderByExpr(),
|
||||
0, 10000, search.index_name(tenant_id), [kb_id]
|
||||
)
|
||||
return settings.docStoreConn.get_fields(res, fields)
|
||||
|
||||
try:
|
||||
primary = await search_fields(
|
||||
["raptor_kwd", "extra"], {"doc_id": doc_id, "raptor_kwd": ["raptor"]}
|
||||
)
|
||||
if collect_raptor_chunk_ids(primary):
|
||||
return collect_raptor_methods(primary)
|
||||
|
||||
return collect_raptor_methods(
|
||||
await search_fields(
|
||||
["raptor_kwd", "extra"],
|
||||
{"doc_id": doc_id},
|
||||
OrderByExpr().desc("create_timestamp_flt"),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to check RAPTOR chunks for doc %s", doc_id)
|
||||
raise
|
||||
@@ -0,0 +1,97 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
RAPTOR chunk management utilities.
|
||||
|
||||
Provides functions for managing RAPTOR summary chunks,
|
||||
including detection, retrieval, and deletion.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from common import settings
|
||||
from rag.nlp import search as nlp_search
|
||||
from rag.utils.raptor_utils import (
|
||||
collect_raptor_chunk_ids,
|
||||
)
|
||||
|
||||
RAPTOR_METHOD_SEARCH_LIMIT = 10000
|
||||
|
||||
|
||||
async def get_raptor_chunk_field_map(doc_id: str, tenant_id: str, kb_id: str) -> dict:
|
||||
"""Return stored RAPTOR marker fields for a document."""
|
||||
from common.doc_store.doc_store_base import OrderByExpr
|
||||
|
||||
async def search_fields(fields: list[str], condition: dict, order_by=None):
|
||||
"""Search chunk fields in the current knowledge base."""
|
||||
res = await thread_pool_exec(
|
||||
settings.docStoreConn.search,
|
||||
fields, [], condition, [], order_by or OrderByExpr(),
|
||||
0, RAPTOR_METHOD_SEARCH_LIMIT, nlp_search.index_name(tenant_id), [kb_id]
|
||||
)
|
||||
return settings.docStoreConn.get_fields(res, fields)
|
||||
|
||||
primary = await search_fields(["raptor_kwd", "extra"], {"doc_id": doc_id, "raptor_kwd": ["raptor"]})
|
||||
if collect_raptor_chunk_ids(primary):
|
||||
return primary
|
||||
|
||||
try:
|
||||
return await search_fields(
|
||||
["raptor_kwd", "extra"],
|
||||
{"doc_id": doc_id},
|
||||
OrderByExpr().desc("create_timestamp_flt"),
|
||||
)
|
||||
except Exception:
|
||||
logging.debug("RAPTOR fallback method lookup with extra field failed for doc %s", doc_id, exc_info=True)
|
||||
return primary
|
||||
|
||||
|
||||
async def delete_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, keep_method: str | None = None) -> int:
|
||||
"""Delete RAPTOR summaries for doc_id, optionally preserving one method."""
|
||||
if keep_method is None:
|
||||
logging.info(
|
||||
"delete_raptor_chunks: removing all RAPTOR summaries (doc=%s tenant=%s kb=%s)",
|
||||
doc_id, tenant_id, kb_id,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": doc_id, "raptor_kwd": ["raptor"]},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
return 0
|
||||
|
||||
field_map = await get_raptor_chunk_field_map(doc_id, tenant_id, kb_id)
|
||||
chunk_ids = collect_raptor_chunk_ids(field_map, exclude_methods={keep_method})
|
||||
if not chunk_ids:
|
||||
logging.debug(
|
||||
"delete_raptor_chunks: no stale RAPTOR chunks to remove (doc=%s tenant=%s kb=%s keep=%s)",
|
||||
doc_id, tenant_id, kb_id, keep_method,
|
||||
)
|
||||
return 0
|
||||
|
||||
logging.info(
|
||||
"delete_raptor_chunks: removing %d stale RAPTOR chunks (doc=%s tenant=%s kb=%s keep=%s)",
|
||||
len(chunk_ids), doc_id, tenant_id, kb_id, keep_method,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"id": list(chunk_ids)},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
return len(chunk_ids)
|
||||
@@ -0,0 +1,419 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Recording Context Module.
|
||||
|
||||
This module provides the [`BaseRecordingContext`](rag/svr/task_executor_refactor/recording_context.py:48) abstract base class,
|
||||
[`RecordingContext`](rag/svr/task_executor_refactor/recording_context.py:89) concrete class, and
|
||||
[`NullRecordingContext`](rag/svr/task_executor_refactor/recording_context.py:204) no-op class, which capture
|
||||
actual execution results from the production code path (e.g., [`do_handle_task()`](rag/svr/task_executor.py))
|
||||
for later comparison with dry-run results.
|
||||
|
||||
The recording context is used throughout the task execution pipeline to collect
|
||||
intermediate metrics and final results at various stages:
|
||||
|
||||
1. **File validation**: Records file size check results and parser ID
|
||||
2. **Chunking**: Records raw chunks after document splitting
|
||||
3. **Outline extraction**: Records whether outline was extracted and entry count
|
||||
4. **MinIO upload**: Records document count after image upload
|
||||
5. **Post-processing**: Records counts for keywords, questions, metadata, and tags
|
||||
6. **Final results**: Records final chunks and their IDs for comparison
|
||||
|
||||
The module also provides context variable management functions and a timing
|
||||
decorator that automatically integrates with the current recording context.
|
||||
|
||||
Usage example::
|
||||
|
||||
from rag.svr.task_executor_refactor.recording_context import RecordingContext
|
||||
|
||||
ctx = RecordingContext()
|
||||
ctx.record("raw_chunk_count", 42)
|
||||
ctx.record("final_chunks", chunks)
|
||||
|
||||
# Later, in comparison:
|
||||
comparator.compare(task_id, ctx, dry_run_records)
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import functools
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Dict, List, Tuple
|
||||
|
||||
|
||||
class BaseRecordingContext(ABC):
|
||||
"""Abstract base class for recording context implementations.
|
||||
|
||||
Defines the common interface shared by
|
||||
[`RecordingContext`](rag/svr/task_executor_refactor/recording_context.py:89) and
|
||||
[`NullRecordingContext`](rag/svr/task_executor_refactor/recording_context.py:204).
|
||||
|
||||
Variables typed as ``BaseRecordingContext`` can hold either implementation,
|
||||
enabling production/dry-run polymorphism without conditional branches.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def record(self, key: str, value: Any) -> None:
|
||||
"""Record a value with the given key."""
|
||||
|
||||
@abstractmethod
|
||||
def save_func_return_value(self, func_name: str, return_value: Any) -> None:
|
||||
"""Record a function's return value into a list associated with func_name."""
|
||||
|
||||
@abstractmethod
|
||||
def get_func_return_values(self, func_name: str) -> List[Any]:
|
||||
"""Get the list of recorded return values for a function."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a recorded value by key."""
|
||||
|
||||
@abstractmethod
|
||||
def get_all_func_return_values(self) -> Dict[str, Any]:
|
||||
"""Get all recorded data."""
|
||||
|
||||
@abstractmethod
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if a key exists in recorded data."""
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Clear all recorded data."""
|
||||
|
||||
@abstractmethod
|
||||
def reset(self) -> None:
|
||||
"""Clear all recorded data and timing records."""
|
||||
|
||||
@abstractmethod
|
||||
@contextmanager
|
||||
def measure(self, name: str):
|
||||
"""Timing context manager to record execution duration."""
|
||||
|
||||
@abstractmethod
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation."""
|
||||
|
||||
|
||||
class RecordingContext(BaseRecordingContext):
|
||||
"""Captures actual execution results from production code for comparison.
|
||||
|
||||
This class acts as a dictionary-like container that stores key-value pairs
|
||||
representing various metrics and intermediate results collected during
|
||||
the production execution of a document processing task. It also supports
|
||||
timing measurements via the [`measure()`](rag/svr/task_executor_refactor/recording_context.py:78) context manager.
|
||||
|
||||
The recorded data is later consumed by the [`Comparator`](rag/svr/task_executor_refactor/comparator.py:130)
|
||||
to compare against dry-run execution results.
|
||||
|
||||
Example:
|
||||
>>> ctx = RecordingContext()
|
||||
>>> ctx.record("chunk_count", 100)
|
||||
>>> ctx.get("chunk_count")
|
||||
100
|
||||
>>> ctx.get("missing_key", "default")
|
||||
'default'
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new RecordingContext."""
|
||||
self._data: Dict[str, Any] = {}
|
||||
self.records: List[Tuple[str, float]] = []
|
||||
|
||||
def record(self, key: str, value: Any) -> None:
|
||||
"""Record a value with the given key.
|
||||
|
||||
This method stores the provided value under the specified key in the
|
||||
internal data dictionary. If the key already exists, the value will
|
||||
be overwritten.
|
||||
|
||||
Args:
|
||||
key: The key to store the value under. Should be a descriptive
|
||||
string that identifies the metric or result being recorded.
|
||||
value: The value to record. Can be any Python object, including
|
||||
primitives, lists, dicts, or complex objects.
|
||||
"""
|
||||
self._data[key] = value
|
||||
|
||||
def save_func_return_value(self, func_name: str, return_value: Any) -> None:
|
||||
"""Record a function's return value into a list associated with func_name.
|
||||
|
||||
Each func_name has a corresponding return_values_list. This method appends
|
||||
the return_value to the list for the given func_name. If the list does not
|
||||
exist, it will be created.
|
||||
|
||||
Args:
|
||||
func_name: The name of the function whose return value is being recorded.
|
||||
return_value: The return value to record.
|
||||
"""
|
||||
if func_name not in self._data:
|
||||
self._data[func_name] = []
|
||||
self._data[func_name].append(return_value)
|
||||
|
||||
def get_func_return_values(self, func_name: str) -> List[Any]:
|
||||
"""Get the list of recorded return values for a function.
|
||||
|
||||
Args:
|
||||
func_name: The name of the function.
|
||||
|
||||
Returns:
|
||||
A list of recorded return values, or an empty list if not found.
|
||||
"""
|
||||
return self._data.get(func_name, [])
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a recorded value by key.
|
||||
|
||||
Retrieves the value associated with the given key. If the key does
|
||||
not exist, returns the provided default value.
|
||||
|
||||
Args:
|
||||
key: The key to look up in the recorded data.
|
||||
default: Default value to return if the key is not found.
|
||||
Defaults to None.
|
||||
|
||||
Returns:
|
||||
The recorded value associated with the key, or the default value
|
||||
if the key does not exist.
|
||||
"""
|
||||
return self._data.get(key, default)
|
||||
|
||||
def get_all_func_return_values(self) -> Dict[str, Any]:
|
||||
"""Get all recorded data.
|
||||
|
||||
Returns a shallow copy of all recorded data as a dictionary.
|
||||
Modifications to the returned dictionary will not affect the
|
||||
internal state of this context.
|
||||
|
||||
Returns:
|
||||
A new dictionary containing all recorded key-value pairs.
|
||||
"""
|
||||
return dict(self._data)
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if a key exists in recorded data.
|
||||
|
||||
Args:
|
||||
key: The key to check for existence.
|
||||
|
||||
Returns:
|
||||
True if the key exists in the recorded data, False otherwise.
|
||||
"""
|
||||
return key in self._data
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all recorded data.
|
||||
|
||||
Removes all key-value pairs from the internal data dictionary
|
||||
and clears all timing records, resetting the context to its
|
||||
initial empty state.
|
||||
"""
|
||||
self._data.clear()
|
||||
self.records.clear()
|
||||
|
||||
@contextmanager
|
||||
def measure(self, name: str):
|
||||
"""Timing context manager to record execution duration.
|
||||
|
||||
Records the elapsed time (in seconds) for the operation specified
|
||||
by `name`.
|
||||
|
||||
Usage::
|
||||
|
||||
with ctx.measure("build_chunks"):
|
||||
...
|
||||
|
||||
Args:
|
||||
name: A descriptive name for the timed operation.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
self.records.append((name, elapsed))
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all recorded data and timing records."""
|
||||
self.clear()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the RecordingContext.
|
||||
|
||||
Returns:
|
||||
A string showing the class name and all recorded data.
|
||||
"""
|
||||
return f"RecordingContext({self._data})"
|
||||
|
||||
|
||||
class NullRecordingContext(BaseRecordingContext):
|
||||
"""No-op RecordingContext for production mode.
|
||||
|
||||
Accepts all RecordingContext API calls but performs no allocation.
|
||||
Eliminates memory overhead in production where recorded data is unused.
|
||||
|
||||
Uses __slots__ for zero instance memory footprint.
|
||||
|
||||
Usage:
|
||||
>>> ctx = NullRecordingContext()
|
||||
>>> ctx.record("chunks", large_list) # no-op, no memory allocated
|
||||
>>> ctx.get("chunks") # always returns None
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def record(self, key: str, value: Any) -> None:
|
||||
pass
|
||||
|
||||
def save_func_return_value(self, func_name: str, return_value: Any) -> None:
|
||||
pass
|
||||
|
||||
def get_func_return_values(self, func_name: str) -> List[Any]:
|
||||
return []
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return default
|
||||
|
||||
def get_all_func_return_values(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def measure(self, name: str):
|
||||
yield
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "NullRecordingContext()"
|
||||
|
||||
|
||||
# Module-level singleton to avoid repeated allocations
|
||||
_NULL_RECORDING_CONTEXT = NullRecordingContext()
|
||||
|
||||
|
||||
# Context variable for coroutine / thread isolation
|
||||
_recording_ctx_var: contextvars.ContextVar[BaseRecordingContext] = contextvars.ContextVar("recording_context")
|
||||
|
||||
|
||||
def get_recording_context() -> BaseRecordingContext:
|
||||
"""Get the BaseRecordingContext for the current execution context.
|
||||
|
||||
Returns the BaseRecordingContext bound to the current coroutine / thread.
|
||||
If no context has been bound, raise RuntimeError.
|
||||
|
||||
Returns:
|
||||
The current BaseRecordingContext, raise RuntimeError if not set.
|
||||
"""
|
||||
context = _recording_ctx_var.get(None)
|
||||
if context is None:
|
||||
raise RuntimeError("no context")
|
||||
return context
|
||||
|
||||
|
||||
def set_recording_context(ctx: BaseRecordingContext) -> None:
|
||||
"""Bind a BaseRecordingContext to the current execution context.
|
||||
|
||||
Args:
|
||||
ctx: The BaseRecordingContext to bind, or None to unbind.
|
||||
"""
|
||||
_recording_ctx_var.set(ctx)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def recording_context_manager(ctx: BaseRecordingContext = None):
|
||||
"""Context manager that sets and restores the BaseRecordingContext.
|
||||
|
||||
Usage::
|
||||
|
||||
with recording_context_manager(RecordingContext()) as ctx:
|
||||
ctx.record("key", "value")
|
||||
|
||||
Args:
|
||||
ctx: The BaseRecordingContext to use. If None, a new one is created.
|
||||
|
||||
Yields:
|
||||
The BaseRecordingContext that was set.
|
||||
"""
|
||||
if ctx is None:
|
||||
ctx = RecordingContext()
|
||||
token = _recording_ctx_var.set(ctx)
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
_recording_ctx_var.reset(token)
|
||||
|
||||
|
||||
def timed_with_recording(
|
||||
func: Callable = None,
|
||||
*,
|
||||
recording_context: BaseRecordingContext = None,
|
||||
) -> Callable:
|
||||
"""Decorator that automatically uses the current BaseRecordingContext for timing.
|
||||
|
||||
Supports two usage forms:
|
||||
|
||||
1. Direct decoration (automatically uses context variable):
|
||||
|
||||
@timed_with_recording
|
||||
def foo(): ...
|
||||
|
||||
2. Parameterized decoration with explicit BaseRecordingContext:
|
||||
|
||||
@timed_with_recording(recording_context=my_ctx)
|
||||
def foo(): ...
|
||||
|
||||
The decorator records the execution time of the decorated function
|
||||
into the BaseRecordingContext's timing records.
|
||||
|
||||
Args:
|
||||
func: The function to decorate (used when called without parentheses).
|
||||
recording_context: Optional BaseRecordingContext to use for timing.
|
||||
If not provided, uses the context variable's current value.
|
||||
|
||||
Returns:
|
||||
The decorated function.
|
||||
"""
|
||||
from common.decorator import timing
|
||||
|
||||
if func is not None and callable(func):
|
||||
# Used as @timed_with_recording without parentheses
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
ctx = recording_context or get_recording_context()
|
||||
if ctx is not None:
|
||||
return timing(context=ctx)(func)(*args, **kwargs)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Used as @timed_with_recording(...) with parentheses
|
||||
def decorator(the_func: Callable) -> Callable:
|
||||
@functools.wraps(the_func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
ctx = recording_context or get_recording_context()
|
||||
if ctx is not None:
|
||||
return timing(context=ctx)(the_func)(*args, **kwargs)
|
||||
return the_func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,140 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Report Generator Module.
|
||||
|
||||
Provides data classes for comparison result reporting:
|
||||
- [`ComparisonResult`](rag/svr/task_executor_refactor/report_generator.py:40): Single key comparison result
|
||||
- [`ComparisonReport`](rag/svr/task_executor_refactor/report_generator.py:66): Full comparison report with serialization
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComparisonResult:
|
||||
"""Result of comparing a single key between two contexts.
|
||||
|
||||
Attributes:
|
||||
key: The key being compared.
|
||||
match: Whether the values match.
|
||||
production_value: Value from production context.
|
||||
dry_run_value: Value from dry-run context.
|
||||
diff_details: Optional description of the difference.
|
||||
"""
|
||||
|
||||
key: str
|
||||
match: bool
|
||||
production_value: Any = None
|
||||
dry_run_value: Any = None
|
||||
diff_details: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"key": self.key,
|
||||
"match": self.match,
|
||||
"diff_details": self.diff_details,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComparisonReport:
|
||||
"""Report of comparing two RecordingContext instances.
|
||||
|
||||
Attributes:
|
||||
task_id: The task identifier.
|
||||
total_keys: Total number of keys compared.
|
||||
matched_keys: Number of keys that matched.
|
||||
mismatched_keys: Number of keys that mismatched.
|
||||
missing_in_production: Keys missing in production context.
|
||||
missing_in_dry_run: Keys missing in dry-run context.
|
||||
details: List of individual comparison results.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
total_keys: int = 0
|
||||
matched_keys: int = 0
|
||||
mismatched_keys: int = 0
|
||||
missing_in_production: List[str] = field(default_factory=list)
|
||||
missing_in_dry_run: List[str] = field(default_factory=list)
|
||||
details: List["ComparisonResult"] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Generate a summary string.
|
||||
|
||||
Returns:
|
||||
A human-readable summary of the comparison.
|
||||
"""
|
||||
if self.total_keys == 0:
|
||||
return f"Task {self.task_id}: No keys to compare"
|
||||
match_rate = (self.matched_keys / self.total_keys) * 100
|
||||
return (
|
||||
f"Task {self.task_id}: {self.matched_keys}/{self.total_keys} "
|
||||
f"keys matched ({match_rate:.1f}%)"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the report.
|
||||
"""
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"total_keys": self.total_keys,
|
||||
"matched_keys": self.matched_keys,
|
||||
"mismatched_keys": self.mismatched_keys,
|
||||
"missing_in_production": self.missing_in_production,
|
||||
"missing_in_dry_run": self.missing_in_dry_run,
|
||||
"details": [d.to_dict() for d in self.details],
|
||||
"summary": self.summary(),
|
||||
}
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Generate a mark-down report.
|
||||
|
||||
Returns:
|
||||
A markdown-formatted report string.
|
||||
"""
|
||||
lines = [
|
||||
f"# Comparison Report: {self.task_id}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- **Total keys**: {self.total_keys}",
|
||||
f"- **Matched**: {self.matched_keys}",
|
||||
f"- **Mismatched**: {self.mismatched_keys}",
|
||||
f"- **Missing in production**: {', '.join(self.missing_in_production) or 'None'}",
|
||||
f"- **Missing in dry-run**: {', '.join(self.missing_in_dry_run) or 'None'}",
|
||||
"",
|
||||
"## Details",
|
||||
"",
|
||||
]
|
||||
|
||||
if self.details:
|
||||
lines.append("| Key | Match | Details |")
|
||||
lines.append("|-----|-------|---------|")
|
||||
for d in self.details:
|
||||
match_str = "✅" if d.match else "❌"
|
||||
details_str = d.diff_details or "-"
|
||||
lines.append(f"| {d.key} | {match_str} | {details_str} |")
|
||||
else:
|
||||
lines.append("No comparison details available.")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,520 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Task Context Module.
|
||||
|
||||
Provides [`TaskContext`](rag/svr/task_executor_refactor/task_context.py) as a typed wrapper
|
||||
around the task dictionary, providing convenient property accessors for all
|
||||
commonly used task attributes throughout the task executor refactor codebase.
|
||||
|
||||
This module defines:
|
||||
- [`TaskDict`](rag/svr/task_executor_refactor/task_context.py): TypedDict for the raw task dictionary.
|
||||
- [`TaskLimiters`](rag/svr/task_executor_refactor/task_context.py): Dataclass encapsulating all rate limiters.
|
||||
- [`TaskCallbacks`](rag/svr/task_executor_refactor/task_context.py): Dataclass encapsulating all callback functions.
|
||||
- [`TaskContext`](rag/svr/task_executor_refactor/task_context.py): Main facade combining the above components.
|
||||
|
||||
Usage example::
|
||||
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext, TaskLimiters, TaskCallbacks
|
||||
|
||||
ctx = TaskContext(
|
||||
task=task_dict,
|
||||
limiters=TaskLimiters(
|
||||
chat=chat_limiter,
|
||||
minio=minio_limiter,
|
||||
chunk=chunk_limiter,
|
||||
embed=embed_limiter,
|
||||
kg=kg_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=progress_callback,
|
||||
has_canceled=has_canceled_func,
|
||||
),
|
||||
write_interceptor=write_interceptor,
|
||||
recording_context=recording_context,
|
||||
)
|
||||
|
||||
# Access task properties directly
|
||||
task_id = ctx.id
|
||||
tenant_id = ctx.tenant_id
|
||||
kb_id = ctx.kb_id
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, List, Optional, Required, TypedDict
|
||||
|
||||
from rag.svr.task_executor_refactor.recording_context import BaseRecordingContext
|
||||
from rag.svr.task_executor_refactor.write_operation_interceptor import WriteOperationInterceptor
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Type Definitions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TaskDict(TypedDict, total=False):
|
||||
"""TypedDict defining the structure of the raw task dictionary.
|
||||
|
||||
All fields are optional except 'id' and 'tenant_id' which are required.
|
||||
"""
|
||||
|
||||
id: Required[str]
|
||||
"""Task identifier (required)."""
|
||||
|
||||
tenant_id: Required[str]
|
||||
"""Tenant identifier (required)."""
|
||||
|
||||
kb_id: str
|
||||
"""Knowledge base / dataset identifier."""
|
||||
|
||||
doc_id: str
|
||||
"""Document identifier."""
|
||||
|
||||
doc_ids: List[str]
|
||||
"""List of document identifiers (for batch tasks like RAPTOR/GraphRAG)."""
|
||||
|
||||
name: str
|
||||
"""Document name."""
|
||||
|
||||
location: str
|
||||
"""Document location/path."""
|
||||
|
||||
size: int
|
||||
"""Document file size in bytes."""
|
||||
|
||||
parser_id: str
|
||||
"""Parser identifier (e.g., 'naive', 'table', 'paper')."""
|
||||
|
||||
parser_config: Dict[str, Any]
|
||||
"""Document-level parser configuration."""
|
||||
|
||||
kb_parser_config: Dict[str, Any]
|
||||
|
||||
"""Knowledge base level parser configuration."""
|
||||
|
||||
language: str
|
||||
"""Document language (e.g., 'en', 'zh')."""
|
||||
|
||||
llm_id: str
|
||||
"""LLM model identifier."""
|
||||
|
||||
embd_id: str
|
||||
"""Embedding model identifier."""
|
||||
|
||||
from_page: int
|
||||
"""Starting page number for processing (0-based)."""
|
||||
|
||||
to_page: int
|
||||
"""Ending page number for processing (-1 means all pages)."""
|
||||
|
||||
task_type: str
|
||||
"""Task type (e.g., 'dataflow', 'raptor', 'graphrag', 'memory')."""
|
||||
|
||||
dataflow_id: str
|
||||
"""Dataflow/pipeline identifier."""
|
||||
|
||||
pagerank: int
|
||||
"""PageRank value for document scoring."""
|
||||
|
||||
file: Any
|
||||
"""File object for dataflow processing."""
|
||||
|
||||
memory_id: str
|
||||
"""Memory identifier for memory tasks."""
|
||||
|
||||
source_id: str
|
||||
"""Source identifier for memory tasks."""
|
||||
|
||||
message_dict: Dict[str, Any]
|
||||
"""Message dictionary for memory tasks."""
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskLimiters:
|
||||
"""Encapsulates all rate limiters for task execution.
|
||||
|
||||
Each limiter is an asyncio.Semaphore used to control concurrency
|
||||
for different types of operations.
|
||||
"""
|
||||
|
||||
chat: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for chat model rate limiting."""
|
||||
|
||||
minio: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for MinIO rate limiting."""
|
||||
|
||||
chunk: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for chunk building rate limiting."""
|
||||
|
||||
embed: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for embedding rate limiting."""
|
||||
|
||||
kg: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for knowledge graph rate limiting."""
|
||||
|
||||
|
||||
def _noop_progress(**kwargs: Any) -> None:
|
||||
"""No-op progress callback."""
|
||||
pass
|
||||
|
||||
|
||||
def _not_canceled(task_id: str) -> bool:
|
||||
"""Default cancellation check - always returns False."""
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskCallbacks:
|
||||
"""Encapsulates all callback functions for task execution."""
|
||||
|
||||
progress: Callable = field(default_factory=lambda: _noop_progress)
|
||||
"""Callback function for progress updates (raw, requires task_id, from_page, to_page)."""
|
||||
|
||||
has_canceled: Callable = field(default_factory=lambda: _not_canceled)
|
||||
"""Function to check if task is canceled."""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Class
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TaskContext:
|
||||
"""Typed wrapper around the task dictionary providing convenient property accessors.
|
||||
|
||||
This class uses composition to encapsulate:
|
||||
1. The raw task dictionary (TaskDict)
|
||||
2. Execution limiters (TaskLimiters)
|
||||
3. Callback functions (TaskCallbacks)
|
||||
4. Optional write operation interceptor
|
||||
5. Optional recording context for intermediate results
|
||||
|
||||
The properties provide a clean interface for accessing task attributes
|
||||
without needing to use dictionary access with string keys throughout
|
||||
the codebase.
|
||||
"""
|
||||
|
||||
# Default values for optional task fields
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"kb_id": "",
|
||||
"doc_id": "",
|
||||
"doc_ids": [],
|
||||
"name": "",
|
||||
"location": "",
|
||||
"size": 0,
|
||||
"parser_id": "",
|
||||
"parser_config": {},
|
||||
"kb_parser_config": {},
|
||||
"language": "en",
|
||||
"llm_id": "",
|
||||
"embd_id": "",
|
||||
"from_page": 0,
|
||||
"to_page": -1,
|
||||
"task_type": "",
|
||||
"dataflow_id": "",
|
||||
"pagerank": 0,
|
||||
"memory_id": "",
|
||||
"source_id": "",
|
||||
"message_dict": {},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task: TaskDict,
|
||||
limiters: TaskLimiters,
|
||||
callbacks: TaskCallbacks,
|
||||
write_interceptor: WriteOperationInterceptor = None,
|
||||
recording_context: BaseRecordingContext = None,
|
||||
):
|
||||
"""Initialize TaskContext.
|
||||
|
||||
Args:
|
||||
task: The raw task dictionary containing all task attributes.
|
||||
limiters: TaskLimiters dataclass containing all rate limiters.
|
||||
callbacks: TaskCallbacks dataclass containing all callback functions.
|
||||
write_interceptor: Optional interceptor for write operations.
|
||||
recording_context: Optional BaseRecordingContext for intermediate result
|
||||
capture. Must be injected via constructor.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields ('id', 'tenant_id') are missing from task.
|
||||
"""
|
||||
# Validate required fields
|
||||
if "id" not in task:
|
||||
raise ValueError("Task must contain 'id'")
|
||||
if "tenant_id" not in task:
|
||||
raise ValueError("Task must contain 'tenant_id'")
|
||||
|
||||
self._task = task
|
||||
self.limiters = limiters
|
||||
self.callbacks = callbacks
|
||||
self._write_interceptor = write_interceptor
|
||||
self._recording_context = recording_context
|
||||
|
||||
|
||||
# Prepare progress callback and set it on the context
|
||||
progress_cb = partial(
|
||||
callbacks.progress,
|
||||
self.id,
|
||||
self.from_page,
|
||||
self.to_page,
|
||||
)
|
||||
self._progress_cb = progress_cb
|
||||
|
||||
# =========================================================================
|
||||
# Core task identity properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Task identifier."""
|
||||
return self._task["id"]
|
||||
|
||||
@property
|
||||
def tenant_id(self) -> str:
|
||||
"""Tenant identifier."""
|
||||
return self._task["tenant_id"]
|
||||
|
||||
@property
|
||||
def kb_id(self) -> str:
|
||||
"""Knowledge base / dataset identifier."""
|
||||
return self._task.get("kb_id", self._DEFAULTS["kb_id"])
|
||||
|
||||
@property
|
||||
def doc_id(self) -> str:
|
||||
"""Document identifier."""
|
||||
return self._task.get("doc_id", self._DEFAULTS["doc_id"])
|
||||
|
||||
@property
|
||||
def doc_ids(self) -> List[str]:
|
||||
"""List of document identifiers (for batch tasks like RAPTOR/GraphRAG)."""
|
||||
return self._task.get("doc_ids", list(self._DEFAULTS["doc_ids"]))
|
||||
|
||||
# =========================================================================
|
||||
# Document metadata properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Document name."""
|
||||
return self._task.get("name", self._DEFAULTS["name"])
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Document location/path."""
|
||||
return self._task.get("location", self._DEFAULTS["location"])
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Document file size in bytes."""
|
||||
return self._task.get("size", self._DEFAULTS["size"])
|
||||
|
||||
# =========================================================================
|
||||
# Parser configuration properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def parser_id(self) -> str:
|
||||
"""Parser identifier (e.g., 'naive', 'table', 'paper')."""
|
||||
return self._task.get("parser_id", self._DEFAULTS["parser_id"])
|
||||
|
||||
@property
|
||||
def parser_config(self) -> Dict[str, Any]:
|
||||
"""Document-level parser configuration."""
|
||||
return self._task.get("parser_config", {})
|
||||
|
||||
@property
|
||||
def kb_parser_config(self) -> Dict[str, Any]:
|
||||
"""Knowledge base level parser configuration."""
|
||||
return self._task.get("kb_parser_config", {})
|
||||
|
||||
# =========================================================================
|
||||
# Language and model properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""Document language (e.g., 'en', 'zh')."""
|
||||
return self._task.get("language", self._DEFAULTS["language"])
|
||||
|
||||
@property
|
||||
def llm_id(self) -> str:
|
||||
"""LLM model identifier."""
|
||||
return self._task.get("llm_id", self._DEFAULTS["llm_id"])
|
||||
|
||||
@property
|
||||
def embd_id(self) -> str:
|
||||
"""Embedding model identifier."""
|
||||
return self._task.get("embd_id", self._DEFAULTS["embd_id"])
|
||||
|
||||
# =========================================================================
|
||||
# Page range properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def from_page(self) -> int:
|
||||
"""Starting page number for processing (0-based)."""
|
||||
return self._task.get("from_page", self._DEFAULTS["from_page"])
|
||||
|
||||
@property
|
||||
def to_page(self) -> int:
|
||||
"""Ending page number for processing (-1 means all pages)."""
|
||||
return self._task.get("to_page", self._DEFAULTS["to_page"])
|
||||
|
||||
# =========================================================================
|
||||
# Task type and routing properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def task_type(self) -> str:
|
||||
"""Task type (e.g., 'dataflow', 'raptor', 'graphrag', 'memory')."""
|
||||
return self._task.get("task_type", self._DEFAULTS["task_type"])
|
||||
|
||||
@property
|
||||
def dataflow_id(self) -> str:
|
||||
"""Dataflow/pipeline identifier."""
|
||||
return self._task.get("dataflow_id", self._DEFAULTS["dataflow_id"])
|
||||
|
||||
# =========================================================================
|
||||
# Additional properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def pagerank(self) -> int:
|
||||
"""PageRank value for document scoring."""
|
||||
return self._task.get("pagerank", self._DEFAULTS["pagerank"])
|
||||
|
||||
@property
|
||||
def file(self) -> Optional[Any]:
|
||||
"""File object for dataflow processing."""
|
||||
return self._task.get("file")
|
||||
|
||||
# =========================================================================
|
||||
# Memory task specific properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def memory_id(self) -> str:
|
||||
"""Memory identifier for memory tasks."""
|
||||
return self._task.get("memory_id", self._DEFAULTS["memory_id"])
|
||||
|
||||
@property
|
||||
def source_id(self) -> str:
|
||||
"""Source identifier for memory tasks."""
|
||||
return self._task.get("source_id", self._DEFAULTS["source_id"])
|
||||
|
||||
@property
|
||||
def message_dict(self) -> Dict[str, Any]:
|
||||
"""Message dictionary for memory tasks."""
|
||||
return self._task.get("message_dict", {})
|
||||
|
||||
# =========================================================================
|
||||
# Raw task dictionary access
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def raw_task(self) -> Dict[str, Any]:
|
||||
"""Return the raw task dictionary."""
|
||||
return self._task
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from the task dictionary with a default.
|
||||
|
||||
Args:
|
||||
key: The key to look up.
|
||||
default: Default value if key is not found.
|
||||
|
||||
Returns:
|
||||
The value associated with the key, or default if not found.
|
||||
"""
|
||||
return self._task.get(key, default)
|
||||
|
||||
# =========================================================================
|
||||
# Limiter properties (proxies to TaskLimiters dataclass)
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def chat_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for chat model rate limiting."""
|
||||
return self.limiters.chat or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def minio_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for MinIO rate limiting."""
|
||||
return self.limiters.minio or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def chunk_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for chunk building rate limiting."""
|
||||
return self.limiters.chunk or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def embed_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for embedding rate limiting."""
|
||||
return self.limiters.embed or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def kg_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for knowledge graph rate limiting."""
|
||||
return self.limiters.kg or asyncio.Semaphore(1)
|
||||
|
||||
# =========================================================================
|
||||
# Context and interceptor properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def recording_context(self) -> BaseRecordingContext:
|
||||
"""BaseRecordingContext for this task.
|
||||
|
||||
Must be injected via constructor. Raises RuntimeError if accessed
|
||||
before initialization or if no context was provided.
|
||||
"""
|
||||
if self._recording_context is None:
|
||||
raise RuntimeError("recording_context accessed but not injected into TaskContext")
|
||||
return self._recording_context
|
||||
|
||||
@property
|
||||
def write_interceptor(self) -> WriteOperationInterceptor:
|
||||
"""Write operation interceptor for comparison mode."""
|
||||
return self._write_interceptor
|
||||
|
||||
# =========================================================================
|
||||
# Callback properties (proxies to TaskCallbacks dataclass)
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def has_canceled_func(self) -> Callable:
|
||||
"""Function to check if task is canceled."""
|
||||
return self.callbacks.has_canceled
|
||||
|
||||
# =========================================================================
|
||||
# Pre-bound progress callback
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def progress_cb(self) -> Callable:
|
||||
"""Pre-bound progress callback (task_id, from_page, to_page already bound).
|
||||
|
||||
Use this property in services for progress updates.
|
||||
Falls back to progress_callback if progress_cb is not set.
|
||||
"""
|
||||
return self._progress_cb
|
||||
@@ -0,0 +1,492 @@
|
||||
# Task Executor Refactoring Plan
|
||||
|
||||
## 1. Current State Analysis
|
||||
|
||||
### 1.1 Original File
|
||||
- **File Location**: `rag/svr/task_executor.py`
|
||||
- **Lines of Code**: Approximately 1,780 lines
|
||||
- **Primary Responsibilities**: Task consumption, document chunking, vectorization, index building, RAPTOR/GraphRAG processing, heartbeat reporting
|
||||
|
||||
### 1.2 Identified Issues
|
||||
|
||||
| Issue Type | Specific Manifestation |
|
||||
|------------|------------------------|
|
||||
| Single Responsibility Violation | One file handles 7+ different responsibilities |
|
||||
| Global State | Global variables like `DONE_TASKS`, `FAILED_TASKS`, `CURRENT_TASKS` |
|
||||
| Tight Coupling | Direct dependencies on `TaskService`, `DocumentService`, `REDIS_CONN`, etc. |
|
||||
| Untestable | Functions depend on global state and external services, difficult to mock |
|
||||
| Hardcoded Configuration | `BATCH_SIZE`, `FACTORY`, etc. hardcoded in the file |
|
||||
|
||||
---
|
||||
|
||||
## 2. Implemented Architecture
|
||||
|
||||
### 2.1 Actual Module Structure
|
||||
|
||||
```
|
||||
rag/svr/task_executor_refactor/
|
||||
├── task_context.py # Task context encapsulation (~450 lines)
|
||||
├── recording_context.py # Execution result recording context (~330 lines)
|
||||
├── write_operation_interceptor.py # Write operation interceptor (~130 lines)
|
||||
├── chunk_service.py # Document chunking service (~430 lines)
|
||||
├── chunk_builder.py # Chunk building logic (~130 lines)
|
||||
├── chunk_post_processor.py # Post-chunking logic (~350 lines)
|
||||
├── embedding_service.py # Embedding service (~130 lines)
|
||||
├── embedding_utils.py # Embedding utility functions (~210 lines)
|
||||
├── raptor_service.py # RAPTOR processing service (~520 lines)
|
||||
├── raptor_utils.py # RAPTOR utility functions (~100 lines)
|
||||
├── dataflow_service.py # Dataflow pipeline service (~430 lines)
|
||||
├── post_processor.py # Post-processing service (~150 lines)
|
||||
├── comparator.py # Comparator (~550 lines)
|
||||
├── report_generator.py # Report generator (~130 lines)
|
||||
├── task_handler.py # Task handler entry point (~630 lines)
|
||||
├── task_manager.py # Task manager (~200 lines)
|
||||
├── constants.py # Constant definitions (~25 lines)
|
||||
└── insert_service.py # Insert service (~150 lines)
|
||||
|
||||
test/unit_test/rag/svr/task_executor_refactor/
|
||||
├── conftest.py # Shared test fixtures (~260 lines)
|
||||
├── test_task_context.py # TaskContext tests (~410 lines)
|
||||
├── test_recording_context.py # RecordingContext tests (~330 lines)
|
||||
├── test_write_operation_interceptor.py # Interceptor tests (~450 lines)
|
||||
├── test_chunk_service.py # ChunkService tests (~560 lines)
|
||||
├── test_chunk_builder.py # ChunkBuilder tests (~290 lines)
|
||||
├── test_chunk_post_processor.py # ChunkPostProcessor tests (~550 lines)
|
||||
├── test_embedding_service.py # EmbeddingService tests (~190 lines)
|
||||
├── test_embedding_utils.py # EmbeddingUtils tests (~370 lines)
|
||||
├── test_raptor_service.py # RaptorService tests (~350 lines)
|
||||
├── test_dataflow_service.py # DataflowService tests (~250 lines)
|
||||
├── test_post_processor.py # PostProcessor tests (~120 lines)
|
||||
├── test_comparator.py # Comparator tests (~570 lines)
|
||||
├── test_task_handler.py # TaskHandler unit tests (~800 lines)
|
||||
├── test_task_handler_integration.py # TaskHandler integration tests (~1400 lines)
|
||||
└── test_constants.py # Constants tests (~40 lines)
|
||||
```
|
||||
|
||||
### 2.2 Layered Architecture Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Business Layer │
|
||||
│ task_handler.py │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ TaskHandler Class │ │
|
||||
│ │ ├── handle_task() # Entry point, handles cancellation and exceptions │ │
|
||||
│ │ ├── handle() # Task type routing dispatch │ │
|
||||
│ │ ├── _run_dataflow() # Dataflow pipeline execution │ │
|
||||
│ │ ├── _run_raptor() # RAPTOR summary generation │ │
|
||||
│ │ ├── _run_graphrag() # GraphRAG knowledge graph │ │
|
||||
│ │ └── _run_standard_chunking() # Standard chunking flow │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Entry Functions: │
|
||||
│ ├── run_refactored_task() # Refactored version entry │
|
||||
│ └── dry_run_task() # Comparison mode entry │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Service Layer │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
|
||||
│ │ ChunkService │ │ EmbeddingService │ │ RaptorService │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ build_chunks() │ │ embed_chunks() │ │ run_raptor_ │ │
|
||||
│ │ insert_chunks() │ │ │ │ for_kb() │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
|
||||
│ │DataflowService │ │ PostProcessor │ │ InsertService │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ run_dataflow() │ │ process_table_ │ │ insert_chunks()│ │
|
||||
│ │ │ │ parser_ │ │ │ │
|
||||
│ │ │ │ metadata() │ │ │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ ChunkBuilder │ │ChunkPostProcessor│ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Chunk building │ │ Post-processing │ │
|
||||
│ │ logic │ │ logic │ │
|
||||
│ └─────────────────┘ └──────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure Layer │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
|
||||
│ │ TaskContext │ │ RecordingContext │ │ Comparator │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ Task property │ │ Execution result │ │ Production vs │ │
|
||||
│ │ accessors │ │ recording │ │ Dry-run │ │
|
||||
│ │ Rate limiter │ │ Function return │ │ Difference │ │
|
||||
│ │ encapsulation │ │ value recording │ │ report gen │ │
|
||||
│ │ Interceptor │ │ Timing decorator │ │ │ │
|
||||
│ │ references │ │ │ │ │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ WriteOperationInterceptor │ │ ReportGenerator │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Whitelist method interception │ │ Difference report │ │
|
||||
│ │ Pre-recorded return value replay │ │ Formatted output │ │
|
||||
│ └──────────────────────────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ TaskManager │ │ Constants & Utils │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Task lifecycle management │ │ CANVAS_DEBUG_ │ │
|
||||
│ │ Task state tracking │ │ DOC_ID │ │
|
||||
│ └──────────────────────────────────┘ │ GRAPH_RAPTOR_ │ │
|
||||
│ │ FAKE_DOC_ID │ │
|
||||
│ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Design Patterns
|
||||
|
||||
### 3.1 Dependency Injection
|
||||
|
||||
All services receive `TaskContext` through constructors, rather than directly importing global state:
|
||||
|
||||
```python
|
||||
class ChunkService:
|
||||
def __init__(self, ctx: TaskContext):
|
||||
self._task_context = ctx
|
||||
```
|
||||
|
||||
### 3.2 Interceptor Pattern
|
||||
|
||||
`WriteOperationInterceptor` is used to replay production execution return values in comparison mode:
|
||||
|
||||
```python
|
||||
# Comparison mode: intercept write operations
|
||||
if ctx.write_interceptor:
|
||||
update_result = ctx.write_interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
else:
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
```
|
||||
|
||||
### 3.3 Recording Context Pattern
|
||||
|
||||
`RecordingContext` captures intermediate results for comparison:
|
||||
|
||||
```python
|
||||
# Record intermediate results
|
||||
get_recording_context().record("chunks", chunks)
|
||||
get_recording_context().record("token_count", token_count)
|
||||
```
|
||||
|
||||
### 3.4 Factory Pattern
|
||||
|
||||
Parser modules are registered through factory mapping:
|
||||
|
||||
```python
|
||||
PARSER_FACTORY = {}
|
||||
|
||||
def register_parser(parser_id: str, parser_module):
|
||||
PARSER_FACTORY[parser_id] = parser_module
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Task Execution Flow
|
||||
|
||||
### 4.1 Standard Task Flow
|
||||
|
||||
```
|
||||
run_refactored_task()
|
||||
│
|
||||
▼
|
||||
TaskContext Creation
|
||||
│
|
||||
▼
|
||||
TaskHandler.handle_task()
|
||||
│
|
||||
├── try: handle()
|
||||
│ │
|
||||
│ ├── Task type judgment
|
||||
│ │ ├── "memory" → handle_save_to_memory_task()
|
||||
│ │ ├── "dataflow" → DataflowService.run_dataflow()
|
||||
│ │ ├── "raptor" → _run_raptor()
|
||||
│ │ ├── "graphrag" → _run_graphrag()
|
||||
│ │ ├── "mindmap" → Placeholder
|
||||
│ │ └── Others → _run_standard_chunking()
|
||||
│ │
|
||||
│ └── _run_standard_chunking()
|
||||
│ │
|
||||
│ ├── Bind embedding model
|
||||
│ ├── Retrieve storage binary
|
||||
│ ├── ChunkService.build_chunks()
|
||||
│ │ ├── File size validation
|
||||
│ │ ├── Parser chunking
|
||||
│ │ ├── Outline extraction
|
||||
│ │ ├── MinIO upload
|
||||
│ │ ├── Keyword extraction
|
||||
│ │ ├── Question generation
|
||||
│ │ ├── Metadata generation
|
||||
│ │ └── Content tagging
|
||||
│ ├── EmbeddingService.embed_chunks()
|
||||
│ ├── TOC generation (async)
|
||||
│ ├── ChunkService.insert_chunks()
|
||||
│ ├── PostProcessor.process_table_parser_metadata()
|
||||
│ ├── TOC insertion
|
||||
│ └── DocumentService.increment_chunk_num()
|
||||
│
|
||||
└── finally: Cancel task cleanup
|
||||
```
|
||||
|
||||
### 4.2 Comparison Mode Flow
|
||||
|
||||
```
|
||||
dry_run_task()
|
||||
│
|
||||
├── Create WriteOperationInterceptor (using pre-recorded values from recording_ctx1)
|
||||
├── Create new RecordingContext (recording_ctx2)
|
||||
├── Set recording_context to recording_ctx2
|
||||
│
|
||||
▼
|
||||
TaskHandler.handle_task() # Execute with interceptor replay
|
||||
│
|
||||
▼
|
||||
ContextComparator.compare(task_id, recording_ctx1, recording_ctx2)
|
||||
│
|
||||
├── Key-by-key comparison
|
||||
├── Generate difference report
|
||||
└── Output mismatched_keys and remaining_values
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Strategy
|
||||
|
||||
### 5.1 Test Coverage Status
|
||||
|
||||
| Module | Test File | Test Lines | Coverage Focus |
|
||||
|--------|-----------|------------|----------------|
|
||||
| `TaskContext` | `test_task_context.py` | ~410 | Property accessors, rate limiters, interceptors |
|
||||
| `RecordingContext` | `test_recording_context.py` | ~330 | Record/retrieve, function return values, timing |
|
||||
| `WriteOperationInterceptor` | `test_write_operation_interceptor.py` | ~450 | Whitelist validation, FIFO replay |
|
||||
| `ChunkService` | `test_chunk_service.py` | ~560 | Chunking logic, post-processing, insertion |
|
||||
| `ChunkBuilder` | `test_chunk_builder.py` | ~290 | Chunk building logic |
|
||||
| `ChunkPostProcessor` | `test_chunk_post_processor.py` | ~550 | Post-processing logic |
|
||||
| `EmbeddingService` | `test_embedding_service.py` | ~190 | Batch encoding, vector stacking |
|
||||
| `EmbeddingUtils` | `test_embedding_utils.py` | ~370 | Text preparation, truncation, stacking |
|
||||
| `RaptorService` | `test_raptor_service.py` | ~350 | RAPTOR execution |
|
||||
| `DataflowService` | `test_dataflow_service.py` | ~250 | Dataflow execution |
|
||||
| `PostProcessor` | `test_post_processor.py` | ~120 | Table metadata processing |
|
||||
| `Comparator` | `test_comparator.py` | ~570 | Various type comparison logic |
|
||||
| `TaskHandler` | `test_task_handler.py` | ~800 | Routing, model binding, task types |
|
||||
| `TaskHandler` | `test_task_handler_integration.py` | ~1400 | Full flow integration tests |
|
||||
| `constants.py` | `test_constants.py` | ~40 | Constant value validation |
|
||||
|
||||
**Total Test Code**: Approximately 6,700+ lines
|
||||
|
||||
### 5.2 Mock Strategy
|
||||
|
||||
```python
|
||||
# conftest.py shared fixtures
|
||||
|
||||
@pytest.fixture
|
||||
def mock_task():
|
||||
"""Standard test task"""
|
||||
return {
|
||||
"id": "task-001",
|
||||
"task_type": "standard",
|
||||
"tenant_id": "tenant-001",
|
||||
"kb_id": "kb-001",
|
||||
"doc_id": "doc-001",
|
||||
"name": "test.pdf",
|
||||
...
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_task_context(mock_task):
|
||||
"""TaskContext fixture"""
|
||||
return TaskContext(
|
||||
task=mock_task,
|
||||
chat_limiter=asyncio.Semaphore(1),
|
||||
minio_limiter=asyncio.Semaphore(1),
|
||||
chunk_limiter=asyncio.Semaphore(1),
|
||||
embed_limiter=asyncio.Semaphore(1),
|
||||
kg_limiter=asyncio.Semaphore(1),
|
||||
progress_callback=lambda **kwargs: None,
|
||||
has_canceled_func=lambda task_id: False,
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 Test Coverage Targets
|
||||
|
||||
| Module | Current Coverage | Target Coverage | Notes |
|
||||
|--------|-----------------|-----------------|-------|
|
||||
| `task_context.py` | ~90% | 95%+ | Good |
|
||||
| `recording_context.py` | ~85% | 90%+ | Good |
|
||||
| `write_operation_interceptor.py` | ~90% | 95%+ | Good |
|
||||
| `chunk_service.py` | ~80% | 90%+ | Good |
|
||||
| `chunk_builder.py` | ~75% | 85%+ | Needs more edge case tests |
|
||||
| `chunk_post_processor.py` | ~80% | 90%+ | Good |
|
||||
| `embedding_service.py` | ~85% | 90%+ | Good |
|
||||
| `raptor_service.py` | ~70% | 85%+ | Improved |
|
||||
| `dataflow_service.py` | ~75% | 85%+ | Good |
|
||||
| `post_processor.py` | ~75% | 85%+ | Good |
|
||||
| `comparator.py` | ~85% | 90%+ | Good |
|
||||
| `task_handler.py` | ~75% | 85%+ | Needs more integration tests |
|
||||
|
||||
---
|
||||
|
||||
## 6. Backward Compatibility Strategy
|
||||
|
||||
### 6.1 Dual Code Path Coexistence
|
||||
|
||||
Original `task_executor.py` is preserved, importing refactored modules:
|
||||
|
||||
```python
|
||||
# rag/svr/task_executor.py (modified)
|
||||
from rag.svr.task_executor_refactor.task_handler import dry_run_task, run_refactored_task
|
||||
from rag.svr.task_executor_refactor.recording_context import timed_with_recording, get_recording_context, \
|
||||
RecordingContext, set_recording_context
|
||||
```
|
||||
|
||||
### 6.2 Migration Plan
|
||||
|
||||
| Phase | Status | Description |
|
||||
|-------|--------|-------------|
|
||||
| Phase 1 | ✅ Completed | Dual code paths parallel, `run_refactored_task()` and `dry_run_task()` available |
|
||||
| Phase 2 | ⏳ Pending | Switch default execution to refactored code, keep old code as fallback |
|
||||
| Phase 3 | ⏳ Pending | Remove old code after validation period |
|
||||
|
||||
---
|
||||
|
||||
## 7. Equivalence Guarantee Strategy
|
||||
|
||||
### 7.1 Comparison Mode
|
||||
|
||||
The refactoring introduces a unique comparison mode to verify equivalence:
|
||||
|
||||
1. **Production Execution**: Run original code path, record all intermediate results to `RecordingContext`
|
||||
2. **Dry Run**: Use `WriteOperationInterceptor` to replay production results, record new intermediate results
|
||||
3. **Comparison**: `ContextComparator` compares differences between two contexts
|
||||
|
||||
### 7.2 Comparison Strategy
|
||||
|
||||
| Data Type | Comparison Strategy |
|
||||
|-----------|---------------------|
|
||||
| Primitives (int, str, bool) | Direct equality |
|
||||
| Floating point | Tolerance range |
|
||||
| Lists | Length + ID set + sampled content |
|
||||
| Dictionaries | Key set + recursive value comparison |
|
||||
| None | Equal |
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation | Status |
|
||||
|------|------------|--------|
|
||||
| Refactoring introduces bugs | Comparison mode verifies equivalence | ✅ Implemented |
|
||||
| Performance regression | Benchmark comparison | ⏳ Pending |
|
||||
| Memory increase | RecordingContext stores intermediate results | ⚠️ Needs monitoring |
|
||||
| Insufficient test coverage | Supplement RaptorService tests | ✅ Improved |
|
||||
| Large modules | Split chunk_service.py | ✅ Split |
|
||||
|
||||
---
|
||||
|
||||
## 9. Future Improvement Suggestions
|
||||
|
||||
### 9.1 High Priority
|
||||
|
||||
1. **Performance Benchmarking**: Compare performance before and after refactoring
|
||||
2. **Improve Integration Tests**: Add more end-to-end test scenarios
|
||||
3. **Fix Type Annotations**: Add `Any` type for `default_value` and similar parameters
|
||||
|
||||
### 9.2 Medium Priority
|
||||
|
||||
4. **Improve Exception Handling**: Preserve more context information when wrapping exceptions
|
||||
5. **Documentation Improvement**: Add usage examples to docstrings
|
||||
|
||||
### 9.3 Low Priority
|
||||
|
||||
6. **Memory Optimization**: Consider streaming recording for large tasks
|
||||
7. **Code Cleanup**: Remove unused imports and functions
|
||||
|
||||
---
|
||||
|
||||
## 10. Code Statistics
|
||||
|
||||
### 10.1 Source Code
|
||||
|
||||
| Module | Lines | Type |
|
||||
|--------|-------|------|
|
||||
| `task_context.py` | ~450 | Infrastructure |
|
||||
| `recording_context.py` | ~330 | Infrastructure |
|
||||
| `write_operation_interceptor.py` | ~130 | Infrastructure |
|
||||
| `comparator.py` | ~550 | Infrastructure |
|
||||
| `report_generator.py` | ~130 | Infrastructure |
|
||||
| `constants.py` | ~25 | Infrastructure |
|
||||
| `task_manager.py` | ~200 | Infrastructure |
|
||||
| `chunk_service.py` | ~430 | Service |
|
||||
| `chunk_builder.py` | ~130 | Service |
|
||||
| `chunk_post_processor.py` | ~350 | Service |
|
||||
| `embedding_service.py` | ~130 | Service |
|
||||
| `embedding_utils.py` | ~210 | Utility |
|
||||
| `raptor_service.py` | ~520 | Service |
|
||||
| `raptor_utils.py` | ~100 | Utility |
|
||||
| `dataflow_service.py` | ~430 | Service |
|
||||
| `post_processor.py` | ~150 | Service |
|
||||
| `insert_service.py` | ~150 | Service |
|
||||
| `task_handler.py` | ~630 | Business |
|
||||
| **Source Code Total** | **~4,900** | |
|
||||
|
||||
### 10.2 Test Code
|
||||
|
||||
| Test File | Lines |
|
||||
|-----------|-------|
|
||||
| `conftest.py` | ~260 |
|
||||
| `test_task_context.py` | ~410 |
|
||||
| `test_recording_context.py` | ~330 |
|
||||
| `test_write_operation_interceptor.py` | ~450 |
|
||||
| `test_chunk_service.py` | ~560 |
|
||||
| `test_chunk_builder.py` | ~290 |
|
||||
| `test_chunk_post_processor.py` | ~550 |
|
||||
| `test_embedding_service.py` | ~190 |
|
||||
| `test_embedding_utils.py` | ~370 |
|
||||
| `test_raptor_service.py` | ~350 |
|
||||
| `test_dataflow_service.py` | ~250 |
|
||||
| `test_post_processor.py` | ~120 |
|
||||
| `test_comparator.py` | ~570 |
|
||||
| `test_task_handler.py` | ~800 |
|
||||
| `test_task_handler_integration.py` | ~1400 |
|
||||
| `test_constants.py` | ~40 |
|
||||
| **Test Code Total** | **~6,700+** |
|
||||
|
||||
### 10.3 Documentation
|
||||
|
||||
| Document | Lines |
|
||||
|----------|-------|
|
||||
| `task_executor_refactoring_plan.md` | This document |
|
||||
|
||||
---
|
||||
|
||||
## 11. Time Estimation
|
||||
|
||||
| Phase | Completed | Estimated Time |
|
||||
|-------|-----------|----------------|
|
||||
| Infrastructure Preparation | ✅ Completed | - |
|
||||
| Core Logic Decoupling | ✅ Completed | - |
|
||||
| Advanced Feature Decoupling | ✅ Completed | - |
|
||||
| Test Writing | ✅ Mostly Completed | - |
|
||||
| Performance Benchmarking | ⏳ Pending | 1-2 days |
|
||||
| Migration to Production | ⏳ Pending | 1-2 days |
|
||||
| **Remaining Total** | | **2-4 days** |
|
||||
|
||||
---
|
||||
|
||||
## 12. Summary
|
||||
|
||||
This refactoring has successfully decomposed the monolithic `task_executor.py` into a layered, testable module architecture:
|
||||
|
||||
- ✅ **Layered Architecture**: Infrastructure Layer → Service Layer → Business Layer
|
||||
- ✅ **Dependency Injection**: Execution resources injected via `TaskContext`
|
||||
- ✅ **Comparison Mode**: Innovative Production vs Dry-run comparison framework
|
||||
- ✅ **Test Coverage**: Approximately 6,700+ lines of test code
|
||||
- ✅ **Module Decomposition**: Large modules split into smaller responsibility units
|
||||
- ⚠️ **Pending Improvements**: Performance benchmarking, production migration validation
|
||||
|
||||
**Overall Status**: Core refactoring completed, test coverage is good, ready for validation and migration phases.
|
||||
@@ -0,0 +1,576 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Task Handler Module.
|
||||
|
||||
Provides [`TaskHandler`](rag/svr/task_executor_refactor/task_handler.py:56) as the main entry point
|
||||
for handling document processing tasks with refactored, testable methods.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import xxhash
|
||||
|
||||
from timeit import default_timer as timer
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.knowledgebase_service import KnowledgebaseService
|
||||
from api.db.joint_services.memory_message_service import handle_save_to_memory_task
|
||||
from api.db.joint_services.tenant_model_service import (
|
||||
get_model_config_by_type_and_name,
|
||||
get_tenant_default_model_by_type,
|
||||
)
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
from api.db.services.task_service import GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
from common.constants import LLMType
|
||||
from common.exceptions import TaskCanceledException
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from rag.nlp import search
|
||||
from rag.svr.task_executor_refactor.constants import CANVAS_DEBUG_DOC_ID
|
||||
from rag.svr.task_executor_refactor.chunk_service import ChunkService
|
||||
from rag.svr.task_executor_refactor.dataflow_service import BillingHook, DataflowService
|
||||
from rag.svr.task_executor_refactor.embedding_service import EmbeddingService
|
||||
from rag.svr.task_executor_refactor.post_processor import PostProcessor
|
||||
from rag.svr.task_executor_refactor.raptor_service import RaptorService
|
||||
from rag.svr.task_executor_refactor.raptor_utils import delete_raptor_chunks
|
||||
from rag.svr.task_executor_refactor.recording_context import RecordingContext
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
from rag.graphrag.general.index import run_graphrag_for_kb
|
||||
from api.db.services.file2document_service import File2DocumentService
|
||||
from rag.prompts.generator import run_toc_from_text
|
||||
from common import settings
|
||||
|
||||
|
||||
class TaskHandler:
|
||||
"""Main task handler for document processing.
|
||||
|
||||
This class orchestrates the entire document processing pipeline:
|
||||
1. Task type detection (memory, dataflow, raptor, graphrag, standard)
|
||||
2. Model binding (embedding, chat)
|
||||
3. Chunk building or RAPTOR/GraphRAG execution
|
||||
4. Embedding
|
||||
5. Indexing
|
||||
6. Post-processing (TOC, table metadata)
|
||||
|
||||
All intermediate results are recorded via RecordingContext for comparison.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
billing_hook: Optional[BillingHook] = None,
|
||||
):
|
||||
"""Initialize TaskHandler.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
billing_hook: Optional billing hook for pipeline success/error callbacks.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
self._billing_hook = billing_hook
|
||||
|
||||
async def handle_task(self) -> None:
|
||||
try:
|
||||
await self.handle()
|
||||
finally:
|
||||
task_id = self._task_context.id
|
||||
task_tenant_id = self._task_context.tenant_id
|
||||
task_dataset_id = self._task_context.kb_id
|
||||
task_doc_id = self._task_context.doc_id
|
||||
if self._task_context.has_canceled_func(task_id):
|
||||
try:
|
||||
exists = await thread_pool_exec(
|
||||
settings.docStoreConn.index_exist,
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
if exists:
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": task_doc_id},
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
self._task_context.recording_context.save_func_return_value("docStoreConn.delete", ret)
|
||||
except Exception as e:
|
||||
logging.exception(
|
||||
f"Remove doc({task_doc_id}) from docStore failed when task({task_id}) canceled, exception: {e}")
|
||||
|
||||
async def handle(self) -> None:
|
||||
"""Handle a document processing task."""
|
||||
ctx = self._task_context
|
||||
task_type = ctx.task_type
|
||||
task_id = ctx.id
|
||||
|
||||
# Handle memory tasks
|
||||
if task_type == "memory":
|
||||
# ignore when it's dry run - no change on handle_save_to_memory_task when refactor
|
||||
if isinstance(ctx.write_interceptor, RecordingContext):
|
||||
logging.info(f"dry run, ignore handle_save_to_memory_task {task_id}")
|
||||
else:
|
||||
# actual run - not dry run
|
||||
await handle_save_to_memory_task(ctx.raw_task)
|
||||
|
||||
# Handle dataflow debug mode
|
||||
if task_type == "dataflow" and ctx.doc_id == CANVAS_DEBUG_DOC_ID:
|
||||
await self._run_dataflow()
|
||||
return
|
||||
|
||||
if task_type.startswith("dataflow"):
|
||||
await self._run_dataflow()
|
||||
return
|
||||
|
||||
# Check if task is canceled
|
||||
if ctx.has_canceled_func(task_id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
# Bind embedding model
|
||||
embedding_model = await self._bind_embedding_model()
|
||||
if embedding_model is None:
|
||||
return
|
||||
|
||||
with embedding_model:
|
||||
vector_size = self._get_vector_size(embedding_model)
|
||||
self._init_kb(vector_size)
|
||||
|
||||
# Route to appropriate handler
|
||||
if task_type == "raptor":
|
||||
await self._run_raptor(embedding_model, vector_size)
|
||||
elif task_type == "graphrag":
|
||||
await self._run_graphrag(embedding_model)
|
||||
elif task_type == "mindmap":
|
||||
ctx.progress_cb(1, "place holder")
|
||||
elif task_type == "evaluation":
|
||||
await self._run_evaluation()
|
||||
elif task_type == "reembedding":
|
||||
await self._run_reembedding()
|
||||
elif task_type == "clone":
|
||||
await self._run_clone()
|
||||
else:
|
||||
await self._run_standard_chunking(embedding_model)
|
||||
|
||||
|
||||
@classmethod
|
||||
def _get_vector_size(cls, embedding_model: LLMBundle) -> int:
|
||||
"""Get vector size from embedding model."""
|
||||
vts, _ = embedding_model.encode(["ok"])
|
||||
return len(vts[0])
|
||||
|
||||
def _init_kb(self, vector_size: int) -> None:
|
||||
"""Initialize knowledge base index."""
|
||||
ctx = self._task_context
|
||||
idxnm = search.index_name(ctx.tenant_id)
|
||||
parser_id = ctx.parser_id
|
||||
# Create index if not exists
|
||||
settings.docStoreConn.create_idx(idxnm, ctx.kb_id, vector_size, parser_id)
|
||||
|
||||
async def _run_dataflow(self) -> None:
|
||||
"""Run dataflow pipeline."""
|
||||
dataflow_service = DataflowService(
|
||||
ctx=self._task_context,
|
||||
billing_hook=self._billing_hook,
|
||||
)
|
||||
await dataflow_service.run_dataflow()
|
||||
|
||||
async def _run_evaluation(self) -> None:
|
||||
"""Run evaluation task."""
|
||||
ctx = self._task_context
|
||||
ctx.progress_cb(1, "Evaluation task placeholder")
|
||||
|
||||
async def _run_reembedding(self) -> None:
|
||||
"""Run reembedding task."""
|
||||
ctx = self._task_context
|
||||
ctx.progress_cb(1, "Reembedding task placeholder")
|
||||
|
||||
async def _run_clone(self) -> None:
|
||||
"""Run clone task."""
|
||||
ctx = self._task_context
|
||||
ctx.progress_cb(1, "Clone task placeholder")
|
||||
|
||||
async def _bind_embedding_model(self) -> Optional[LLMBundle]:
|
||||
"""Bind embedding model to task."""
|
||||
ctx = self._task_context
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_embedding_id = ctx.embd_id
|
||||
task_language = ctx.language
|
||||
|
||||
try:
|
||||
if task_embedding_id:
|
||||
embd_model_config = get_model_config_by_type_and_name(
|
||||
task_tenant_id, LLMType.EMBEDDING, task_embedding_id
|
||||
)
|
||||
else:
|
||||
embd_model_config = get_tenant_default_model_by_type(
|
||||
task_tenant_id, LLMType.EMBEDDING
|
||||
)
|
||||
embedding_model = LLMBundle(task_tenant_id, embd_model_config, lang=task_language)
|
||||
vts, _ = embedding_model.encode(["ok"])
|
||||
return embedding_model
|
||||
except Exception as e:
|
||||
error_message = f'Fail to bind embedding model: {str(e)}'
|
||||
ctx.progress_cb(-1, msg=error_message)
|
||||
logging.exception(error_message)
|
||||
raise
|
||||
|
||||
async def _run_raptor(
|
||||
self,
|
||||
embedding_model: LLMBundle,
|
||||
vector_size: int,
|
||||
) -> None:
|
||||
"""Run RAPTOR summary generation."""
|
||||
ctx = self._task_context
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_dataset_id = ctx.kb_id
|
||||
kb_task_llm_id = ctx.kb_parser_config.get("llm_id") or ctx.llm_id
|
||||
|
||||
ok, kb = KnowledgebaseService.get_by_id(task_dataset_id)
|
||||
if not ok:
|
||||
ctx.progress_cb(prog=-1.0, msg="Cannot found valid dataset for RAPTOR task")
|
||||
return
|
||||
|
||||
kb_parser_config = kb.parser_config
|
||||
if not kb_parser_config.get("raptor", {}).get("use_raptor", False):
|
||||
kb_parser_config.update({
|
||||
"raptor": {
|
||||
"use_raptor": True,
|
||||
"prompt": "Please summarize the following paragraphs. Be careful with the numbers, do not make things up. Paragraphs as following:\n {cluster_content}\nThe above is the content you need to summarize.",
|
||||
"max_token": 256,
|
||||
"threshold": 0.1,
|
||||
"max_cluster": 64,
|
||||
"random_seed": 0,
|
||||
"scope": "file",
|
||||
"clustering_method": "gmm",
|
||||
"tree_builder": "raptor",
|
||||
},
|
||||
})
|
||||
if ctx.write_interceptor:
|
||||
update_result = ctx.write_interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
else:
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
|
||||
if not update_result:
|
||||
ctx.progress_cb(prog=-1.0, msg="Internal error: Invalid RAPTOR configuration")
|
||||
return
|
||||
|
||||
# Bind LLM for raptor
|
||||
chat_model_config = get_model_config_by_type_and_name(
|
||||
task_tenant_id, LLMType.CHAT, kb_task_llm_id
|
||||
)
|
||||
with LLMBundle(task_tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
# Run RAPTOR
|
||||
raptor_service = RaptorService(ctx=ctx)
|
||||
|
||||
async with ctx.kg_limiter:
|
||||
chunks, token_count, raptor_cleanup_chunks = await raptor_service.run_raptor_for_kb(
|
||||
kb_parser_config=kb_parser_config,
|
||||
chat_mdl=chat_model,
|
||||
embd_mdl=embedding_model,
|
||||
vector_size=vector_size,
|
||||
doc_ids=ctx.doc_ids,
|
||||
)
|
||||
|
||||
ctx.recording_context.record("raptor_chunks", chunks)
|
||||
ctx.recording_context.record("raptor_token_count", token_count)
|
||||
|
||||
# Insert RAPTOR chunks
|
||||
if chunks:
|
||||
task_doc_id = (ctx.doc_ids or [GRAPH_RAPTOR_FAKE_DOC_ID])[0]
|
||||
chunk_service = ChunkService(ctx=ctx)
|
||||
insert_result = await chunk_service.insert_chunks(ctx.id, task_tenant_id, task_dataset_id, chunks)
|
||||
if insert_result:
|
||||
ctx.recording_context.record("insertion_result", "success")
|
||||
else:
|
||||
ctx.recording_context.record("insertion_result", "failed")
|
||||
|
||||
# Cleanup stale RAPTOR chunks
|
||||
cleaned_chunks = 0
|
||||
for cleanup_doc_id, keep_method in raptor_cleanup_chunks:
|
||||
ret = await self._delete_raptor_chunks(
|
||||
cleanup_doc_id, task_tenant_id, task_dataset_id, keep_method
|
||||
)
|
||||
cleaned_chunks += ret
|
||||
|
||||
if cleaned_chunks:
|
||||
ctx.progress_cb(msg=f"Cleaned up {cleaned_chunks} stale RAPTOR chunks.")
|
||||
|
||||
# Update document stats
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, len(chunks), 0)
|
||||
|
||||
ctx.recording_context.record("task_status", "completed")
|
||||
ctx.progress_cb(prog=1.0, msg="RAPTOR done")
|
||||
|
||||
async def _run_graphrag(
|
||||
self,
|
||||
embedding_model: LLMBundle
|
||||
) -> None:
|
||||
"""Run GraphRAG."""
|
||||
ctx = self._task_context
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_dataset_id = ctx.kb_id
|
||||
kb_task_llm_id = ctx.kb_parser_config.get("llm_id") or ctx.llm_id
|
||||
task_language = ctx.language
|
||||
|
||||
ok, kb = KnowledgebaseService.get_by_id(task_dataset_id)
|
||||
if not ok:
|
||||
ctx.progress_cb(prog=-1.0, msg="Cannot found valid dataset for GraphRAG task")
|
||||
return
|
||||
|
||||
kb_parser_config = kb.parser_config
|
||||
if not kb_parser_config.get("graphrag", {}).get("use_graphrag", False):
|
||||
kb_parser_config.update({
|
||||
"graphrag": {
|
||||
"use_graphrag": True,
|
||||
"entity_types": ["organization", "person", "geo", "event", "category"],
|
||||
"method": "light",
|
||||
}
|
||||
})
|
||||
if ctx.write_interceptor:
|
||||
update_result = ctx.write_interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
else:
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
if not update_result:
|
||||
ctx.progress_cb(prog=-1.0, msg="Internal error: Invalid GraphRAG configuration")
|
||||
return
|
||||
|
||||
graphrag_conf = kb_parser_config.get("graphrag", {})
|
||||
start_ts = timer()
|
||||
chat_model_config = get_model_config_by_type_and_name(
|
||||
task_tenant_id, LLMType.CHAT, kb_task_llm_id
|
||||
)
|
||||
with LLMBundle(task_tenant_id, chat_model_config, lang=task_language) as chat_model:
|
||||
|
||||
with_resolution = graphrag_conf.get("resolution", False)
|
||||
with_community = graphrag_conf.get("community", False)
|
||||
|
||||
async with ctx.kg_limiter:
|
||||
result = await run_graphrag_for_kb(
|
||||
row=ctx.raw_task,
|
||||
doc_ids=ctx.doc_ids,
|
||||
language=task_language,
|
||||
kb_parser_config=kb_parser_config,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
callback=ctx.progress_cb,
|
||||
with_resolution=with_resolution,
|
||||
with_community=with_community,
|
||||
)
|
||||
logging.info(f"GraphRAG task result for task {ctx.raw_task}:\n{result}")
|
||||
|
||||
ctx.recording_context.record("graphrag_result", result)
|
||||
ctx.progress_cb(prog=1.0, msg="Knowledge Graph done ({:.2f}s)".format(timer() - start_ts))
|
||||
|
||||
async def _run_standard_chunking(
|
||||
self,
|
||||
embedding_model: LLMBundle
|
||||
) -> None:
|
||||
"""Run standard chunking pipeline."""
|
||||
ctx = self._task_context
|
||||
task_id = ctx.id
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_dataset_id = ctx.kb_id
|
||||
task_doc_id = ctx.doc_id
|
||||
task_start_ts = timer()
|
||||
doc_task_llm_id = ctx.parser_config.get("llm_id") or ctx.llm_id
|
||||
ctx.raw_task['llm_id'] = doc_task_llm_id
|
||||
|
||||
# Build chunks
|
||||
start_ts = timer()
|
||||
chunk_service = ChunkService(ctx=ctx)
|
||||
|
||||
# Get storage binary
|
||||
bucket, name = File2DocumentService.get_storage_address(doc_id=ctx.doc_id)
|
||||
binary = await self._get_storage_binary(bucket, name)
|
||||
|
||||
chunks = await chunk_service.build_chunks(binary)
|
||||
ctx.recording_context.record("chunks", chunks)
|
||||
chunk_ids = [c.get("id") for c in chunks if isinstance(c, dict) and "id" in c]
|
||||
ctx.recording_context.record("chunk_ids_count", len(chunk_ids))
|
||||
|
||||
logging.info("Build document {}: {:.2f}s".format(ctx.name, timer() - start_ts))
|
||||
|
||||
if not chunks:
|
||||
ctx.progress_cb(1., msg=f"No chunk built from {ctx.name}")
|
||||
return
|
||||
|
||||
ctx.progress_cb(msg="Generate {} chunks".format(len(chunks)))
|
||||
|
||||
# Embed chunks
|
||||
start_ts = timer()
|
||||
embedding_service = EmbeddingService(ctx=ctx)
|
||||
try:
|
||||
token_count, vector_size = embedding_service.embed_chunks(
|
||||
chunks, embedding_model, ctx.parser_config
|
||||
)
|
||||
except TaskCanceledException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_message = "Generate embedding error:{}".format(str(e))
|
||||
ctx.progress_cb(-1, error_message)
|
||||
logging.exception(error_message)
|
||||
raise
|
||||
|
||||
ctx.recording_context.record("token_count", token_count)
|
||||
ctx.recording_context.record("vector_size", vector_size)
|
||||
progress_message = "Embedding chunks ({:.2f}s)".format(timer() - start_ts)
|
||||
logging.info(progress_message)
|
||||
ctx.progress_cb(msg=progress_message)
|
||||
|
||||
# Build TOC if needed
|
||||
toc_thread = None
|
||||
if ctx.parser_id.lower() == "naive" and ctx.parser_config.get("toc_extraction", False):
|
||||
toc_thread = asyncio.create_task(asyncio.to_thread(self._build_toc, ctx, chunks, ctx.progress_cb))
|
||||
|
||||
# Insert chunks
|
||||
chunk_count = len(set([chunk["id"] for chunk in chunks]))
|
||||
start_ts = timer()
|
||||
|
||||
chunk_service = ChunkService(ctx=ctx)
|
||||
|
||||
if ctx.has_canceled_func(task_id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
insert_result = await chunk_service.insert_chunks(
|
||||
task_id, task_tenant_id, task_dataset_id, chunks
|
||||
)
|
||||
|
||||
if not insert_result:
|
||||
ctx.recording_context.record("insertion_result", "failed")
|
||||
return
|
||||
ctx.recording_context.record("insertion_result", "success")
|
||||
|
||||
# Post-processing
|
||||
post_processor = PostProcessor(ctx=ctx)
|
||||
await post_processor.process_table_parser_metadata(task_doc_id, chunks)
|
||||
|
||||
ctx.progress_cb(msg="Indexing done ({:.2f}s).".format(timer() - start_ts))
|
||||
|
||||
toc_chunk = await self._process_toc_thread(toc_thread)
|
||||
if toc_chunk:
|
||||
ctx.recording_context.record("toc_chunk", [toc_chunk])
|
||||
await post_processor.insert_toc_chunk(toc_chunk, chunk_service)
|
||||
|
||||
if ctx.has_canceled_func(task_id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
# Update document stats
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, chunk_count, 0)
|
||||
|
||||
task_time_cost = timer() - task_start_ts
|
||||
ctx.recording_context.record("task_status", "completed")
|
||||
ctx.progress_cb(prog=1.0, msg="Task done ({:.2f}s)".format(task_time_cost))
|
||||
|
||||
logging.info(
|
||||
"Chunk doc({}), page({}-{}), chunks({}), token({}), elapsed:{:.2f}".format(
|
||||
ctx.name, ctx.from_page, ctx.to_page,
|
||||
len(chunks), token_count, task_time_cost
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _process_toc_thread(self, toc_thread):
|
||||
try:
|
||||
if toc_thread:
|
||||
return await toc_thread
|
||||
else:
|
||||
return None
|
||||
finally:
|
||||
if toc_thread is not None and not toc_thread.done():
|
||||
toc_thread.cancel()
|
||||
|
||||
@classmethod
|
||||
async def _get_storage_binary(cls, bucket: str, name: str) -> bytes:
|
||||
from common import settings
|
||||
"""Get binary from storage."""
|
||||
return await thread_pool_exec(settings.STORAGE_IMPL.get, bucket, name)
|
||||
|
||||
@classmethod
|
||||
def _build_toc(cls, ctx: TaskContext, docs: List[Dict], progress_cb: Callable) -> Optional[Dict]:
|
||||
"""Build table of contents."""
|
||||
progress_cb(msg="Start to generate table of content ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(
|
||||
ctx.tenant_id, LLMType.CHAT, ctx.llm_id
|
||||
)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_mdl:
|
||||
|
||||
docs = sorted(docs, key=lambda d: (
|
||||
d.get("page_num_int", 0)[0] if isinstance(d.get("page_num_int", 0), list) else d.get("page_num_int", 0),
|
||||
d.get("top_int", 0)[0] if isinstance(d.get("top_int", 0), list) else d.get("top_int", 0)
|
||||
))
|
||||
|
||||
# NOTE: asyncio.run() creates a new event loop in the worker thread
|
||||
# (this method is called via asyncio.to_thread), which is the
|
||||
# intended pattern for bridging sync -> async in a thread context.
|
||||
toc: list[dict] = asyncio.run(
|
||||
run_toc_from_text([d["content_with_weight"] for d in docs], chat_mdl, progress_cb)
|
||||
)
|
||||
logging.info("------------ T O C -------------\n" + json.dumps(toc, ensure_ascii=False, indent=' '))
|
||||
|
||||
for ii, item in enumerate(toc):
|
||||
try:
|
||||
chunk_val = item.pop("chunk_id", None)
|
||||
if chunk_val is None or str(chunk_val).strip() == "":
|
||||
logging.warning(f"Index {ii}: chunk_id is missing or empty. Skipping.")
|
||||
continue
|
||||
curr_idx = int(chunk_val or -1)
|
||||
if curr_idx >= len(docs):
|
||||
logging.error(f"Index {ii}: chunk_id {curr_idx} exceeds docs length {len(docs)}.")
|
||||
continue
|
||||
item["ids"] = [docs[curr_idx]["id"]]
|
||||
if ii + 1 < len(toc):
|
||||
next_chunk_val = toc[ii + 1].get("chunk_id", "")
|
||||
if str(next_chunk_val).strip() != "":
|
||||
next_idx = int(next_chunk_val)
|
||||
for jj in range(curr_idx + 1, min(next_idx + 1, len(docs))):
|
||||
item["ids"].append(docs[jj]["id"])
|
||||
else:
|
||||
logging.warning(f"Index {ii + 1}: next chunk_id is empty, range fill skipped.")
|
||||
except (ValueError, TypeError) as e:
|
||||
logging.error(f"Index {ii}: Data conversion error - {e}")
|
||||
except Exception as e:
|
||||
logging.exception(f"Index {ii}: Unexpected error - {e}")
|
||||
|
||||
if toc:
|
||||
import copy
|
||||
d = copy.deepcopy(docs[-1])
|
||||
d["content_with_weight"] = json.dumps(toc, ensure_ascii=False)
|
||||
d["toc_kwd"] = "toc"
|
||||
d["available_int"] = 0
|
||||
d["page_num_int"] = [100000000]
|
||||
d["id"] = xxhash.xxh64(
|
||||
(d["content_with_weight"] + str(d["doc_id"])).encode("utf-8", "surrogatepass")).hexdigest()
|
||||
return d
|
||||
return None
|
||||
|
||||
async def _delete_raptor_chunks(
|
||||
self, doc_id: str, tenant_id: str, kb_id: str, keep_method: Optional[str]
|
||||
) -> int:
|
||||
"""Delete RAPTOR chunks."""
|
||||
if self._task_context.write_interceptor:
|
||||
return self._task_context.write_interceptor.intercept("delete_raptor_chunks")
|
||||
else:
|
||||
return await delete_raptor_chunks(doc_id, tenant_id, kb_id, keep_method)
|
||||
@@ -0,0 +1,177 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Task Manager Module.
|
||||
|
||||
Provides [`TaskManager`](rag/svr/task_executor_refactor/task_manager.py:50) as the entry point
|
||||
for executing document processing tasks, supporting both production and dry-run (comparison) modes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from rag.svr.task_executor_refactor.comparator import ContextComparator
|
||||
from rag.svr.task_executor_refactor.task_context import TaskCallbacks, TaskDict, TaskLimiters
|
||||
from rag.svr.task_executor_refactor.dataflow_service import BillingHook
|
||||
from rag.svr.task_executor_refactor.recording_context import (
|
||||
BaseRecordingContext,
|
||||
RecordingContext,
|
||||
_NULL_RECORDING_CONTEXT,
|
||||
set_recording_context, recording_context_manager,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
from rag.svr.task_executor_refactor.task_handler import TaskHandler
|
||||
from rag.svr.task_executor_refactor.write_operation_interceptor import (
|
||||
WriteOperationInterceptor,
|
||||
)
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""Entry point for executing document processing tasks.
|
||||
|
||||
This class provides methods for:
|
||||
- Production task execution (run_refactored_task)
|
||||
- Dry-run task execution with comparison (dry_run_task)
|
||||
|
||||
Usage:
|
||||
manager = TaskManager()
|
||||
await manager.run_refactored_task(task, chat_limiter, ...)
|
||||
# or
|
||||
await manager.dry_run_task(task, recording_ctx1, ...)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def run_refactored_task(
|
||||
cls,
|
||||
task: dict,
|
||||
chat_limiter: Any,
|
||||
minio_limiter: Any,
|
||||
chunk_limiter: Any,
|
||||
embed_limiter: Any,
|
||||
kg_limiter: Any,
|
||||
set_progress: Any,
|
||||
has_canceled: Any,
|
||||
billing_hook: Optional[BillingHook] = None,
|
||||
) -> None:
|
||||
"""Run a document processing task in production mode.
|
||||
|
||||
Args:
|
||||
task: Task configuration dictionary.
|
||||
chat_limiter: Rate limiter for chat operations.
|
||||
minio_limiter: Rate limiter for MinIO operations.
|
||||
chunk_limiter: Rate limiter for chunking operations.
|
||||
embed_limiter: Rate limiter for embedding operations.
|
||||
kg_limiter: Rate limiter for knowledge graph operations.
|
||||
set_progress: Progress callback function.
|
||||
has_canceled: Function to check if task is canceled.
|
||||
billing_hook: Optional billing hook for pipeline success/error callbacks.
|
||||
"""
|
||||
with recording_context_manager(_NULL_RECORDING_CONTEXT):
|
||||
# Use NullRecordingContext in production to avoid memory allocation
|
||||
set_recording_context(_NULL_RECORDING_CONTEXT)
|
||||
|
||||
# Create TaskContext with all execution resources
|
||||
task_context = TaskContext(
|
||||
task=task,
|
||||
limiters=TaskLimiters(
|
||||
chat=chat_limiter,
|
||||
minio=minio_limiter,
|
||||
chunk=chunk_limiter,
|
||||
embed=embed_limiter,
|
||||
kg=kg_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=set_progress,
|
||||
has_canceled=has_canceled,
|
||||
),
|
||||
recording_context=_NULL_RECORDING_CONTEXT,
|
||||
)
|
||||
|
||||
# Execute with TaskHandler
|
||||
handler = TaskHandler(ctx=task_context, billing_hook=billing_hook)
|
||||
await handler.handle_task()
|
||||
|
||||
@classmethod
|
||||
async def dry_run_task(
|
||||
cls,
|
||||
task: TaskDict,
|
||||
recording_ctx1: BaseRecordingContext,
|
||||
chat_limiter: Any,
|
||||
minio_limiter: Any,
|
||||
chunk_limiter: Any,
|
||||
embed_limiter: Any,
|
||||
kg_limiter: Any,
|
||||
set_progress: Any,
|
||||
has_canceled: Any,
|
||||
) -> None:
|
||||
"""Run a document processing task in dry-run mode for comparison.
|
||||
|
||||
This executes the task with a write operation interceptor that records
|
||||
all write operations, then compares the results with the production run.
|
||||
|
||||
Args:
|
||||
task: Task configuration dictionary.
|
||||
recording_ctx1: RecordingContext from production execution.
|
||||
chat_limiter: Rate limiter for chat operations.
|
||||
minio_limiter: Rate limiter for MinIO operations.
|
||||
chunk_limiter: Rate limiter for chunking operations.
|
||||
embed_limiter: Rate limiter for embedding operations.
|
||||
kg_limiter: Rate limiter for knowledge graph operations.
|
||||
set_progress: Progress callback function.
|
||||
has_canceled: Function to check if task is canceled.
|
||||
"""
|
||||
interceptor = WriteOperationInterceptor(recording_ctx1.get_all_func_return_values())
|
||||
recording_ctx2 = RecordingContext()
|
||||
|
||||
with recording_context_manager(recording_ctx2):
|
||||
set_recording_context(recording_ctx2)
|
||||
|
||||
# Create TaskContext with all execution resources
|
||||
task_context = TaskContext(
|
||||
task=task,
|
||||
limiters=TaskLimiters(
|
||||
chat=chat_limiter,
|
||||
minio=minio_limiter,
|
||||
chunk=chunk_limiter,
|
||||
embed=embed_limiter,
|
||||
kg=kg_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=set_progress,
|
||||
has_canceled=has_canceled,
|
||||
),
|
||||
write_interceptor=interceptor,
|
||||
recording_context=recording_ctx2,
|
||||
)
|
||||
|
||||
# Execute with TaskHandler
|
||||
handler = TaskHandler(ctx=task_context)
|
||||
await handler.handle_task()
|
||||
|
||||
# Compare results
|
||||
comp: ContextComparator = ContextComparator()
|
||||
comp_result = comp.compare(task_context.id, recording_ctx1, recording_ctx2)
|
||||
logging.info(f"-------{task_context.name}, compare result:{comp_result.to_markdown()}")
|
||||
if interceptor.remaining_values_count() > 0 or comp_result.mismatched_keys > 0:
|
||||
logging.info(f"------task:{task_context.id} {task_context.name} differs, "
|
||||
f"interceptor.remaining_values_count():{interceptor.remaining_values_count()}, "
|
||||
f"mismatched_keys:{comp_result.mismatched_keys}")
|
||||
if interceptor.remaining_values_count() > 0:
|
||||
logging.info(f"------task:{task_context.id}, remaining values:{interceptor.remaining_values()}")
|
||||
if comp_result.mismatched_keys > 0:
|
||||
logging.info(f"-------compare result:{comp_result.details}")
|
||||
else:
|
||||
logging.info(f"------task:{task_context.id} {task_context.name} same result for prod and dry run ")
|
||||
@@ -0,0 +1,138 @@
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Write Operation Interceptor Module
|
||||
|
||||
Provides a mechanism to intercept write operations during comparison mode.
|
||||
The interceptor consumes pre-recorded return values (from production execution)
|
||||
and returns them one by one when the corresponding methods are called.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Set of allowed method names that can be intercepted
|
||||
ALLOWED_METHOD_NAMES = {
|
||||
"KnowledgebaseService.update_by_id",
|
||||
"TaskService.update_chunk_ids",
|
||||
"DocumentService.increment_chunk_num",
|
||||
"DocMetadataService.update_document_metadata",
|
||||
"PipelineOperationLogService.record_pipeline_operation",
|
||||
"PipelineOperationLogService.create",
|
||||
"delete_raptor_chunks",
|
||||
"handle_save_to_memory_task",
|
||||
"docStoreConn.insert",
|
||||
"docStoreConn.delete"
|
||||
}
|
||||
|
||||
_NO_DEFAULT = object()
|
||||
|
||||
|
||||
class WriteOperationInterceptor:
|
||||
"""Intercepts write operations and returns pre-recorded values.
|
||||
|
||||
This interceptor is used in comparison mode to replay production execution
|
||||
results. When a method is called, the interceptor pops the first recorded
|
||||
return value from the corresponding list and returns it.
|
||||
|
||||
Usage:
|
||||
# Create interceptor with pre-recorded values
|
||||
interceptor = WriteOperationInterceptor({
|
||||
"build_chunks": [chunks1, chunks2],
|
||||
"embedding": [(token_count1, vector_size1)],
|
||||
...
|
||||
})
|
||||
|
||||
# Intercept a method call
|
||||
result = interceptor.intercept("build_chunks") # Returns chunks1
|
||||
result = interceptor.intercept("build_chunks") # Returns chunks2
|
||||
"""
|
||||
|
||||
def __init__(self, recorded_values: Dict[str, List[Any]]):
|
||||
"""Initialize the interceptor with pre-recorded values.
|
||||
|
||||
Args:
|
||||
recorded_values: A dictionary where keys are method names and
|
||||
values are lists of pre-recorded return values. Each call
|
||||
to intercept() will pop and return the first value from
|
||||
the corresponding list.
|
||||
|
||||
Note:
|
||||
If a key from ALLOWED_METHOD_NAMES is not in recorded_values,
|
||||
it will be initialized with an empty list. This allows the
|
||||
interceptor to be created even if not all methods have recorded
|
||||
values, and it will fall through to original execution when
|
||||
no recorded values are available.
|
||||
"""
|
||||
self._recorded_values: Dict[str, List[Any]] = dict()
|
||||
for key in ALLOWED_METHOD_NAMES:
|
||||
self._recorded_values[key] = list(recorded_values.get(key, []))
|
||||
|
||||
def intercept(self, method_name: str, default_value = _NO_DEFAULT) -> Any:
|
||||
"""Intercept a method call and return the next pre-recorded value.
|
||||
|
||||
Args:
|
||||
method_name: Name of the method being intercepted.
|
||||
default_value: default value
|
||||
|
||||
Returns:
|
||||
The next pre-recorded return value for this method.
|
||||
|
||||
Raises:
|
||||
ValueError: If method_name is not in the allowed method names set.
|
||||
KeyError: If method_name has no recorded values list.
|
||||
IndexError: If the recorded values list for method_name is empty.
|
||||
"""
|
||||
if method_name not in ALLOWED_METHOD_NAMES:
|
||||
raise ValueError(
|
||||
f"Cannot intercept method '{method_name}'. "
|
||||
f"Allowed method names: {ALLOWED_METHOD_NAMES}"
|
||||
)
|
||||
|
||||
if method_name not in self._recorded_values:
|
||||
raise KeyError(f"No recorded values found for method '{method_name}'")
|
||||
|
||||
values_list = self._recorded_values[method_name]
|
||||
if not values_list:
|
||||
if default_value is not _NO_DEFAULT:
|
||||
logging.info(f"return default value for {method_name}")
|
||||
return default_value
|
||||
raise IndexError(f"No more recorded values for method '{method_name}'")
|
||||
|
||||
return values_list.pop(0)
|
||||
|
||||
|
||||
def remaining_count(self, method_name: str) -> int:
|
||||
"""Get the number of remaining recorded values for a method.
|
||||
|
||||
Args:
|
||||
method_name: Name of the method to check.
|
||||
|
||||
Returns:
|
||||
Number of remaining recorded values.
|
||||
"""
|
||||
if method_name not in self._recorded_values:
|
||||
return 0
|
||||
return len(self._recorded_values[method_name])
|
||||
|
||||
|
||||
def remaining_values(self):
|
||||
return {k: list(v) for k, v in self._recorded_values.items()}
|
||||
|
||||
def remaining_values_count(self):
|
||||
return sum(len(values) for values in self._recorded_values.values())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"WriteOperationInterceptor(total_recorded={self._recorded_values})"
|
||||
@@ -33,7 +33,7 @@ test_image = base64.b64decode(test_image_base64)
|
||||
async def image2id(d: dict, storage_put_func: partial, objname: str, bucket: str = "imagetemps"):
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from rag.svr.task_executor import minio_limiter
|
||||
from rag.svr.task_executor_limiter import minio_limiter
|
||||
|
||||
if "image" not in d:
|
||||
return
|
||||
|
||||
@@ -62,16 +62,28 @@ def _as_extra_dict(extra) -> dict:
|
||||
if isinstance(extra, dict):
|
||||
return extra
|
||||
if isinstance(extra, str) and extra:
|
||||
# Try standard JSON first (double quotes)
|
||||
try:
|
||||
parsed = json.loads(extra)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.warning(
|
||||
"Ignoring malformed RAPTOR extra payload while collecting chunk metadata: %s",
|
||||
extra[:200],
|
||||
exc_info=True,
|
||||
)
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
last_exc = True
|
||||
|
||||
# Fallback: try parsing Python dict literal (single quotes)
|
||||
try:
|
||||
import ast
|
||||
parsed = ast.literal_eval(extra)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (ValueError, SyntaxError):
|
||||
last_exc = True
|
||||
|
||||
logging.warning(
|
||||
"Ignoring malformed RAPTOR extra payload while collecting chunk metadata: %s",
|
||||
extra[:200],
|
||||
exc_info=last_exc,
|
||||
)
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user