RICS / app /chunking /splitter.py
StormShadow308's picture
Ship production RAG hardening: citation extraction, full-library retrieval, auth.
865bc90
Raw
History Blame Contribute Delete
5.44 kB
"""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