Spaces:
Runtime error
Runtime error
File size: 5,435 Bytes
49f0cfb dc1b199 49f0cfb dc1b199 865bc90 dc1b199 49f0cfb dc1b199 49f0cfb 865bc90 dc1b199 49f0cfb dc1b199 865bc90 dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb 865bc90 dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb dc1b199 49f0cfb 865bc90 49f0cfb 865bc90 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | """Text chunking via LangChain RecursiveCharacterTextSplitter.
Replaces the custom recursive splitter with the standard LangChain
implementation backed by tiktoken (``cl100k_base`` encoding).
The splitter receives a list of LangChain ``Document`` objects (from the
parsers) and returns a new list of ``Document`` objects with split content.
Each output document inherits the source document's metadata.
Compared to master branch:
- ✅ Less code — delegates token counting to LangChain / tiktoken
- ✅ Standard interface: ``split_documents(docs)`` is the LangChain pattern
- ✅ Accurate token-based chunk sizing via tiktoken
- ✅ Metadata automatically propagated from parent documents
"""
import logging
import re
import tiktoken
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from app.config import settings
from app.ingest.ocr_normalize import TABLE_CLOSE, TABLE_OPEN
logger = logging.getLogger(__name__)
# Shared tiktoken encoding used for both splitting and prompt token counting
_ENCODING_NAME = "cl100k_base"
# Sentence-aware separator ladder: the recursive splitter tries these in order,
# so it prefers paragraph -> sentence boundaries and only falls back to word /
# character splits when a single sentence exceeds the token budget. This is what
# keeps chunks from being cut mid-sentence.
_SEPARATORS = ["\n\n", "\n", ". ", "? ", "! ", "; ", ", ", " ", ""]
_TABLE_BLOCK_RE = re.compile(
re.escape(TABLE_OPEN) + r".*?" + re.escape(TABLE_CLOSE),
re.DOTALL,
)
def count_tokens(text: str) -> int:
"""Count the number of tiktoken tokens in ``text``.
Used by ``app.generator.prompts`` to enforce context-window budgets.
Args:
text: Any plain-text string.
Returns:
Integer token count.
Example::
n = count_tokens("The property is a two-storey terrace.")
"""
enc = tiktoken.get_encoding(_ENCODING_NAME)
return len(enc.encode(text))
def build_splitter(
chunk_size: int | None = None,
chunk_overlap: int | None = None,
) -> RecursiveCharacterTextSplitter:
"""Create a tiktoken-backed :class:`RecursiveCharacterTextSplitter`.
Args:
chunk_size: Token count per chunk. Defaults to ``settings.chunk_size``.
chunk_overlap: Token overlap between consecutive chunks.
Defaults to ``settings.chunk_overlap``.
Returns:
Configured :class:`RecursiveCharacterTextSplitter`.
Example::
splitter = build_splitter(chunk_size=500, chunk_overlap=75)
chunks = splitter.split_documents(docs)
"""
return RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name=_ENCODING_NAME,
chunk_size=chunk_size or settings.chunk_size,
chunk_overlap=chunk_overlap or settings.chunk_overlap,
separators=_SEPARATORS,
)
def split_documents(
documents: list[Document],
chunk_size: int | None = None,
chunk_overlap: int | None = None,
) -> list[Document]:
"""Split ``documents`` into token-bounded chunks.
Each output ``Document`` inherits the metadata of its parent and has its
``page_content`` set to the chunk text. No additional metadata is added
here — the caller (pipeline) is responsible for injecting ``chunk_id``,
``doc_id``, ``tenant_id``, etc.
Args:
documents: Input LangChain :class:`Document` objects.
chunk_size: Override for ``settings.chunk_size``.
chunk_overlap: Override for ``settings.chunk_overlap``.
Returns:
List of chunk :class:`Document` objects.
Example::
docs = parse_pdf(path)
chunks = split_documents(docs)
# Each chunk has page_content ≤ settings.chunk_size tokens
"""
splitter = build_splitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
chunks: list[Document] = []
for doc in documents:
chunks.extend(_split_one(doc, splitter))
logger.debug(
"Split %d document(s) into %d chunks (size=%d, overlap=%d)",
len(documents),
len(chunks),
chunk_size or settings.chunk_size,
chunk_overlap or settings.chunk_overlap,
)
return chunks
def _split_one(
doc: Document, splitter: RecursiveCharacterTextSplitter
) -> list[Document]:
"""Split one document, keeping each ``[TABLE]…[/TABLE]`` block as one chunk.
Tables carry row/column meaning that must never be cut across chunk
boundaries, so they are emitted intact (tagged ``section_type='table'``)
while the surrounding prose is split sentence-aware. Document order is
preserved.
"""
content = doc.page_content or ""
if TABLE_OPEN not in content:
return splitter.split_documents([doc])
out: list[Document] = []
cursor = 0
for m in _TABLE_BLOCK_RE.finditer(content):
prose = content[cursor:m.start()].strip()
if prose:
out.extend(
splitter.split_documents([Document(page_content=prose, metadata=dict(doc.metadata))])
)
table_meta = dict(doc.metadata)
table_meta["section_type"] = "table"
out.append(Document(page_content=m.group(0).strip(), metadata=table_meta))
cursor = m.end()
tail = content[cursor:].strip()
if tail:
out.extend(
splitter.split_documents([Document(page_content=tail, metadata=dict(doc.metadata))])
)
return out
|