- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
158 lines
4.8 KiB
Python
158 lines
4.8 KiB
Python
"""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) x (1 - special_char_ratio) # noqa: RUF002
|
|
- 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)
|