feat(data-process): 接入三种文档切分引擎

This commit is contained in:
caoxiaozhu
2026-07-25 18:00:21 +08:00
parent 4782981169
commit ea08478a37
9 changed files with 667 additions and 716 deletions

View File

@@ -30,13 +30,10 @@ from psycopg.rows import dict_row
from app.modules.data_process.algorithms import (
ParsedText,
TextChunk,
canonical_record_json,
chunk_unstructured,
content_quality_flags,
desensitize_pii,
desensitize_structured_record,
detect_document_structure,
detect_pdf_document_noise,
estimate_token_count,
extract_pdf_page_texts,
@@ -48,6 +45,13 @@ from app.modules.data_process.algorithms import (
remove_document_noise,
score_quality,
)
from app.modules.data_process.document_chunking import (
DocumentChunk,
chunk_fixed_text,
chunk_layout_document,
chunk_semantic_text,
merge_short_chunks,
)
from app.modules.data_process.generation import generate_model_records
from app.modules.data_process.storage import (
LocalDataProcessStorage,
@@ -238,152 +242,58 @@ def _parse_stored_source(source: dict[str, Any]) -> ParsedText:
)
def _shift_chunk(chunk: TextChunk, offset: int, source_text: str) -> TextChunk:
start = offset + chunk.start
end = offset + chunk.end
return TextChunk(
content=chunk.content,
start=start,
end=end,
start_line=source_text.count("\n", 0, start) + 1,
end_line=source_text.count("\n", 0, max(start, end - 1)) + 1,
token_count=chunk.token_count,
)
def _merge_short_chunks(
chunks: list[TextChunk],
source_text: str,
*,
min_token_count: int,
chunk_size: int,
) -> list[TextChunk]:
"""按原文顺序合并相邻短块,同时严格遵守切片大小上限。"""
merged: list[TextChunk] = []
index = 0
while index < len(chunks):
current = chunks[index]
if current.token_count >= min_token_count:
merged.append(current)
index += 1
continue
candidates: list[tuple[int, int, int]] = []
if merged:
candidates.append((merged[-1].start, current.end, -1))
if index + 1 < len(chunks):
candidates.append((current.start, chunks[index + 1].end, 1))
selected = next(
(
(start, end, direction)
for start, end, direction in candidates
if estimate_token_count(source_text[start:end]) <= chunk_size
),
None,
)
if selected is None:
merged.append(current)
index += 1
continue
start, end, direction = selected
combined = TextChunk(
content=source_text[start:end],
start=start,
end=end,
start_line=source_text.count("\n", 0, start) + 1,
end_line=source_text.count("\n", 0, max(start, end - 1)) + 1,
token_count=estimate_token_count(source_text[start:end]),
)
if direction < 0:
merged[-1] = combined
index += 1
else:
merged.append(combined)
index += 2
return merged
def _chunk_source_text(
text: str,
source: dict[str, Any],
config: dict[str, Any],
preprocess_options: set[str],
) -> list[tuple[TextChunk, tuple[str, ...]]]:
"""按可选文档结构分段后切片,结构边界之间不共享 overlap"""
) -> list[DocumentChunk]:
"""根据任务配置调用真实的 Docling/LlamaIndex 切分器"""
method = str(_value(config, "chunk_method", "chunkMethod", "structure"))
detect_structure = (
method == "structure" or "detect_document_structure" in preprocess_options
)
method = str(_value(config, "chunk_method", "chunkMethod", "layout_hybrid"))
preserve_context = "preserve_context" in preprocess_options
merge_short = "merge_short_content" in preprocess_options
chunk_size = int(_value(config, "chunk_size", "chunkSize", 800))
configured_minimum = int(_value(config, "min_chunk_size", "minChunkSize", 100))
minimum = configured_minimum if merge_short else 1
overlap = (
int(_value(config, "chunk_overlap", "chunkOverlap", 100))
if preserve_context
else 0
)
common = {
"method": method,
"chunk_size": chunk_size,
"chunk_overlap": overlap,
"min_chunk_size": minimum,
"custom_delimiter": str(
_value(config, "custom_delimiter", "customDelimiter", "") or ""
),
"preserve_code_blocks": bool(
_value(config, "preserve_code_blocks", "preserveCodeBlocks", False)
),
"preserve_tables": bool(
_value(config, "preserve_tables", "preserveTables", False)
),
"preserve_lists": bool(
_value(config, "preserve_lists", "preserveLists", False)
),
}
sections: list[tuple[int, int, tuple[str, ...]]] = [(0, len(text), ())]
if detect_structure:
structure = detect_document_structure(text)
if structure.headings:
sections = []
first_start = structure.headings[0].start
if first_start > 0 and text[:first_start].strip():
sections.append((0, first_start, ()))
stack: list[tuple[int, str]] = []
for index, heading in enumerate(structure.headings):
while stack and stack[-1][0] >= heading.level:
stack.pop()
stack.append((heading.level, heading.title))
end = (
structure.headings[index + 1].start
if index + 1 < len(structure.headings)
else len(text)
)
sections.append((heading.start, end, tuple(title for _, title in stack)))
result: list[tuple[TextChunk, tuple[str, ...]]] = []
for start, end, heading_path in sections:
section_text = text[start:end]
local_chunks = chunk_unstructured(section_text, **common)
shifted = [_shift_chunk(chunk, start, text) for chunk in local_chunks]
result.extend((chunk, heading_path) for chunk in shifted)
if merge_short and result:
# 结构分段只负责提供标题路径和隔离 overlap不应让目录项或短小节
# 突破 min_chunk_size 约束。合并后保留首个原始块的标题路径。
heading_paths = {chunk.start: heading_path for chunk, heading_path in result}
merged = _merge_short_chunks(
[chunk for chunk, _ in result],
text,
min_token_count=configured_minimum,
text = str(source.get("content") or "")
if method == "layout_hybrid":
raw = source.get("raw_content")
if not isinstance(raw, bytes):
raise InvalidStateError("版面结构混合切分需要原始文件,请重新上传后再处理")
chunks = chunk_layout_document(
raw,
filename=str(source.get("name") or "document.pdf"),
source_text=text,
chunk_size=chunk_size,
)
result = [(chunk, heading_paths.get(chunk.start, ())) for chunk in merged]
return result
elif method == "semantic":
chunks = chunk_semantic_text(
text,
chunk_size=chunk_size,
chunk_overlap=overlap,
breakpoint_percentile_threshold=int(
_value(
config,
"semantic_breakpoint_percentile",
"semanticBreakpointPercentile",
95,
)
),
)
elif method == "fixed":
chunks = chunk_fixed_text(text, chunk_size=chunk_size, chunk_overlap=overlap)
else:
raise ValueError(f"unsupported chunk method: {method}")
if "merge_short_content" in preprocess_options:
chunks = merge_short_chunks(
chunks,
source_text=text,
min_token_count=int(_value(config, "min_chunk_size", "minChunkSize", 100)),
max_token_count=chunk_size,
)
return chunks
_NEGATION_MARKERS = frozenset({"", "", "", "", "没有", "并非", "not", "no", "never"})
@@ -456,23 +366,27 @@ def _build_preview_items(
parsed = _parse_stored_source(source)
if process_type == "unstructured":
document_noise_spans = tuple(source.get("document_noise_spans") or ())
chunks = _chunk_source_text(parsed.text, config, preprocess_options)
for chunk, heading_path in chunks:
chunks = _chunk_source_text(source, config, preprocess_options)
for chunk in chunks:
content = (
remove_document_noise(
chunk.content,
chunk.contextualized_content,
document_noise_spans,
source_offset=chunk.start,
source_offset=chunk.source_start or 0,
)
if should_clean_invalid and document_noise_spans
else chunk.content
if (
should_clean_invalid
and document_noise_spans
and chunk.source_start is not None
)
else chunk.contextualized_content
)
preprocess_flags = content_quality_flags(
content,
min_chars=0,
min_tokens=0,
)
if content != chunk.content:
if content != chunk.contextualized_content:
preprocess_flags = (*preprocess_flags, "document_noise_removed")
flag_set = set(preprocess_flags)
if "clean_invalid_content" in preprocess_options and flag_set & {
@@ -508,25 +422,28 @@ def _build_preview_items(
quality = _preview_quality(content, config)
quality["pii_replacements"] = pii_counts
quality["preprocess_flags"] = list(preprocess_flags)
chunk_method = str(
_value(config, "chunk_method", "chunkMethod", "structure")
quality["chunk_method"] = str(
_value(config, "chunk_method", "chunkMethod", "layout_hybrid")
)
if (
chunk_method == "structure"
or "detect_document_structure" in preprocess_options
):
quality["heading_path"] = list(heading_path)
quality["heading_path"] = list(chunk.heading_path)
quality["source_pages"] = list(chunk.source_pages)
quality["doc_item_refs"] = list(chunk.doc_item_refs)
quality["source_bboxes"] = list(chunk.source_bboxes)
append_item(
{
"source_file_id": source["id"],
"original_content": chunk.content,
"original_content": chunk.original_content,
"edited_content": content,
"source_start": chunk.start,
"source_end": chunk.end,
"source_start_line": chunk.start_line,
"source_end_line": chunk.end_line,
"source_start": chunk.source_start,
"source_end": chunk.source_end,
"source_start_line": chunk.source_start_line,
"source_end_line": chunk.source_end_line,
"token_count": estimate_token_count(content),
"status": "modified" if content != chunk.content else "original",
"status": (
"modified"
if content != chunk.original_content
else "original"
),
"quality_score": quality,
}
)
@@ -1265,13 +1182,21 @@ def _prepare_preview_items(
]
if not sources:
raise InvalidStateError("at least one source file is required")
preprocess_options = _preprocess_options(task.get("config") or {})
if (
task.get("process_type") == "unstructured"
and preprocess_options & {"clean_invalid", "clean_invalid_content"}
config = task.get("config") or {}
preprocess_options = _preprocess_options(config)
chunk_method = str(
_value(config, "chunk_method", "chunkMethod", "layout_hybrid")
)
is_unstructured = task.get("process_type") == "unstructured"
if is_unstructured and (
chunk_method == "layout_hybrid"
or preprocess_options & {"clean_invalid", "clean_invalid_content"}
):
for index, source in enumerate(sources):
if str(source.get("file_format") or "").lower() != "pdf":
if (
chunk_method != "layout_hybrid"
and str(source.get("file_format") or "").lower() != "pdf"
):
continue
storage_object_id = str(source.get("storage_object_id") or "")
actual_size = storage.file_size(
@@ -1280,10 +1205,10 @@ def _prepare_preview_items(
expected_source_file_id=str(source["id"]),
)
if actual_size is None:
logger.info(
"skip PDF document noise detection for unavailable legacy source %s",
source["id"],
)
if chunk_method == "layout_hybrid":
raise InvalidStateError(
"版面结构混合切分无法读取原始文件,请重新上传后再处理"
)
continue
expected_size = int(source.get("size_bytes") or 0)
if expected_size and actual_size != expected_size:
@@ -1296,6 +1221,13 @@ def _prepare_preview_items(
expected_size=actual_size,
)
)
enriched = dict(source)
if chunk_method == "layout_hybrid":
enriched["raw_content"] = raw
sources[index] = enriched
continue
if str(source.get("file_format") or "").lower() != "pdf":
continue
pages = extract_pdf_page_texts(raw)
extracted_text = "\n\n".join(page.text for page in pages if page.text)
if extracted_text != str(source.get("content") or ""):
@@ -1304,7 +1236,6 @@ def _prepare_preview_items(
source["id"],
)
continue
enriched = dict(source)
enriched["document_noise_spans"] = detect_pdf_document_noise(pages)
sources[index] = enriched
items = _build_preview_items(task, sources)