Spaces:
Sleeping
Sleeping
| """ | |
| Smart Document Chunking Service | |
| Sentence-aware text chunking with page number and section tracking. | |
| Replaces the crude word-count split (text.split()) with: | |
| - Sentence-boundary detection (no mid-sentence breaks) | |
| - Per-chunk page number list (e.g. [12, 13]) | |
| - Section/heading detection from document structure | |
| - Configurable overlap to preserve cross-boundary context | |
| """ | |
| import logging | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import List, Optional | |
| from app.config import settings | |
| from app.services.extractors import PageContent | |
| logger = logging.getLogger(__name__) | |
| # ── Data structures ──────────────────────────────────────────────────────────── | |
| class DocumentChunk: | |
| """A single text chunk with full provenance metadata.""" | |
| chunk_index: int | |
| text: str | |
| page_numbers: List[int] # Pages this chunk spans, e.g. [12, 13] | |
| section_title: Optional[str] # Nearest detected heading, e.g. "Safety Procedures" | |
| char_start: int = 0 # Character offset in full_text | |
| char_end: int = 0 | |
| # ── Heading detection patterns ───────────────────────────────────────────────── | |
| # Matches ALL-CAPS lines (≥4 chars), numbered sections (1.2.3), or markdown headings | |
| _HEADING_PATTERNS = [ | |
| re.compile(r"^#{1,4}\s+(.+)$", re.MULTILINE), # Markdown headings | |
| re.compile( | |
| r"^(\d+(?:\.\d+)*)\s+([A-Z][^\n]{3,60})$", re.MULTILINE | |
| ), # Numbered: "1.2 Section" | |
| re.compile(r"^([A-Z][A-Z\s\-]{4,60})$", re.MULTILINE), # ALL-CAPS headings | |
| ] | |
| # Sentence boundary: period/question/exclamation followed by space and capital (or end) | |
| _SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+(?=[A-Z\"])") | |
| # ── Markdown table handling ──────────────────────────────────────────────────── | |
| # | |
| # Tables arrive from pdf_layout.py as Markdown, optionally preceded by a | |
| # "[Table 1 on page 7]" label. They need separate treatment from prose for one | |
| # concrete reason: a Markdown table contains no sentence-ending punctuation, so | |
| # the sentence splitter treats an entire table as a single sentence and | |
| # _group_into_chunks emits it whole no matter how large it is. | |
| # | |
| # Measured before this fix: a 400-row table became one 14,703-character chunk. | |
| # That is roughly twice gemini-embedding-001's ~2048 token input limit, so most | |
| # of the table was silently truncated and never indexed at all. What did get | |
| # embedded was a single vector averaging 400 unrelated rows, which matches | |
| # nothing specifically. | |
| # | |
| # The fix splits long tables by rows and repeats the header on every part, so | |
| # each part stands alone: "| 6 to 8 ft | 60 in |" is only meaningful next to | |
| # "| Mining Height | Bolt Length |". | |
| _TABLE_LABEL = re.compile(r"^\[Table [^\]]*\]$") | |
| # Prose shorter than this immediately before a table is treated as its caption | |
| # and kept with the table rather than emitted as a standalone fragment. | |
| _LEAD_IN_MAX_CHARS = 400 | |
| _TABLE_BLOCK = re.compile( | |
| r"(?:^\[Table [^\]]*\][ \t]*\n)?" # optional "[Table 1 on page 7]" label | |
| r"^\|.*\|[ \t]*\n" # header row | |
| r"^\|[\s\-:|]+\|[ \t]*\n" # ---|--- separator row | |
| r"(?:^\|.*\|[ \t]*(?:\n|$))*", # data rows | |
| re.MULTILINE, | |
| ) | |
| def split_markdown_table(block: str, max_chars: int) -> List[str]: | |
| """ | |
| Split one Markdown table into parts that each fit within max_chars. | |
| Every part repeats the label, header row and separator, so a part retrieved | |
| on its own is still readable — a bare row of numbers is not an answer. | |
| Parts are labelled "(part i of n)" so the model can tell a split table from | |
| a complete one and does not report a partial list as exhaustive. | |
| A table already within the limit is returned unchanged, so small tables | |
| keep their existing single-chunk behaviour. | |
| """ | |
| lines = [ln for ln in block.strip().splitlines() if ln.strip()] | |
| if not lines: | |
| return [] | |
| label = None | |
| if _TABLE_LABEL.match(lines[0].strip()): | |
| label = lines[0].strip() | |
| lines = lines[1:] | |
| # Need at least a header, a separator and one data row to be worth splitting. | |
| if len(lines) < 3: | |
| return [block.strip()] | |
| header, separator, rows = lines[0], lines[1], lines[2:] | |
| if len(block) <= max_chars: | |
| return [block.strip()] | |
| def preamble(part_no: int, total: int) -> List[str]: | |
| head = [] | |
| if label: | |
| # "[Table 1 on page 7]" -> "[Table 1 on page 7, part 2 of 3]" | |
| head.append( | |
| f"{label[:-1]}, part {part_no} of {total}]" if total > 1 else label | |
| ) | |
| head.extend([header, separator]) | |
| return head | |
| # Two passes: the first packs rows to learn the part count, the second | |
| # rebuilds with accurate "part i of n" labels. Without this the label would | |
| # have to be written before the total is known. | |
| def pack(total_hint: int) -> List[List[str]]: | |
| groups: List[List[str]] = [] | |
| current: List[str] = [] | |
| budget = max_chars - len("\n".join(preamble(total_hint, total_hint))) - 1 | |
| for row in rows: | |
| row_len = len(row) + 1 | |
| if current and sum(len(r) + 1 for r in current) + row_len > budget: | |
| groups.append(current) | |
| current = [] | |
| current.append(row) | |
| if current: | |
| groups.append(current) | |
| return groups | |
| groups = pack(1) | |
| groups = pack(max(len(groups), 1)) # re-pack with the real label width | |
| total = len(groups) | |
| return [ | |
| "\n".join(preamble(i, total) + group) for i, group in enumerate(groups, start=1) | |
| ] | |
| class ChunkingService: | |
| """ | |
| Sentence-aware document chunker with page number tracking. | |
| Algorithm: | |
| 1. Split full text into sentences using regex boundary detection | |
| 2. Group sentences into chunks respecting max token size | |
| 3. Track which pages each chunk spans using char offsets | |
| 4. Detect section headings and annotate each chunk with nearest heading | |
| 5. Add configurable word overlap between adjacent chunks | |
| """ | |
| def __init__( | |
| self, | |
| chunk_size: int = None, # words per chunk | |
| chunk_overlap: int = None, # words of overlap | |
| min_chunk_words: int = 20, # skip chunks smaller than this | |
| ): | |
| self.chunk_size = chunk_size or settings.CHUNK_SIZE # default: 1000 | |
| self.chunk_overlap = chunk_overlap or settings.CHUNK_OVERLAP # default: 200 | |
| self.min_chunk_words = min_chunk_words | |
| # ── Public API ───────────────────────────────────────────────────────────── | |
| def chunk_document( | |
| self, | |
| full_text: str, | |
| pages: List[PageContent], | |
| ) -> List[DocumentChunk]: | |
| """ | |
| Split a document into annotated chunks. | |
| Args: | |
| full_text: Complete document text | |
| pages: Per-page content from extractor (for page number mapping) | |
| Returns: | |
| List of DocumentChunk objects with page_numbers and section_title | |
| """ | |
| if not full_text.strip(): | |
| return [] | |
| # Build page offset map: char_offset → page_number | |
| page_map = self._build_page_map(pages) | |
| # Extract section headings with their char positions | |
| headings = self._extract_headings(full_text) | |
| # Split into sentences | |
| sentences = self._split_sentences(full_text) | |
| if not sentences: | |
| return [] | |
| # Group sentences into word-count-bounded chunks with overlap | |
| raw_chunks = self._group_into_chunks(sentences, full_text) | |
| # Enforce the hard character ceiling that CHUNK_SIZE cannot: tables are | |
| # a single "sentence" and would otherwise be emitted at any size. | |
| raw_chunks = self._enforce_size_ceiling(raw_chunks) | |
| # Annotate each chunk with page numbers and section title | |
| chunks: List[DocumentChunk] = [] | |
| for idx, (chunk_text, char_start, char_end) in enumerate(raw_chunks): | |
| # Table parts are exempt from the minimum-words filter: a short | |
| # lookup table is meaningful even at a handful of words, and | |
| # dropping it would lose the data entirely. | |
| if len(chunk_text.split()) < self.min_chunk_words and "|" not in chunk_text: | |
| continue # Skip tiny fragments | |
| page_nums = self._get_page_numbers(char_start, char_end, page_map) | |
| section = self._get_section_title(char_start, headings) | |
| chunks.append( | |
| DocumentChunk( | |
| chunk_index=idx, | |
| text=chunk_text.strip(), | |
| page_numbers=page_nums, | |
| section_title=section, | |
| char_start=char_start, | |
| char_end=char_end, | |
| ) | |
| ) | |
| logger.info( | |
| f"Chunked document: {len(sentences)} sentences → {len(chunks)} chunks " | |
| f"(size={self.chunk_size} words, overlap={self.chunk_overlap} words)" | |
| ) | |
| return chunks | |
| # ── Internal helpers ─────────────────────────────────────────────────────── | |
| def _enforce_size_ceiling(self, raw_chunks: List[tuple]) -> List[tuple]: | |
| """ | |
| Break any chunk that exceeds MAX_CHUNK_CHARS. | |
| Runs as a post-pass rather than being folded into sentence grouping so | |
| that prose chunking behaviour is completely unchanged — only oversized | |
| chunks are touched, and in practice those are the table ones. | |
| A chunk may hold prose *and* a table, because the sentence splitter | |
| does not break on ".\\n\\n[Table 1 on page 7]" (the following character | |
| is '[', not a capital letter). Prose and table parts are therefore | |
| separated here and emitted in document order. | |
| All parts inherit the source chunk's char offsets. Page attribution | |
| stays correct — every part came from that same span — at the cost of | |
| page ranges being no narrower than the original chunk's. | |
| """ | |
| max_chars = settings.MAX_CHUNK_CHARS | |
| result: List[tuple] = [] | |
| for chunk_text, char_start, char_end in raw_chunks: | |
| if len(chunk_text) <= max_chars: | |
| result.append((chunk_text, char_start, char_end)) | |
| continue | |
| for part in self._split_oversized(chunk_text, max_chars): | |
| result.append((part, char_start, char_end)) | |
| return result | |
| def _split_oversized(self, text: str, max_chars: int) -> List[str]: | |
| """Split one oversized chunk into table parts and prose parts.""" | |
| parts: List[str] = [] | |
| cursor = 0 | |
| for match in _TABLE_BLOCK.finditer(text): | |
| prose = text[cursor : match.start()].strip() | |
| lead_in = "" | |
| if prose: | |
| if len(prose) <= _LEAD_IN_MAX_CHARS: | |
| # A short line before a table is almost always its | |
| # caption ("Roof bolt patterns shall conform to the | |
| # schedule below."). On its own it is under the | |
| # minimum-words filter and would be discarded, so it rides | |
| # with the first part of the table it introduces. | |
| lead_in = prose | |
| else: | |
| parts.extend(self._split_plain_text(prose, max_chars)) | |
| table_budget = max_chars - (len(lead_in) + 2 if lead_in else 0) | |
| table_parts = split_markdown_table(match.group(0), table_budget) | |
| if lead_in and table_parts: | |
| table_parts[0] = f"{lead_in}\n\n{table_parts[0]}" | |
| parts.extend(table_parts) | |
| cursor = match.end() | |
| trailing = text[cursor:].strip() | |
| if trailing: | |
| parts.extend(self._split_plain_text(trailing, max_chars)) | |
| # No table found — the chunk is just a very long run of prose. | |
| return parts or self._split_plain_text(text, max_chars) | |
| def _split_plain_text(self, text: str, max_chars: int) -> List[str]: | |
| """ | |
| Hard-split prose that has no usable sentence boundaries. | |
| Breaks on whitespace so words stay intact. Only reached for text the | |
| sentence splitter already failed to divide, so there is no better | |
| boundary available. | |
| """ | |
| text = text.strip() | |
| if len(text) <= max_chars: | |
| return [text] if text else [] | |
| parts: List[str] = [] | |
| current: List[str] = [] | |
| length = 0 | |
| for word in text.split(): | |
| if current and length + len(word) + 1 > max_chars: | |
| parts.append(" ".join(current)) | |
| current, length = [], 0 | |
| current.append(word) | |
| length += len(word) + 1 | |
| if current: | |
| parts.append(" ".join(current)) | |
| return parts | |
| def _split_sentences(self, text: str) -> List[str]: | |
| """Split text into sentences using regex boundary detection.""" | |
| # Replace common abbreviations that fool period detection | |
| text = re.sub( | |
| r"\b(Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|No|Vol|Fig)\.", r"\1<ABBR>", text | |
| ) | |
| # Split on sentence boundaries | |
| raw_sentences = _SENTENCE_BOUNDARY.split(text) | |
| # Restore abbreviation dots | |
| sentences = [ | |
| s.replace("<ABBR>", ".").strip() for s in raw_sentences if s.strip() | |
| ] | |
| return sentences | |
| def _group_into_chunks( | |
| self, | |
| sentences: List[str], | |
| full_text: str, | |
| ) -> List[tuple]: | |
| """ | |
| Group sentences into chunks respecting chunk_size with overlap. | |
| Returns list of (chunk_text, char_start, char_end) tuples. | |
| """ | |
| chunks = [] | |
| # Indices into `sentences`, not the strings themselves. Looking the | |
| # strings back up with sentences.index() was O(n) per flush and | |
| # returned the *first* match, so a document containing the same | |
| # sentence twice attributed the chunk to the wrong character offset — | |
| # and therefore the wrong page. | |
| current: List[int] = [] | |
| current_word_count = 0 | |
| # Precompute sentence char offsets in full_text | |
| sentence_offsets = self._compute_sentence_offsets(sentences, full_text) | |
| def flush(indices: List[int]) -> None: | |
| if not indices: | |
| return | |
| char_start = sentence_offsets[indices[0]][0] | |
| char_end = sentence_offsets[indices[-1]][1] | |
| chunks.append( | |
| (" ".join(sentences[j] for j in indices), char_start, char_end) | |
| ) | |
| i = 0 | |
| while i < len(sentences): | |
| word_count = len(sentences[i].split()) | |
| # A single sentence larger than chunk_size can never be packed. | |
| # It must be emitted on its own AND i must advance, or the loop | |
| # below spins forever: the else-branch flushes, restores a | |
| # non-empty overlap, and re-tests the same oversized sentence | |
| # against the same budget. That hung ingestion permanently on any | |
| # document with prose followed by a large table, because a | |
| # Markdown table has no sentence boundaries and merges into the | |
| # preceding sentence. | |
| if word_count > self.chunk_size: | |
| flush(current) | |
| # Deliberately no overlap here — carrying sentences forward | |
| # would recreate the non-empty state that caused the spin. | |
| current, current_word_count = [], 0 | |
| flush([i]) | |
| i += 1 | |
| continue | |
| if current_word_count + word_count <= self.chunk_size: | |
| current.append(i) | |
| current_word_count += word_count | |
| i += 1 | |
| continue | |
| # Chunk is full: emit it and carry an overlap into the next one. | |
| flush(current) | |
| current = self._get_overlap_indices(current, sentences, self.chunk_overlap) | |
| current_word_count = sum(len(sentences[j].split()) for j in current) | |
| # Guard against a pathological overlap that leaves no room for the | |
| # next sentence, which would stall progress again. | |
| if current_word_count + word_count > self.chunk_size: | |
| flush(current) | |
| current, current_word_count = [], 0 | |
| flush(current) | |
| return chunks | |
| def _get_overlap_indices( | |
| self, indices: List[int], sentences: List[str], target_words: int | |
| ) -> List[int]: | |
| """Return the tail sentence indices totalling approximately target_words.""" | |
| result: List[int] = [] | |
| word_count = 0 | |
| for idx in reversed(indices): | |
| wc = len(sentences[idx].split()) | |
| if word_count + wc > target_words: | |
| break | |
| result.insert(0, idx) | |
| word_count += wc | |
| return result | |
| def _compute_sentence_offsets( | |
| self, sentences: List[str], full_text: str | |
| ) -> List[tuple]: | |
| """Find (start, end) char offsets of each sentence in full_text.""" | |
| offsets = [] | |
| search_from = 0 | |
| for sentence in sentences: | |
| # Find the sentence in full text starting from last known position | |
| idx = full_text.find(sentence[:30], search_from) # match on first 30 chars | |
| if idx == -1: | |
| idx = search_from | |
| end = idx + len(sentence) | |
| offsets.append((idx, end)) | |
| search_from = max(search_from, idx + 1) | |
| return offsets | |
| def _build_page_map(self, pages: List[PageContent]) -> List[tuple]: | |
| """Build sorted list of (char_start, char_end, page_number) for binary search.""" | |
| return [ | |
| (p.char_start, p.char_end, p.page_number) | |
| for p in sorted(pages, key=lambda p: p.char_start) | |
| ] | |
| def _get_page_numbers( | |
| self, char_start: int, char_end: int, page_map: List[tuple] | |
| ) -> List[int]: | |
| """Return all page numbers that a chunk's char range overlaps.""" | |
| page_nums = [] | |
| for p_start, p_end, page_num in page_map: | |
| # Overlap condition | |
| if p_start < char_end and p_end > char_start: | |
| page_nums.append(page_num) | |
| return sorted(set(page_nums)) or [1] | |
| def _extract_headings(self, text: str) -> List[tuple]: | |
| """ | |
| Extract (char_position, heading_text) from document. | |
| Looks for ALL-CAPS lines, numbered sections, and markdown headings. | |
| """ | |
| headings = [] | |
| for pattern in _HEADING_PATTERNS: | |
| for match in pattern.finditer(text): | |
| heading_text = match.group(0).strip() | |
| # Clean up heading text | |
| heading_text = re.sub(r"^#+\s*", "", heading_text) # Remove markdown # | |
| heading_text = re.sub( | |
| r"^\d+(?:\.\d+)*\s*", "", heading_text | |
| ) # Remove numbering | |
| if 3 <= len(heading_text) <= 100: | |
| headings.append((match.start(), heading_text)) | |
| # Sort by position | |
| headings.sort(key=lambda h: h[0]) | |
| return headings | |
| def _get_section_title( | |
| self, char_start: int, headings: List[tuple] | |
| ) -> Optional[str]: | |
| """Return the most recent heading before char_start.""" | |
| result = None | |
| for pos, title in headings: | |
| if pos <= char_start: | |
| result = title | |
| else: | |
| break | |
| return result | |