| """ |
| TextChunker — 文本文档分块。 |
| |
| 使用 LangChain 的 RecursiveCharacterTextSplitter 进行语义边界感知的分块。 |
| """ |
|
|
| import re |
| import uuid |
| from typing import Dict, List, Optional |
|
|
|
|
| class TextChunker: |
| """文本文档分块器。 |
| |
| 使用递归字符分割, 优先在段落/句子边界处分块, |
| 保持语义完整性的同时控制块大小。 |
| |
| Usage: |
| chunker = TextChunker(chunk_size=512, chunk_overlap=64) |
| chunks = chunker.chunk("长文本内容...", metadata={"source": "doc.txt"}) |
| """ |
|
|
| def __init__( |
| self, |
| chunk_size: int = 512, |
| chunk_overlap: int = 64, |
| separators: Optional[List[str]] = None, |
| ): |
| self.chunk_size = chunk_size |
| self.chunk_overlap = chunk_overlap |
| self.separators = separators or [ |
| "\n\n", |
| "\n", |
| "。", |
| ". ", |
| "? ", |
| "! ", |
| ";", |
| "; ", |
| ",", |
| ", ", |
| " ", |
| "", |
| ] |
|
|
| def chunk( |
| self, |
| text: str, |
| metadata: Optional[dict] = None, |
| source_file: Optional[str] = None, |
| ) -> List[dict]: |
| """将文本分割为块。 |
| |
| Args: |
| text: 输入文本。 |
| metadata: 附加元数据 (合并到每个 chunk)。 |
| source_file: 来源文件路径。 |
| |
| Returns: |
| chunk 字典列表, 每个包含: |
| - chunk_id: 唯一 ID |
| - text: 块文本 |
| - metadata: 元数据 (含 source_file, chunk_index 等) |
| - modality: "text" |
| """ |
| try: |
| from langchain_text_splitters import RecursiveCharacterTextSplitter |
| except ImportError: |
| raise ImportError( |
| "需要 langchain-text-splitters。请安装: pip install langchain-text-splitters" |
| ) |
|
|
| splitter = RecursiveCharacterTextSplitter( |
| chunk_size=self.chunk_size, |
| chunk_overlap=self.chunk_overlap, |
| separators=self.separators, |
| length_function=len, |
| is_separator_regex=False, |
| ) |
|
|
| docs = splitter.create_documents( |
| texts=[text], |
| metadatas=[metadata or {}], |
| ) |
|
|
| chunks = [] |
| for i, doc in enumerate(docs): |
| chunks.append({ |
| "chunk_id": str(uuid.uuid4()), |
| "text": doc.page_content.strip(), |
| "metadata": { |
| "source_file": source_file or metadata.get("source", "unknown"), |
| "chunk_index": i, |
| "chunk_count": len(docs), |
| "modality": "text", |
| "content_type": "text_chunk", |
| **(metadata or {}), |
| }, |
| "modality": "text", |
| }) |
|
|
| return chunks |
|
|
| def chunk_with_token_limit( |
| self, |
| text: str, |
| max_tokens: int = 512, |
| metadata: Optional[dict] = None, |
| source_file: Optional[str] = None, |
| ) -> List[dict]: |
| """使用 token 计数的分块 (需要 tiktoken)。 |
| |
| Args: |
| text: 输入文本。 |
| max_tokens: 每块最大 token 数。 |
| metadata: 附加元数据。 |
| source_file: 来源文件路径。 |
| |
| Returns: |
| chunk 字典列表。 |
| """ |
| try: |
| import tiktoken |
| except ImportError: |
| raise ImportError("需要 tiktoken。请安装: pip install tiktoken") |
|
|
| try: |
| enc = tiktoken.get_encoding("cl100k_base") |
| except Exception: |
| enc = tiktoken.get_encoding("o200k_base") |
|
|
| sentences = self._split_sentences(text) |
| chunks = [] |
| current_chunk: List[str] = [] |
| current_tokens = 0 |
|
|
| for sentence in sentences: |
| sent_tokens = len(enc.encode(sentence)) |
|
|
| if current_tokens + sent_tokens > max_tokens and current_chunk: |
| chunk_text = "".join(current_chunk) |
| chunks.append({ |
| "chunk_id": str(uuid.uuid4()), |
| "text": chunk_text.strip(), |
| "metadata": { |
| "source_file": source_file or (metadata or {}).get("source", "unknown"), |
| "chunk_index": len(chunks), |
| "modality": "text", |
| "content_type": "text_chunk", |
| "token_count": current_tokens, |
| **(metadata or {}), |
| }, |
| "modality": "text", |
| }) |
| current_chunk = [] |
| current_tokens = 0 |
|
|
| |
| if self.chunk_overlap > 0: |
| overlap_sentences = self._estimate_overlap_sentences( |
| chunk_text, enc, self.chunk_overlap |
| ) |
| current_chunk = overlap_sentences |
| current_tokens = sum(len(enc.encode(s)) for s in current_chunk) |
|
|
| current_chunk.append(sentence) |
| current_tokens += sent_tokens |
|
|
| |
| if current_chunk: |
| chunk_text = "".join(current_chunk) |
| chunks.append({ |
| "chunk_id": str(uuid.uuid4()), |
| "text": chunk_text.strip(), |
| "metadata": { |
| "source_file": source_file or (metadata or {}).get("source", "unknown"), |
| "chunk_index": len(chunks), |
| "modality": "text", |
| "content_type": "text_chunk", |
| "token_count": current_tokens, |
| **(metadata or {}), |
| }, |
| "modality": "text", |
| }) |
|
|
| |
| for c in chunks: |
| c["metadata"]["chunk_count"] = len(chunks) |
|
|
| return chunks |
|
|
| @staticmethod |
| def _split_sentences(text: str) -> List[str]: |
| """简单的句子分割, 兼顾中英文。""" |
| |
| pattern = r"(?<=[.!?。!?;;])\s*" |
| sentences = re.split(pattern, text) |
| return [s for s in sentences if s.strip()] |
|
|
| @staticmethod |
| def _estimate_overlap_sentences( |
| chunk_text: str, enc, target_overlap_tokens: int |
| ) -> List[str]: |
| """估计重叠所需的句子数。""" |
| sentences = TextChunker._split_sentences(chunk_text) |
| overlap = [] |
| tokens = 0 |
| for s in reversed(sentences): |
| s_tokens = len(enc.encode(s)) |
| if tokens + s_tokens > target_overlap_tokens and overlap: |
| break |
| overlap.insert(0, s) |
| tokens += s_tokens |
| return overlap |
|
|