| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- """文档三级分块服务(接收已解析文本,不直接读取文件)"""
- import re
- import unicodedata
- from typing import Dict, List
- from backend.indexing.semantic_chunker import SemanticChunker
- # 编译非打印 C0/C1 控制字符的正则(保留常规排版字:\t, \n, \r)
- _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
- # 编译零宽字符和不可见格式化控制字符(零宽空白、BOM 标记、左右强排标志等)
- _INVISIBLE_CHAR_RE = re.compile(r"[\u200b-\u200d\ufeff\u200f\u202a-\u202e]")
- def sanitize_text(text: str) -> str:
- """
- 企业级标准文本净化器 (Text Sanitizer)。
- 1. 规范化 (Normalization):统一转换为标准 NFC 格式。
- 2. 剔除/替换不合法及不可见字节:过滤 NUL、零宽字符、BOM 标签等。
- 3. 清洗非打印字符及乱码:剔除 C0/C1 控制符号,剥离 Unicode PUA 私有使用区乱码符号。
- 4. 编码收敛防爆:利用 utf-8 ignore 安全剥离孤立的 UTF-16 代理项。
- """
- if not text:
- return ""
- text = unicodedata.normalize("NFC", text)
- text = _INVISIBLE_CHAR_RE.sub("", text)
- text = _CONTROL_CHAR_RE.sub("", text)
- text = re.sub(r"[\ue000-\uf8ff]", "", text)
- try:
- cleaned = text.encode("utf-8", "ignore").decode("utf-8", "ignore")
- except Exception:
- chars = []
- for char in text:
- if 0xD800 <= ord(char) <= 0xDFFF:
- continue
- chars.append(char)
- cleaned = "".join(chars)
- return cleaned
- # 分隔符按语义粒度分组,越靠前越优先作为分块边界。
- # 切分时保留分隔符作为独立原子,保证拼接后原文不变。
- _SENTENCE_SEPARATORS = ["\n\n", "。", "!", "?", ";", "\n"]
- _CLAUSE_SEPARATORS = [",", "、"]
- _WORD_SEPARATORS = [" ", ""]
- DEFAULT_SEPARATORS = _SENTENCE_SEPARATORS + _CLAUSE_SEPARATORS + _WORD_SEPARATORS
- class SemanticTextSplitter:
- """
- 语义优先的文本分块器。
- 核心保证:
- 1. 分块后所有 chunk 按顺序拼接,等于原始输入。
- 2. 每个 chunk 的末尾尽量落在语义边界(段落 > 句子 > 子句 > 词)。
- 3. 重叠区只在边界处截取,不会从句子中间截断。
- """
- def __init__(
- self,
- chunk_size: int = 800,
- chunk_overlap: int = 100,
- separators: List[str] | None = None,
- use_semantic_chunking: bool = False,
- semantic_similarity_threshold: float = 0.6,
- ):
- self.chunk_size = chunk_size
- self.chunk_overlap = chunk_overlap
- self.separators = separators or DEFAULT_SEPARATORS
- self.use_semantic_chunking = use_semantic_chunking
- self.semantic_chunker = None
- if use_semantic_chunking:
- # overlap 在语义段落边界处由 SemanticChunker 处理,保证承上启下句子完整
- self.semantic_chunker = SemanticChunker(
- similarity_threshold=semantic_similarity_threshold,
- max_chunk_size=chunk_size,
- chunk_overlap=chunk_overlap,
- )
- def split_text(self, text: str) -> List[str]:
- """对单段文本执行分块。"""
- text = sanitize_text(text)
- if not text:
- return []
- if len(text) <= self.chunk_size:
- return [text]
- # 若启用语义分块,先按话题边界粗分,再在每个语义段落内做规则细分。
- # 保证所有段落拼接仍等于原文。
- if self.use_semantic_chunking and self.semantic_chunker is not None:
- semantic_chunks = self.semantic_chunker.split_text(text)
- result: List[str] = []
- for chunk in semantic_chunks:
- if len(chunk) <= self.chunk_size:
- result.append(chunk)
- else:
- atoms = self._split_into_atoms(chunk)
- result.extend(self._merge_atoms(atoms))
- return result
- # 未启用语义分块:按标点规则切分。
- atoms = self._split_into_atoms(text)
- return self._merge_atoms(atoms)
- def _split_into_atoms(self, text: str) -> List[str]:
- """
- 递归地在最粗可用的分隔符处切分文本,保留分隔符作为独立原子。
- 例如 "。" 切分后,标点本身会作为一个原子项,保证后续拼接不丢标点。
- """
- return self._split_recursive(text, self.separators.copy())
- def _split_recursive(self, text: str, separators: List[str]) -> List[str]:
- if len(text) <= self.chunk_size or not separators:
- return [text] if text else []
- separator = separators.pop(0)
- if separator == "":
- # 兜底:按字符切分
- return list(text)
- parts = text.split(separator)
- if len(parts) <= 1:
- return self._split_recursive(text, separators)
- result: List[str] = []
- for i, part in enumerate(parts):
- if i > 0:
- # 分隔符本身作为独立原子保留
- result.append(separator)
- if not part:
- continue
- if len(part) > self.chunk_size:
- result.extend(self._split_recursive(part, separators.copy()))
- else:
- result.append(part)
- return result
- def _merge_atoms(self, atoms: List[str]) -> List[str]:
- """
- 把原子片段合并成接近 chunk_size 的块。
- 合并规则:
- 1. 原子不可拆分;
- 2. 中间 chunk 的末尾尽量落在语义分隔符上;
- 3. 因回退而移出当前 chunk 的非边界原子,会保留到下一个 chunk 中,保证原文不丢失;
- 4. 若启用 overlap,下一个 chunk 以当前 chunk 末尾的边界感知重叠区开头。
- """
- if not atoms:
- return []
- chunks: List[str] = []
- current: List[str] = []
- current_len = 0
- def last_sep_index(seq: List[str]) -> int:
- for idx in range(len(seq) - 1, -1, -1):
- if seq[idx] in self.separators:
- return idx
- return -1
- for atom in atoms:
- atom_len = len(atom)
- # 加入当前原子会超过 chunk_size,则先结束当前 chunk
- if current and current_len + atom_len > self.chunk_size:
- sep_idx = last_sep_index(current)
- if sep_idx >= 0:
- chunk_atoms = current[: sep_idx + 1]
- pending = current[sep_idx + 1 :] + [atom]
- else:
- # 当前窗口内没有可用分隔符,直接结束整个窗口
- chunk_atoms = current
- pending = [atom]
- chunks.append("".join(chunk_atoms))
- if self.chunk_overlap > 0 and chunk_atoms:
- overlap_atoms = self._extract_overlap_atoms(chunk_atoms)
- current = overlap_atoms + pending
- else:
- current = pending
- current_len = sum(len(a) for a in current)
- continue
- current.append(atom)
- current_len += atom_len
- if current:
- chunks.append("".join(current))
- return chunks
- def _extract_overlap_atoms(self, atoms: List[str]) -> List[str]:
- """
- 从一组原子末尾回退,截取不超过 chunk_overlap 且以语义边界结尾的重叠区。
- 优先让整个重叠区以句子或子句分隔符结尾。
- """
- if self.chunk_overlap <= 0 or not atoms:
- return []
- overlap: List[str] = []
- length = 0
- for atom in reversed(atoms):
- overlap.insert(0, atom)
- length += len(atom)
- if length >= self.chunk_overlap:
- break
- # 让 overlap 以语义边界结束
- while len(overlap) > 1:
- if overlap[-1] in self.separators:
- break
- removed_len = len(overlap[-1])
- if length - removed_len >= self.chunk_overlap // 3:
- length -= removed_len
- overlap.pop()
- else:
- break
- return overlap
- @staticmethod
- def _atoms_length(atoms: List[str]) -> int:
- return sum(len(a) for a in atoms)
- class HierarchicalTextSplitter:
- """三级层次化分块(L1 / L2 / L3)"""
- def __init__(self, chunk_size: int = 800, chunk_overlap: int = 100):
- # L1 是对原文的粗粒度切分,L1 之间不允许 overlap,保证所有 L1 拼接等于原文。
- # L2/L3 在父级内部进行切分,启用基于 embedding 相似度的语义边界;
- # 允许 L2/L3 在语义边界处保留 overlap,使承上启下的句子同时出现在前后块中。
- level_1_size = max(2000, chunk_size * 3)
- level_2_size = max(1000, chunk_size * 2)
- level_3_size = max(600, chunk_size)
- self._splitter_level_1 = SemanticTextSplitter(
- chunk_size=level_1_size,
- chunk_overlap=0,
- separators=DEFAULT_SEPARATORS,
- use_semantic_chunking=False,
- )
- self._splitter_level_2 = SemanticTextSplitter(
- chunk_size=level_2_size,
- chunk_overlap=chunk_overlap,
- separators=DEFAULT_SEPARATORS,
- use_semantic_chunking=True,
- )
- self._splitter_level_3 = SemanticTextSplitter(
- chunk_size=level_3_size,
- chunk_overlap=chunk_overlap,
- separators=DEFAULT_SEPARATORS,
- use_semantic_chunking=True,
- )
- @staticmethod
- def _build_chunk_id(document_id: int, level: int, index: int) -> str:
- return f"{document_id}::l{level}::{index}"
- def split_text(
- self,
- text: str,
- document_id: int,
- filename: str = "",
- file_type: str = "",
- file_path: str = "",
- page_number: int = 0,
- ) -> List[Dict]:
- """对单段文本执行三级分块,返回 L1/L2/L3 全部分块。"""
- text = sanitize_text(text)
- if not text:
- return []
- base_doc = {
- "filename": sanitize_text(filename),
- "file_path": sanitize_text(file_path),
- "file_type": sanitize_text(file_type),
- "page_number": int(page_number),
- }
- root_chunks: List[Dict] = []
- level_1_counter = 0
- level_2_counter = 0
- level_3_counter = 0
- chunk_idx = 0
- level_1_texts = self._splitter_level_1.split_text(text)
- for level_1_text in level_1_texts:
- if not level_1_text:
- continue
- level_1_id = self._build_chunk_id(document_id, 1, level_1_counter)
- level_1_counter += 1
- level_1_chunk = {
- **base_doc,
- "text": level_1_text,
- "chunk_id": level_1_id,
- "parent_chunk_id": "",
- "root_chunk_id": level_1_id,
- "chunk_level": 1,
- "chunk_idx": chunk_idx,
- }
- chunk_idx += 1
- root_chunks.append(level_1_chunk)
- level_2_texts = self._splitter_level_2.split_text(level_1_text)
- for level_2_text in level_2_texts:
- if not level_2_text:
- continue
- level_2_id = self._build_chunk_id(document_id, 2, level_2_counter)
- level_2_counter += 1
- level_2_chunk = {
- **base_doc,
- "text": level_2_text,
- "chunk_id": level_2_id,
- "parent_chunk_id": level_1_id,
- "root_chunk_id": level_1_id,
- "chunk_level": 2,
- "chunk_idx": chunk_idx,
- }
- chunk_idx += 1
- root_chunks.append(level_2_chunk)
- level_3_texts = self._splitter_level_3.split_text(level_2_text)
- for level_3_text in level_3_texts:
- if not level_3_text:
- continue
- level_3_id = self._build_chunk_id(document_id, 3, level_3_counter)
- level_3_counter += 1
- root_chunks.append({
- **base_doc,
- "text": level_3_text,
- "chunk_id": level_3_id,
- "parent_chunk_id": level_2_id,
- "root_chunk_id": level_1_id,
- "chunk_level": 3,
- "chunk_idx": chunk_idx,
- })
- chunk_idx += 1
- return root_chunks
|