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