| """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))] |
|
|
| |
| sentences = re.split(r"(?<=[.!?\n])\s+", text) |
| chunks: list[str] = [] |
| cur = "" |
| for s in sentences: |
| s = s.strip() |
| if not s: |
| continue |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
|
|
| |
| 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) |
|
|