Coverage for src/local_deep_research/embeddings/splitters/bounded_semantic_chunker.py: 98%
34 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""Bound semantic document chunks with character-based recursive splitters."""
3from __future__ import annotations
5from collections.abc import Iterable
6from typing import TYPE_CHECKING, Protocol
8from langchain_core.documents.transformers import BaseDocumentTransformer
9from local_deep_research.constants import DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS
11if TYPE_CHECKING:
12 from collections.abc import Sequence
14 from langchain_core.documents import Document
17class _DocumentSplitter(Protocol):
18 """Minimal public document-splitting contract."""
20 def split_documents( 20 ↛ exitline 20 didn't return from function 'split_documents' because
21 self, documents: Iterable[Document]
22 ) -> list[Document]: ...
25class BoundedSemanticChunker(BaseDocumentTransformer):
26 """Bound semantic-comparison inputs and final chunks by character length."""
28 def __init__(
29 self,
30 semantic_chunker: _DocumentSplitter,
31 chunk_size: int,
32 separators: list[str] | None = None,
33 ) -> None:
34 """Create recursive pre- and post-semantic bounding stages.
36 Note:
37 The parent ``langchain_text_splitters`` package is imported before
38 its ``character`` submodule to preserve the parent-first import
39 invariant required for safe concurrent cold imports.
40 """
41 import langchain_text_splitters # noqa: F401
42 from langchain_text_splitters.character import (
43 RecursiveCharacterTextSplitter,
44 )
46 effective_separators = (
47 separators.copy()
48 if separators is not None
49 else DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS.copy()
50 )
51 # RecursiveCharacterTextSplitter only guarantees the hard character
52 # bound when it can fall back to splitting individual characters.
53 # User-configured separator lists are allowed to omit that fallback,
54 # so add it here instead of letting one separator-free span bypass the
55 # cap this wrapper exists to enforce.
56 if "" not in effective_separators:
57 effective_separators.append("")
59 def create_boundary_splitter() -> RecursiveCharacterTextSplitter:
60 return RecursiveCharacterTextSplitter(
61 chunk_size=chunk_size,
62 # Semantic mode historically did not apply document overlap.
63 # Zero overlap also prevents a near-size configured overlap
64 # from amplifying large inputs into excessive requests.
65 chunk_overlap=0,
66 length_function=len,
67 keep_separator=True,
68 strip_whitespace=False,
69 separators=effective_separators.copy(),
70 )
72 self._pre_splitter = create_boundary_splitter()
73 self._semantic_chunker = semantic_chunker
74 self._post_splitter = create_boundary_splitter()
76 def split_text(self, text: str) -> list[str]:
77 return [
78 document.page_content for document in self.create_documents([text])
79 ]
81 def create_documents(
82 self,
83 texts: list[str],
84 metadatas: list[dict[str, object]] | None = None,
85 ) -> list[Document]:
86 source_documents = self._pre_splitter.create_documents(texts, metadatas)
87 semantic_documents = self._semantic_chunker.split_documents(
88 source_documents
89 )
90 return self._post_splitter.split_documents(semantic_documents)
92 def split_documents(self, documents: Iterable[Document]) -> list[Document]:
93 """Bound semantic inputs and outputs while preserving their ordering."""
94 texts = []
95 metadatas = []
96 for document in documents:
97 texts.append(document.page_content)
98 metadatas.append(document.metadata)
99 return self.create_documents(texts, metadatas)
101 def transform_documents(
102 self, documents: Sequence[Document], **kwargs: object
103 ) -> Sequence[Document]:
104 return self.split_documents(documents)