""" RAG Chunking Engine - 2026 Modern Standards ============================================ Recursive character splitting with configurable strategies per content type. Content-hash dedup via Redis. Quality scoring for ingestion filtering. Strategies: - recursive: paragraph → sentence → word → character (default) - semantic: split on topic boundaries (embedding similarity drop) - sentence: preserve complete sentences - code: class/function boundary aware - fixed: simple token/character count Standards (2026 consensus): - Default: recursive, 512 tokens, 15% overlap - News: recursive, 512 tokens, 15% overlap - Scam reports: semantic, 400 tokens, 10% overlap - Contract code: code-aware, 256 tokens, 20% overlap - Wallet profiles: fixed, 128 tokens, 0% overlap - Annual reports: recursive, 800 tokens, 15% overlap """ import hashlib import logging import re logger = logging.getLogger("rag.chunking") # ── Strategy presets per content type ── STRATEGY_PRESETS = { "news": {"strategy": "recursive", "chunk_size": 512, "overlap": 0.15}, "scam_report": {"strategy": "semantic", "chunk_size": 400, "overlap": 0.10}, "contract_code": {"strategy": "code", "chunk_size": 256, "overlap": 0.20}, "wallet_profile": {"strategy": "fixed", "chunk_size": 128, "overlap": 0.0}, "annual_report": {"strategy": "recursive", "chunk_size": 800, "overlap": 0.15}, "default": {"strategy": "recursive", "chunk_size": 512, "overlap": 0.15}, } # ── Separator hierarchy for recursive splitting ── RECURSIVE_SEPARATORS = ["\n\n", "\n", ". ", " ", ""] CODE_SEPARATORS = ["\n\nclass ", "\n\ndef ", "\n\n", "\n", " ", ""] def get_strategy(content_type: str) -> dict: """Get chunking strategy preset for a content type.""" return STRATEGY_PRESETS.get(content_type, STRATEGY_PRESETS["default"]) def recursive_chunk( text: str, chunk_size: int = 512, overlap: float = 0.15, separators: list[str] | None = None, ) -> list[str]: """ Recursive character splitting - tries to split at natural boundaries. Separator hierarchy: paragraph → line → sentence → word → character. Args: text: Document text to chunk chunk_size: Target chunk size in characters (~4 chars per token) overlap: Fraction of chunk_size to overlap (0.0-0.25) separators: Custom separator hierarchy (defaults to RECURSIVE_SEPARATORS) Returns: List of text chunks """ if not text or len(text) < 50: return [text] if text else [] if separators is None: separators = RECURSIVE_SEPARATORS overlap_chars = int(chunk_size * overlap) chunks = [] start = 0 while start < len(text): end = min(start + chunk_size, len(text)) # If this is the last chunk, just take the remainder if end >= len(text): chunks.append(text[start:]) break # Try to find a natural split point within the chunk chunk_text = text[start:end] split_pos = None for sep in separators: if not sep: # Empty separator = character-level split (last resort) split_pos = end break # Find the LAST occurrence of separator in the chunk # (prefer splitting at the end to keep chunks as large as possible) pos = chunk_text.rfind(sep) if pos > chunk_size * 0.5: # Only split if it's in the latter half split_pos = start + pos + len(sep) break if split_pos is None: split_pos = end chunks.append(text[start:split_pos].strip()) # Next chunk starts with overlap start = split_pos - overlap_chars if start <= 0 or start >= len(text): break # Remove empty chunks return [c for c in chunks if len(c) > 20] def code_chunk( code: str, chunk_size: int = 256, overlap: float = 0.20, ) -> list[str]: """Structure-aware chunking for smart contract code.""" return recursive_chunk(code, chunk_size, overlap, CODE_SEPARATORS) def sentence_chunk( text: str, chunk_size: int = 512, overlap: float = 0.15, ) -> list[str]: """ Sentence-aware chunking - never splits mid-sentence. Groups sentences to hit target chunk size. """ if not text: return [] # Split into sentences (handles abbreviations like "Dr.", "3.14", "0x...") sentences = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text) if len(sentences) <= 1: return recursive_chunk(text, chunk_size, overlap) chunks = [] current = "" for sent in sentences: if len(current) + len(sent) <= chunk_size: current += " " + sent if current else sent else: if current: chunks.append(current.strip()) current = sent if current: chunks.append(current.strip()) # Add overlap: prepend last ~15% of previous chunk to next if overlap > 0 and len(chunks) > 1: overlap_chars = int(chunk_size * overlap) overlapped = [chunks[0]] for i in range(1, len(chunks)): prev_tail = chunks[i - 1][-overlap_chars:] if len(chunks[i - 1]) > overlap_chars else chunks[i - 1] overlapped.append(prev_tail + " " + chunks[i]) return overlapped return chunks def fixed_chunk( text: str, chunk_size: int = 128, overlap: float = 0.0, ) -> list[str]: """Simple fixed-size chunking by character count.""" if not text: return [] overlap_chars = int(chunk_size * overlap) chunks = [] for i in range(0, len(text), chunk_size - overlap_chars): chunks.append(text[i : i + chunk_size].strip()) return [c for c in chunks if len(c) > 10] def chunk_document( text: str, content_type: str = "default", custom_strategy: dict | None = None, ) -> list[str]: """ Chunk a document using the appropriate strategy for its content type. Args: text: Document text content_type: One of 'news', 'scam_report', 'contract_code', 'wallet_profile', 'annual_report', 'default' custom_strategy: Override with {'strategy': '...', 'chunk_size': N, 'overlap': F} Returns: List of text chunks """ strategy = custom_strategy or get_strategy(content_type) strat_name = strategy["strategy"] size = strategy["chunk_size"] overlap = strategy["overlap"] if strat_name == "recursive": return recursive_chunk(text, size, overlap) elif strat_name == "code": return code_chunk(text, size, overlap) elif strat_name == "sentence": return sentence_chunk(text, size, overlap) elif strat_name == "fixed": return fixed_chunk(text, size, overlap) elif strat_name == "semantic": # Semantic chunking requires embedding every sentence - expensive. # Fall back to recursive for now; semantic can be added later. logger.info("Semantic chunking requested, falling back to recursive") return recursive_chunk(text, size, overlap) else: return recursive_chunk(text, size, overlap) # ── Content Hash Dedup ── def content_hash(text: str) -> str: """MD5 hash of normalized text for dedup.""" # Normalize: lowercase, strip extra whitespace, remove punctuation noise normalized = re.sub(r"\s+", " ", text.lower().strip()) normalized = re.sub(r"[^\w\s]", "", normalized) return hashlib.md5(normalized.encode()).hexdigest() async def is_duplicate(collection: str, text: str, redis_client=None) -> bool: """Check if content already exists in the collection via hash dedup.""" if redis_client is None: from app.rag_service import _get_redis redis_client = await _get_redis() h = content_hash(text) key = f"rag:dedup:{collection}:{h}" exists = await redis_client.exists(key) return bool(exists) async def mark_ingested( collection: str, text: str, ttl: int = 604800, # 7 days default redis_client=None, ) -> None: """Mark content as ingested in dedup registry.""" if redis_client is None: from app.rag_service import _get_redis redis_client = await _get_redis() h = content_hash(text) key = f"rag:dedup:{collection}:{h}" await redis_client.setex(key, ttl, "1") # ── Quality Scoring ── def quality_score(text: str, content_type: str = "default") -> int: """ Score content quality 0-100. Skip docs below threshold (default: 30). Factors: - Length (too short = low signal) - Structure (has paragraphs, headings) - Entity density (addresses, token symbols, chain names) - Noise ratio (special characters, repeated patterns) """ if not text or len(text) < 50: return 0 score = 50 # Start neutral # Length bonus if len(text) > 500: score += 15 elif len(text) > 200: score += 10 elif len(text) > 100: score += 5 # Structure bonus (paragraphs, headings) if "\n\n" in text: score += 10 if re.search(r"^#{1,3}\s", text, re.MULTILINE): score += 5 # Entity density (crypto-specific) entities = 0 entities += len(re.findall(r"0x[a-fA-F0-9]{40}", text)) # ETH addresses entities += len(re.findall(r"[1-9A-HJ-NP-Za-km-z]{32,44}", text)) # Solana addresses entities += len(re.findall(r"\b[A-Z]{2,5}\b", text)) # Token symbols entities += len(re.findall(r"(?i)(ethereum|solana|bitcoin|polygon|arbitrum|bsc|avalanche|tron)", text)) score += min(entities * 2, 20) # Cap at +20 # Noise penalty noise_chars = len(re.findall(r"[^\w\s.,;:!?\-()\[\]{}$@%&*+/<=>|~^]", text)) noise_ratio = noise_chars / max(len(text), 1) if noise_ratio > 0.3: score -= 20 elif noise_ratio > 0.15: score -= 10 # Repeated pattern penalty (spam) words = text.lower().split() if len(words) > 10: unique_ratio = len(set(words)) / len(words) if unique_ratio < 0.3: score -= 25 elif unique_ratio < 0.5: score -= 10 return max(0, min(100, score)) # ── Batch Chunking for Ingestion ── def chunk_batch( documents: list[dict], content_type: str = "default", min_quality: int = 30, ) -> list[dict]: """ Process a batch of documents: chunk, score quality, filter. Each document should have: {'text': str, 'source': str, 'url': str, ...} Returns list of chunk dicts with metadata preserved. """ chunks = [] skipped = 0 for doc in documents: text = doc.get("text", doc.get("title", "")) if not text: skipped += 1 continue # Quality filter score = quality_score(text, content_type) if score < min_quality: skipped += 1 continue # Chunk doc_chunks = chunk_document(text, content_type) for i, chunk in enumerate(doc_chunks): chunks.append( { "text": chunk, "chunk_index": i, "total_chunks": len(doc_chunks), "quality_score": score, "content_hash": content_hash(chunk), "source": doc.get("source", "unknown"), "url": doc.get("url", ""), "category": doc.get("category", content_type), "date": doc.get("date", ""), "chain": doc.get("chain", ""), "tags": doc.get("tags", []), **{k: v for k, v in doc.items() if k not in ("text", "title")}, } ) logger.info(f"Chunked {len(documents)} docs → {len(chunks)} chunks ({skipped} skipped)") return chunks