"""Inference-side semantic chunker. The training task is "does a segment boundary follow this sentence"; this module turns that into the thing a RAG pipeline actually wants: a list of text chunks, subject to hard size limits an embedder can accept. Two things here are not in the training loop and matter in production: 1. Windowed inference. Real documents run far past 2048 tokens. We slide a window with sentence-level left context so boundary decisions near a window edge still see what came before, instead of being scored cold. 2. Hard size caps. A semantic boundary model will happily emit a 4000-token chunk if the topic does not shift. Embedders truncate at their own limit, so `max_chunk_tokens` force-splits at the lowest-confidence interior sentence rather than at an arbitrary character offset. """ from __future__ import annotations import os import re from dataclasses import dataclass from typing import Callable, Iterable import numpy as np from transformers import AutoTokenizer #: Published weights. Used when no model path is given, so `SemanticChunker()` #: works with nothing downloaded by hand. DEFAULT_MODEL = "0xKitkat/semantic-chunker-modernbert-base" #: Where the ONNX graph lives inside that repo. ONNX_SUBFOLDER = "onnx" # Sentence splitter: deliberately dependency-free. Handles the common # abbreviation cases that naive `.` splitting gets wrong. Pass your own via # `sentence_splitter=` if you already have spacy/nltk/pysbd in the pipeline. _ABBREV = ( r"(? list[str]: out = [] for block in re.split(r"\n\s*\n", text): # paragraph breaks are always splits block = block.strip() if not block: continue out.extend(s.strip() for s in _SENT_RE.split(block) if s.strip()) return out def _softmax_last(x: np.ndarray) -> np.ndarray: x = x - x.max(axis=-1, keepdims=True) e = np.exp(x) return e / e.sum(axis=-1, keepdims=True) class TorchBackend: """Default backend. torch is imported here so the ONNX path stays torch-free.""" def __init__(self, model_path: str, device: str | None = None): import torch from transformers import AutoModelForTokenClassification self._torch = torch self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") self.model = ( AutoModelForTokenClassification.from_pretrained(model_path) .eval() .to(self.device) ) self.id2label = dict(self.model.config.id2label) def __call__(self, input_ids: np.ndarray, attention_mask: np.ndarray) -> np.ndarray: t = self._torch with t.no_grad(): out = self.model( input_ids=t.tensor(input_ids, dtype=t.long, device=self.device), attention_mask=t.tensor(attention_mask, dtype=t.long, device=self.device), ).logits return out.float().cpu().numpy() def resolve_onnx_dir(path_or_repo: str, subfolder: str = ONNX_SUBFOLDER) -> str: """Accept a local ONNX dir, a local repo root, or a Hub repo id. Without this, ONNX users have to know that the graph sits in a subfolder and fetch it themselves before anything works -- which is exactly the friction the ONNX build exists to remove. """ if os.path.isdir(path_or_repo): for cand in (path_or_repo, os.path.join(path_or_repo, subfolder)): if os.path.exists(os.path.join(cand, "model.onnx")): return cand raise FileNotFoundError( f"no model.onnx in {path_or_repo} or its {subfolder}/ subfolder" ) from huggingface_hub import snapshot_download local = snapshot_download(path_or_repo, allow_patterns=f"{subfolder}/*") return os.path.join(local, subfolder) class OnnxBackend: """CPU inference with onnxruntime only -- no torch in the dependency tree.""" def __init__(self, model_dir: str, providers: list[str] | None = None): import json import onnxruntime as ort model_dir = resolve_onnx_dir(model_dir) path = os.path.join(model_dir, "model.onnx") if not os.path.exists(path): raise FileNotFoundError(f"no model.onnx under {model_dir}") self.sess = ort.InferenceSession( path, providers=providers or ["CPUExecutionProvider"] ) with open(os.path.join(model_dir, "config.json"), encoding="utf-8") as f: cfg = json.load(f) self.id2label = cfg.get("id2label", {0: "O", 1: "semantic-shift"}) def __call__(self, input_ids: np.ndarray, attention_mask: np.ndarray) -> np.ndarray: return self.sess.run( None, { "input_ids": input_ids.astype(np.int64), "attention_mask": attention_mask.astype(np.int64), }, )[0] @dataclass class Chunk: text: str start_sentence: int end_sentence: int n_tokens: int boundary_score: float # confidence of the boundary that closed this chunk class SemanticChunker: def __init__( self, model_path: str | None = None, threshold: float = 0.5, max_chunk_tokens: int = 512, min_chunk_tokens: int = 0, min_chunk_sentences: int = 1, window_tokens: int = 2048, context_sentences: int = 3, device: str | None = None, sentence_splitter: Callable[[str], list[str]] | None = None, batch_size: int = 8, backend: str = "torch", tokenizer_path: str | None = None, ): model_path = model_path or DEFAULT_MODEL self.tokenizer = AutoTokenizer.from_pretrained(tokenizer_path or model_path) if backend == "onnx": self.backend = OnnxBackend(model_path) elif backend == "torch": self.backend = TorchBackend(model_path, device) else: raise ValueError(f"unknown backend {backend!r}, expected 'torch' or 'onnx'") self.device = getattr(self.backend, "device", "cpu") self._configure( threshold=threshold, max_chunk_tokens=max_chunk_tokens, min_chunk_tokens=min_chunk_tokens, min_chunk_sentences=min_chunk_sentences, window_tokens=window_tokens, context_sentences=context_sentences, sentence_splitter=sentence_splitter, batch_size=batch_size, ) @classmethod def from_backend(cls, backend, tokenizer, **kwargs): """Wrap a model somebody else already loaded. `transformers.pipeline` owns model construction -- it resolves the checkpoint, picks the device, and applies dtype/quantisation settings. Re-loading the weights underneath it would double the memory and silently ignore all of that, so the pipeline builds the chunker around the model it was handed instead. """ self = cls.__new__(cls) self.tokenizer = tokenizer self.backend = backend self.device = getattr(backend, "device", "cpu") self._configure(**kwargs) return self def _configure( self, threshold: float = 0.5, max_chunk_tokens: int = 512, min_chunk_tokens: int = 0, min_chunk_sentences: int = 1, window_tokens: int = 2048, context_sentences: int = 3, sentence_splitter: Callable[[str], list[str]] | None = None, batch_size: int = 8, ): self.threshold = threshold self.max_chunk_tokens = max_chunk_tokens self.min_chunk_tokens = min_chunk_tokens self.min_chunk_sentences = max(1, min_chunk_sentences) self.window_tokens = window_tokens self.context_sentences = context_sentences self.split_sentences = sentence_splitter or default_sentence_splitter self.batch_size = batch_size id2label = dict(self.backend.id2label) self.pos_idx = next( (int(i) for i, n in id2label.items() if str(n).lower() in {"semantic-shift", "separator", "shift"}), 1, ) # ------------------------------------------------------------------ def boundary_scores(self, sentences: list[str]) -> list[float]: """P(boundary follows) for each sentence. Last entry is always 1.0.""" if not sentences: return [] if len(sentences) == 1: return [1.0] toks = self.tokenizer( [s if i == 0 else " " + s for i, s in enumerate(sentences)], add_special_tokens=False, )["input_ids"] budget = self.window_tokens - 2 windows = [] # (ids, [(sentence_idx, token_pos)]) start = 0 n = len(sentences) while start < n: ids, marks = [], [] ctx_start = max(0, start - self.context_sentences) for i in range(ctx_start, start): ids.extend(toks[i]) i = start while i < n: t = toks[i] or [self.tokenizer.unk_token_id] if len(t) > budget: t = t[:budget] if len(ids) + len(t) > budget: break ids.extend(t) marks.append((i, len(ids) - 1)) i += 1 if i == start: # pathological single sentence marks.append((start, max(len(ids) - 1, 0))) i = start + 1 windows.append((ids, marks)) start = i scores = [0.0] * n for b in range(0, len(windows), self.batch_size): batch = windows[b : b + self.batch_size] maxlen = max(len(w[0]) for w in batch) + 2 input_ids = np.full( (len(batch), maxlen), self.tokenizer.pad_token_id, dtype=np.int64 ) attn = np.zeros((len(batch), maxlen), dtype=np.int64) for r, (ids, _) in enumerate(batch): seq = [self.tokenizer.cls_token_id] + ids + [self.tokenizer.sep_token_id] input_ids[r, : len(seq)] = seq attn[r, : len(seq)] = 1 logits = self.backend(input_ids, attn) probs = _softmax_last(logits.astype(np.float32))[..., self.pos_idx] for r, (_, marks) in enumerate(batch): for sent_idx, tok_pos in marks: scores[sent_idx] = float(probs[r, tok_pos + 1]) # +1 for CLS scores[-1] = 1.0 # document end is always a boundary return scores # ------------------------------------------------------------------ def _token_lens(self, texts: list[str]) -> list[int]: """One batched tokenizer call, not one per sentence.""" if not texts: return [] enc = self.tokenizer(texts, add_special_tokens=False)["input_ids"] return [len(x) for x in enc] def split(self, text: str) -> list[Chunk]: sentences = self.split_sentences(text) if not sentences: return [] scores = self.boundary_scores(sentences) sent_tokens = self._token_lens(sentences) chunks: list[Chunk] = [] buf_start = 0 buf_tokens = 0 def emit(end_idx: int, score: float): nonlocal buf_start, buf_tokens body = " ".join(sentences[buf_start : end_idx + 1]).strip() if body: chunks.append( Chunk( text=body, start_sentence=buf_start, end_sentence=end_idx, n_tokens=sum(sent_tokens[buf_start : end_idx + 1]), boundary_score=score, ) ) buf_start = end_idx + 1 buf_tokens = 0 for i, sent in enumerate(sentences): buf_tokens += sent_tokens[i] n_in_buf = i - buf_start + 1 # hard cap wins over semantics: split at the weakest interior # boundary so we cut where the model is least confident, not # wherever the token counter happened to run out. if buf_tokens > self.max_chunk_tokens and n_in_buf > 1: interior = range(buf_start, i) cut = max(interior, key=lambda j: scores[j]) emit(cut, scores[cut]) buf_tokens = sum(sent_tokens[buf_start : i + 1]) n_in_buf = i - buf_start + 1 if scores[i] >= self.threshold and n_in_buf >= self.min_chunk_sentences: emit(i, scores[i]) if buf_start < len(sentences): emit(len(sentences) - 1, 1.0) return self._merge_undersized(chunks, sentences, sent_tokens) def _merge_undersized(self, chunks, sentences, sent_tokens): """Merge chunks below min_chunk_tokens into their neighbour. Measured need, not speculation: on short-document corpora the boundary model happily splits a 150-token document in two, and two 75-token fragments retrieve worse than one whole document. A topic boundary is real there, but acting on it is counterproductive -- the embedder needs enough text to place the vector well. Merges forward by preference (keeps reading order), backward for a trailing runt, and never produces a chunk exceeding max_chunk_tokens. """ if self.min_chunk_tokens <= 0 or len(chunks) < 2: return chunks out = [] i = 0 while i < len(chunks): cur = chunks[i] while ( cur.n_tokens < self.min_chunk_tokens and i + 1 < len(chunks) and cur.n_tokens + chunks[i + 1].n_tokens <= self.max_chunk_tokens ): nxt = chunks[i + 1] cur = Chunk( text=(cur.text + " " + nxt.text).strip(), start_sentence=cur.start_sentence, end_sentence=nxt.end_sentence, n_tokens=cur.n_tokens + nxt.n_tokens, boundary_score=nxt.boundary_score, ) i += 1 out.append(cur) i += 1 # a trailing runt has no forward neighbour left; fold it back if ( len(out) > 1 and out[-1].n_tokens < self.min_chunk_tokens and out[-2].n_tokens + out[-1].n_tokens <= self.max_chunk_tokens ): last, prev = out.pop(), out.pop() out.append( Chunk( text=(prev.text + " " + last.text).strip(), start_sentence=prev.start_sentence, end_sentence=last.end_sentence, n_tokens=prev.n_tokens + last.n_tokens, boundary_score=last.boundary_score, ) ) return out def split_text(self, text: str) -> list[str]: """Convenience: just the strings.""" return [c.text for c in self.split(text)] def batch_split(self, texts: Iterable[str]) -> list[list[str]]: return [self.split_text(t) for t in texts] def _cli(argv=None): import argparse import json as _json import sys ap = argparse.ArgumentParser( prog="boundary-chunk", description="Split a document into topic-coherent chunks.", ) ap.add_argument("--model", default=None, help=f"default: {DEFAULT_MODEL}") ap.add_argument("--threshold", type=float, default=0.5) ap.add_argument("--max-chunk-tokens", type=int, default=512) ap.add_argument("--min-chunk-tokens", type=int, default=0) ap.add_argument("--file", help="read text from file; otherwise stdin") ap.add_argument("--backend", default="torch", choices=["torch", "onnx"]) ap.add_argument("--tokenizer", default=None, help="only needed for a bare onnx dir") ap.add_argument("--json", action="store_true", help="emit JSON instead of text") args = ap.parse_args(argv) text = open(args.file, encoding="utf-8").read() if args.file else sys.stdin.read() ch = SemanticChunker( args.model, threshold=args.threshold, max_chunk_tokens=args.max_chunk_tokens, min_chunk_tokens=args.min_chunk_tokens, backend=args.backend, tokenizer_path=args.tokenizer, ) chunks = ch.split(text) if args.json: print(_json.dumps([c.__dict__ for c in chunks], ensure_ascii=False, indent=2)) else: for i, c in enumerate(chunks): print(f"\n--- chunk {i} ({c.n_tokens} tok, score {c.boundary_score:.3f}) ---") print(c.text) return 0 if __name__ == "__main__": raise SystemExit(_cli())