"""M3 RAG Chunking — recursive text chunking with MD5 dedup. Why chunking matters: - Embedding models have context limits (bge-m3 = 8192 tokens, but we chunk to 512 for granularity and to keep individual FAISS hits useful) - Smaller chunks = better retrieval precision (less noise per result) - Dedup via content MD5 prevents the same fact being ingested N times Strategy (per 2026 RAG standards): - Recursive character split, paragraph → sentence → word boundaries - Overlap window preserves cross-chunk context - Quality score: (length / max_chunk) × (1 - special_char_ratio) - Skip chunks < min_chars (likely noise) """ from __future__ import annotations import hashlib import logging import re from dataclasses import dataclass log = logging.getLogger(__name__) DEFAULT_MAX_CHUNK = 512 DEFAULT_OVERLAP = 64 DEFAULT_MIN_CHARS = 32 _redis = None def _get_redis(): global _redis if _redis is None: from app.core.redis import get_redis _redis = get_redis() return _redis @dataclass class Chunk: """A piece of text ready to embed.""" text: str content_hash: str index: int quality: float = 1.0 def content_hash(text: str) -> str: """Stable MD5 of normalized text. Used for dedup.""" norm = re.sub(r"\s+", " ", text.strip().lower()) return hashlib.md5(norm.encode("utf-8", errors="ignore")).hexdigest() def chunk_text( text: str, max_chunk: int = DEFAULT_MAX_CHUNK, overlap: int = DEFAULT_OVERLAP, min_chars: int = DEFAULT_MIN_CHARS, ) -> list[Chunk]: """Recursive character chunker. Splits on paragraph breaks first, then sentence, then word. Each split is greedy-packed into chunks of <= max_chunk chars, with `overlap` chars carried forward to preserve context. """ if not text or not text.strip(): return [] text = text.strip() if len(text) <= max_chunk: h = content_hash(text) return [Chunk(text=text, content_hash=h, index=0, quality=_quality(text, max_chunk))] # Build sentence-level segments (split on . ! ? \n) sentences = re.split(r"(?<=[.!?\n])\s+", text) chunks: list[str] = [] cur = "" for s in sentences: s = s.strip() if not s: continue # If a single sentence is too long, hard-split on words if len(s) > max_chunk: words = s.split() sub = "" for w in words: if len(sub) + len(w) + 1 > max_chunk and sub: chunks.append(sub) sub = w else: sub = (sub + " " + w).strip() if sub: chunks.append(sub) elif len(cur) + len(s) + 1 > max_chunk: if cur: chunks.append(cur) cur = s else: cur = (cur + " " + s).strip() if cur: chunks.append(cur) # Apply overlap: each chunk gets the last `overlap` chars of the prior chunk as prefix if overlap > 0 and len(chunks) > 1: overlapped: list[str] = [] for i, c in enumerate(chunks): if i == 0: overlapped.append(c) else: tail = chunks[i - 1][-overlap:] overlapped.append(tail + " " + c) chunks = overlapped # Filter and produce Chunk objects out: list[Chunk] = [] for i, c in enumerate(chunks): if len(c) < min_chars: continue out.append( Chunk( text=c, content_hash=content_hash(c), index=i, quality=_quality(c, max_chunk), ) ) return out def _quality(text: str, max_chunk: int) -> float: """Quick quality score 0-1. Penalize noise (high special-char ratio).""" if not text: return 0.0 n = len(text) special = sum(1 for c in text if not c.isalnum() and not c.isspace()) length_score = min(1.0, n / max_chunk) special_score = max(0.0, 1.0 - (special / n)) return round(0.7 * length_score + 0.3 * special_score, 3) # ── Dedup via Redis ───────────────────────────────────────────────── def is_duplicate(content_hash: str, collection: str) -> bool: """Has this exact content already been ingested into the collection?""" try: return bool(_get_redis().sismember(f"rag:hashes:{collection}", content_hash)) except Exception as e: log.debug("dedup_check_failed: %s", e) return False def mark_ingested(content_hash: str, collection: str) -> None: """Record that this content hash is now in the collection.""" try: _get_redis().sadd(f"rag:hashes:{collection}", content_hash) except Exception as e: log.debug("dedup_mark_failed: %s", e)