from __future__ import annotations import argparse import hashlib import json import re from dataclasses import dataclass from pathlib import Path from typing import Iterable HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$") TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$") TABLE_CAPTION_RE = re.compile( r"^\s*(?:\*\*)?\s*(?:Table|TABLE)\s+[A-Za-z0-9. -]+(?::|\.|\s*$).*?(?:\*\*)?\s*$" ) FIGURE_RE = re.compile( r"^\s*(?:Image\s+/.+?\s+description:|(?:\*\*)?\s*Figure\s+\d+[A-Za-z0-9.:-]*\b)", re.IGNORECASE, ) WORD_RE = re.compile(r"\w+|[^\w\s]", re.UNICODE) LAKE_ID_RE = re.compile(r"\b\d{2}[_A-Z0-9]{3,12}[_A-Z0-9]*\b") GENERIC_TITLE_RE = re.compile( r"^(abstract|arstract|introduction|summary|executive summary|foreword|acknowledgements?|keywords?)$", re.IGNORECASE, ) SKIP_SECTION_RE = re.compile( r"^(contents|references|bibliography|further reading|acknowledgements?|acknowledgments?|" r"declaration of competing interest|declaration of competing interests|competing interests?|" r"conflict of interest|data availability)$", re.IGNORECASE, ) NOISE_PATTERNS = [ re.compile(r"^\s*\*{3,}\s*$"), re.compile(r"^\s*-{3,}\s*$"), re.compile(r"^\s*nrsc\s*$", re.IGNORECASE), re.compile(r"^\s*\d+\s*$"), re.compile(r"^\s*[ivxlcdm]{1,8}\s*$", re.IGNORECASE), re.compile(r"^\s*National Remote Sensing Centre,\s*ISRO,\s*Hyderabad\s*\d*\s*$", re.IGNORECASE), re.compile(r"^\s*\d+\s+National Remote Sensing Centre,\s*ISRO,\s*Hyderabad\s*$", re.IGNORECASE), ] @dataclass class ChunkConfig: max_text_tokens: int = 850 text_overlap_tokens: int = 120 min_text_tokens: int = 40 table_rows_per_chunk: int = 35 model_name: str | None = "BAAI/bge-m3" class TokenCounter: def __init__(self, model_name: str | None = None) -> None: self.tokenizer = None if model_name: try: from transformers import AutoTokenizer self.tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=True) except Exception: self.tokenizer = None def count(self, text: str) -> int: if not text: return 0 if self.tokenizer is not None: return len(self.tokenizer.encode(text, add_special_tokens=False)) return len(WORD_RE.findall(text)) def strip_markdown(text: str) -> str: text = text.strip() text = re.sub(r"^\*+|\*+$", "", text) text = re.sub(r"\*{1,3}", " ", text) text = re.sub(r"`+", "", text) text = re.sub(r"", " ", text, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", "", text) return re.sub(r"\s+", " ", text).strip() def clean_line(line: str) -> str: line = line.replace("\ufeff", "") line = re.sub(r"", " ", line, flags=re.IGNORECASE) line = re.sub(r"\s+$", "", line) return line def is_noise_line(line: str) -> bool: stripped = strip_markdown(line) if not stripped: return False return any(pattern.match(stripped) for pattern in NOISE_PATTERNS) def is_table_line(line: str) -> bool: return bool(TABLE_LINE_RE.match(line)) def is_table_separator(line: str) -> bool: cells = [cell.strip() for cell in line.strip().strip("|").split("|")] return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells) def is_heading(line: str) -> tuple[int, str] | None: match = HEADING_RE.match(line) if not match: return None return len(match.group(1)), strip_markdown(match.group(2)) def looks_like_table_caption(line: str) -> bool: return bool(TABLE_CAPTION_RE.match(line.strip())) or bool( re.match(r"^Table\s+[A-Za-z0-9. -]+(?::|\.|\s|$)", strip_markdown(line), re.IGNORECASE) ) def looks_like_figure(line: str) -> bool: return bool(FIGURE_RE.match(line)) def title_from_filename(path: Path) -> str: stem = path.stem stem = stem.replace("_extraction", "").replace("_gemini", "") stem = stem.replace("('", "").replace("', '.pdf')", "") stem = stem.replace("_", " ") return strip_markdown(stem) def normalize_document_title(path: Path, first_heading: str | None) -> str: if first_heading and not GENERIC_TITLE_RE.match(first_heading): return first_heading return title_from_filename(path) def make_chunk_id(source_file: str, chunk_type: str, line_start: int, line_end: int, index: int) -> str: raw = f"{source_file}:{chunk_type}:{line_start}:{line_end}:{index}" return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] def heading_path(headings: list[str | None]) -> str: return " > ".join(heading for heading in headings if heading) def should_skip_section(headings: list[str | None]) -> bool: return any(bool(heading and SKIP_SECTION_RE.match(heading.strip())) for heading in headings) def prefix_content( body: str, document_title: str, section_path: str, chunk_type: str, label: str | None = None, ) -> str: lines = [f"Document: {document_title}"] if section_path: lines.append(f"Section: {section_path}") lines.append(f"Type: {chunk_type}") if label: lines.append(label) lines.append("") lines.append(body.strip()) return "\n".join(lines).strip() def split_oversized_paragraph(paragraph: str, counter: TokenCounter, max_tokens: int) -> list[str]: sentences = re.split(r"(?<=[.!?])\s+", paragraph) pieces: list[str] = [] current: list[str] = [] for sentence in sentences: if counter.count(sentence) > max_tokens: words = WORD_RE.findall(sentence) for start in range(0, len(words), max_tokens): pieces.append(" ".join(words[start : start + max_tokens])) continue candidate = " ".join(current + [sentence]).strip() if current and counter.count(candidate) > max_tokens: pieces.append(" ".join(current).strip()) current = [sentence] else: current.append(sentence) if current: pieces.append(" ".join(current).strip()) return [piece for piece in pieces if piece] def overlap_tail(text: str, overlap_tokens: int) -> str: if overlap_tokens <= 0: return "" words = WORD_RE.findall(text) return " ".join(words[-overlap_tokens:]) def split_text(text: str, counter: TokenCounter, max_tokens: int, overlap_tokens: int) -> list[str]: text = re.sub(r"\n{3,}", "\n\n", text.strip()) if not text: return [] if counter.count(text) <= max_tokens: return [text] paragraphs = [paragraph.strip() for paragraph in re.split(r"\n\s*\n", text) if paragraph.strip()] expanded: list[str] = [] for paragraph in paragraphs: if counter.count(paragraph) > max_tokens: expanded.extend(split_oversized_paragraph(paragraph, counter, max_tokens)) else: expanded.append(paragraph) chunks: list[str] = [] current = "" for paragraph in expanded: candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph if current and counter.count(candidate) > max_tokens: chunks.append(current.strip()) tail = overlap_tail(current, overlap_tokens) current = f"{tail}\n\n{paragraph}".strip() if tail else paragraph else: current = candidate if current: chunks.append(current.strip()) return chunks def parse_table_header(table_lines: list[str]) -> tuple[list[str], str | None]: if not table_lines: return [], None header = [strip_markdown(cell) for cell in table_lines[0].strip().strip("|").split("|")] separator = table_lines[1] if len(table_lines) > 1 and is_table_separator(table_lines[1]) else None return header, separator def extract_caption_from_buffer(buffer: list[tuple[int, str]]) -> str | None: for _, line in reversed(buffer[-4:]): if line.strip() and looks_like_table_caption(line): return strip_markdown(line) return None def trim_caption_from_buffer(buffer: list[tuple[int, str]], caption: str | None) -> None: if not caption: return for idx in range(len(buffer) - 1, max(-1, len(buffer) - 5), -1): if strip_markdown(buffer[idx][1]) == caption: del buffer[idx] return def infer_caption_from_previous(lines: list[str], table_start: int) -> str | None: for idx in range(table_start - 1, max(-1, table_start - 5), -1): line = lines[idx].strip() if not line: continue if looks_like_table_caption(line): return strip_markdown(line) break return None def extract_entities(text: str) -> dict[str, list[str]]: BASINS = ["Indus", "Ganga", "Brahmaputra"] SUBBASINS = [ "Teesta", "Satluj", "Chenab", "Jhelum", "Ravi", "Beas", "Shyok", "Gandak", "Ghaghara", "Kosi", "Sarda", "Upper Ganga", "Yamuna", "Gilgit", "Indus upper", "Indus middle", "Amo Chu", "Dibang", "Dihang", "Jia Bharali", "Lhasa Tsangpo", "Lohit", "Lower Yarlung Tsangpo", "Manas", "Puna Tsang Chu", "Subansiri", "Upper Yarlung Tsangpo" ] COUNTRIES = ["India", "China", "Nepal", "Bhutan", "Myanmar"] def find_terms(terms: list[str]) -> list[str]: if not terms: return [] pattern = r"\b(?:" + "|".join(re.escape(t) for t in terms) + r")\b" return sorted({m.group(0) for m in re.finditer(pattern, text, flags=re.IGNORECASE)}) basins = find_terms(BASINS) subbasins = find_terms(SUBBASINS) countries = find_terms(COUNTRIES) lake_ids = sorted(set(LAKE_ID_RE.findall(text))) return { "basins": basins[:20], "subbasins": subbasins[:20], "countries": countries[:20], "lake_ids": lake_ids[:50], } def collect_table(lines: list[str], start_idx: int) -> tuple[list[str], int]: table_lines: list[str] = [] idx = start_idx while idx < len(lines) and is_table_line(lines[idx]): table_lines.append(clean_line(lines[idx])) idx += 1 return table_lines, idx def collect_figure(lines: list[str], start_idx: int) -> tuple[list[str], int]: figure_lines = [clean_line(lines[start_idx])] idx = start_idx + 1 while idx < len(lines): line = clean_line(lines[idx]) if not line.strip(): break if is_heading(line) or is_table_line(line): break # Keep short caption continuations with the figure, but avoid swallowing a whole paragraph. if looks_like_figure(line) or len(figure_lines) < 3: figure_lines.append(line) idx += 1 continue break return figure_lines, idx def emit_text_chunks( chunks: list[dict], buffer: list[tuple[int, str]], *, source_file: str, document_title: str, section_path: str, headings: list[str | None], counter: TokenCounter, config: ChunkConfig, ) -> None: if not buffer: return line_start = buffer[0][0] line_end = buffer[-1][0] body = "\n".join(line for _, line in buffer).strip() body = re.sub(r"\n{3,}", "\n\n", body) if not body: return text_chunks = split_text(body, counter, config.max_text_tokens, config.text_overlap_tokens) for piece_index, piece in enumerate(text_chunks): if counter.count(piece) < config.min_text_tokens and len(text_chunks) > 1: continue content = prefix_content(piece, document_title, section_path, "text") metadata = { "source_file": source_file, "document_title": document_title, "section_path": section_path, "section_headings": [heading for heading in headings if heading], "chunk_type": "text", "line_start": line_start, "line_end": line_end, "token_count_estimate": counter.count(content), **extract_entities(content), } chunks.append( { "id": make_chunk_id(source_file, "text", line_start, line_end, piece_index), "text": content, "metadata": metadata, } ) def emit_table_chunks( chunks: list[dict], table_lines: list[str], *, caption: str | None, source_file: str, document_title: str, section_path: str, headings: list[str | None], line_start: int, counter: TokenCounter, config: ChunkConfig, ) -> None: if not table_lines: return columns, separator = parse_table_header(table_lines) header = table_lines[0] body_start = 2 if separator else 1 body_rows = table_lines[body_start:] rows_per_chunk = max(1, config.table_rows_per_chunk) row_groups = [body_rows[i : i + rows_per_chunk] for i in range(0, len(body_rows), rows_per_chunk)] or [[]] for group_index, rows in enumerate(row_groups): table_body = [header] if separator: table_body.append(separator) table_body.extend(rows) label = f"Table: {caption}" if caption else "Table" body = "\n".join(table_body) content = prefix_content(body, document_title, section_path, "table", label) row_start = group_index * rows_per_chunk + 1 row_end = row_start + len(rows) - 1 if rows else row_start metadata = { "source_file": source_file, "document_title": document_title, "section_path": section_path, "section_headings": [heading for heading in headings if heading], "chunk_type": "table", "table_caption": caption, "columns": columns, "table_row_start": row_start, "table_row_end": row_end, "line_start": line_start, "line_end": line_start + len(table_lines) - 1, "token_count_estimate": counter.count(content), **extract_entities(content), } chunks.append( { "id": make_chunk_id(source_file, "table", line_start, line_start + len(table_lines) - 1, group_index), "text": content, "metadata": metadata, } ) def emit_figure_chunk( chunks: list[dict], figure_lines: list[str], *, source_file: str, document_title: str, section_path: str, headings: list[str | None], line_start: int, counter: TokenCounter, ) -> None: body = "\n".join(figure_lines).strip() caption = next((strip_markdown(line) for line in figure_lines if re.match(r"^\s*(?:\*\*)?\s*Figure\s+\d+", line, re.IGNORECASE)), None) label = f"Figure: {caption}" if caption else "Figure" content = prefix_content(body, document_title, section_path, "figure", label) metadata = { "source_file": source_file, "document_title": document_title, "section_path": section_path, "section_headings": [heading for heading in headings if heading], "chunk_type": "figure", "figure_caption": caption, "line_start": line_start, "line_end": line_start + len(figure_lines) - 1, "token_count_estimate": counter.count(content), **extract_entities(content), } chunks.append( { "id": make_chunk_id(source_file, "figure", line_start, line_start + len(figure_lines) - 1, 0), "text": content, "metadata": metadata, } ) def chunk_markdown_file(path: Path, data_dir: Path, counter: TokenCounter, config: ChunkConfig) -> list[dict]: raw_text = path.read_text(encoding="utf-8", errors="replace") lines = [clean_line(line) for line in raw_text.splitlines()] chunks: list[dict] = [] headings: list[str | None] = [None] * 6 first_heading: str | None = None document_title = normalize_document_title(path, first_heading) source_file = str(path.relative_to(data_dir.parent)).replace("\\", "/") text_buffer: list[tuple[int, str]] = [] def flush_text() -> None: emit_text_chunks( chunks, text_buffer, source_file=source_file, document_title=document_title, section_path=heading_path(headings), headings=headings, counter=counter, config=config, ) text_buffer.clear() idx = 0 while idx < len(lines): line = lines[idx] line_no = idx + 1 heading = is_heading(line) if heading: flush_text() level, title = heading if first_heading is None: first_heading = title document_title = normalize_document_title(path, first_heading) headings[level - 1] = title for reset_idx in range(level, len(headings)): headings[reset_idx] = None idx += 1 continue if is_noise_line(line): idx += 1 continue if should_skip_section(headings): idx += 1 continue if is_table_line(line): caption = extract_caption_from_buffer(text_buffer) or infer_caption_from_previous(lines, idx) trim_caption_from_buffer(text_buffer, caption) flush_text() table_lines, next_idx = collect_table(lines, idx) emit_table_chunks( chunks, table_lines, caption=caption, source_file=source_file, document_title=document_title, section_path=heading_path(headings), headings=headings, line_start=line_no, counter=counter, config=config, ) idx = next_idx continue if looks_like_figure(line): flush_text() figure_lines, next_idx = collect_figure(lines, idx) emit_figure_chunk( chunks, figure_lines, source_file=source_file, document_title=document_title, section_path=heading_path(headings), headings=headings, line_start=line_no, counter=counter, ) idx = next_idx continue text_buffer.append((line_no, line)) idx += 1 flush_text() return chunks def write_jsonl(path: Path, rows: Iterable[dict]) -> int: path.parent.mkdir(parents=True, exist_ok=True) count = 0 with path.open("w", encoding="utf-8") as handle: for row in rows: handle.write(json.dumps(row, ensure_ascii=False) + "\n") count += 1 return count def build_chunks(data_dir: Path, config: ChunkConfig) -> list[dict]: counter = TokenCounter(config.model_name) all_chunks: list[dict] = [] for path in sorted(data_dir.glob("*.md")): all_chunks.extend(chunk_markdown_file(path, data_dir, counter, config)) return all_chunks def summarize(chunks: list[dict]) -> dict[str, int]: summary: dict[str, int] = {"total": len(chunks)} for chunk in chunks: chunk_type = chunk["metadata"]["chunk_type"] summary[chunk_type] = summary.get(chunk_type, 0) + 1 return summary def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Chunk Markdown files for a GLOF RAG chatbot.") parser.add_argument("--data-dir", type=Path, default=Path("rag/data"), help="Directory containing Markdown files.") parser.add_argument("--out", type=Path, default=Path("rag/artifacts/chunks.jsonl"), help="Output JSONL path.") parser.add_argument("--model-name", default="BAAI/bge-m3", help="Tokenizer model used for token-aware splitting.") parser.add_argument("--max-text-tokens", type=int, default=850) parser.add_argument("--text-overlap-tokens", type=int, default=120) parser.add_argument("--min-text-tokens", type=int, default=40) parser.add_argument("--table-rows-per-chunk", type=int, default=35) return parser.parse_args() def main() -> None: args = parse_args() config = ChunkConfig( max_text_tokens=args.max_text_tokens, text_overlap_tokens=args.text_overlap_tokens, min_text_tokens=args.min_text_tokens, table_rows_per_chunk=args.table_rows_per_chunk, model_name=args.model_name, ) chunks = build_chunks(args.data_dir, config) count = write_jsonl(args.out, chunks) print(json.dumps({"output": str(args.out), "written": count, "summary": summarize(chunks)}, indent=2)) if __name__ == "__main__": main()