ragfoundry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. ragfoundry/__init__.py +36 -0
  2. ragfoundry/_utils.py +88 -0
  3. ragfoundry/chunk_store.py +109 -0
  4. ragfoundry/chunking/__init__.py +23 -0
  5. ragfoundry/chunking/_text.py +20 -0
  6. ragfoundry/chunking/base.py +171 -0
  7. ragfoundry/chunking/llm.py +88 -0
  8. ragfoundry/chunking/semantic.py +67 -0
  9. ragfoundry/cli.py +162 -0
  10. ragfoundry/config.py +248 -0
  11. ragfoundry/errors.py +19 -0
  12. ragfoundry/evaluation/__init__.py +35 -0
  13. ragfoundry/evaluation/dataset.py +46 -0
  14. ragfoundry/evaluation/generation.py +116 -0
  15. ragfoundry/evaluation/ragas_adapter.py +84 -0
  16. ragfoundry/evaluation/retrieval.py +71 -0
  17. ragfoundry/evaluation/runner.py +190 -0
  18. ragfoundry/graphs/__init__.py +14 -0
  19. ragfoundry/graphs/ingest.py +99 -0
  20. ragfoundry/graphs/query.py +197 -0
  21. ragfoundry/loaders/__init__.py +15 -0
  22. ragfoundry/loaders/base.py +162 -0
  23. ragfoundry/loaders/excel.py +64 -0
  24. ragfoundry/loaders/web.py +136 -0
  25. ragfoundry/models/__init__.py +64 -0
  26. ragfoundry/observability/__init__.py +65 -0
  27. ragfoundry/pipeline.py +353 -0
  28. ragfoundry/prompts.py +90 -0
  29. ragfoundry/retrieval/__init__.py +19 -0
  30. ragfoundry/retrieval/fusion.py +32 -0
  31. ragfoundry/retrieval/rerank.py +62 -0
  32. ragfoundry/retrieval/retriever.py +100 -0
  33. ragfoundry/retrieval/transforms.py +53 -0
  34. ragfoundry/vectorstores/__init__.py +8 -0
  35. ragfoundry/vectorstores/factory.py +232 -0
  36. ragfoundry/vectorstores/index.py +59 -0
  37. ragfoundry-0.1.0.dist-info/METADATA +802 -0
  38. ragfoundry-0.1.0.dist-info/RECORD +40 -0
  39. ragfoundry-0.1.0.dist-info/WHEEL +4 -0
  40. ragfoundry-0.1.0.dist-info/entry_points.txt +2 -0
ragfoundry/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """ragfoundry: configurable production RAG on LangChain and LangGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chunking import chunk_documents, register_chunker
6
+ from .config import RAGConfig
7
+ from .errors import (
8
+ ConfigurationError,
9
+ IngestionError,
10
+ MissingDependencyError,
11
+ RAGFoundryError,
12
+ )
13
+ from .evaluation import EvalReport, EvalSample, load_dataset
14
+ from .graphs import IngestReport
15
+ from .loaders import load_sources, register_loader
16
+ from .pipeline import RAGPipeline, RAGResult
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "ConfigurationError",
22
+ "EvalReport",
23
+ "EvalSample",
24
+ "IngestReport",
25
+ "IngestionError",
26
+ "MissingDependencyError",
27
+ "RAGConfig",
28
+ "RAGFoundryError",
29
+ "RAGPipeline",
30
+ "RAGResult",
31
+ "chunk_documents",
32
+ "load_dataset",
33
+ "load_sources",
34
+ "register_chunker",
35
+ "register_loader",
36
+ ]
ragfoundry/_utils.py ADDED
@@ -0,0 +1,88 @@
1
+ """Small helpers shared across the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import re
8
+ from typing import Any
9
+
10
+ from langchain_core.messages import BaseMessage
11
+
12
+ from .errors import MissingDependencyError
13
+
14
+ _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
15
+ _METADATA_KEY = re.compile(r"[^0-9a-zA-Z_]")
16
+
17
+
18
+ def require(module: str, extra: str) -> Any:
19
+ """Import an optional module, or explain which extra provides it."""
20
+ try:
21
+ return importlib.import_module(module)
22
+ except ImportError as exc:
23
+ raise MissingDependencyError(
24
+ f"'{module}' is required for this feature. "
25
+ f"Install it with: pip install 'ragfoundry[{extra}]'"
26
+ ) from exc
27
+
28
+
29
+ def message_text(message: BaseMessage | str) -> str:
30
+ """Return the plain text of a model reply, without reasoning blocks."""
31
+ content: Any = message if isinstance(message, str) else message.content
32
+ if isinstance(content, list):
33
+ parts = []
34
+ for block in content:
35
+ if isinstance(block, str):
36
+ parts.append(block)
37
+ elif isinstance(block, dict) and block.get("type") == "text":
38
+ parts.append(str(block.get("text", "")))
39
+ content = "".join(parts)
40
+ return _THINK_BLOCK.sub("", str(content)).strip()
41
+
42
+
43
+ def parse_json(text: str) -> Any | None:
44
+ """Extract the first JSON array or object from model output, or ``None``."""
45
+ decoder = json.JSONDecoder()
46
+ for match in re.finditer(r"[\[{]", text):
47
+ try:
48
+ value, _ = decoder.raw_decode(text[match.start() :])
49
+ except json.JSONDecodeError:
50
+ continue
51
+ return value
52
+ return None
53
+
54
+
55
+ def string_items(value: Any) -> list[str] | None:
56
+ """Read a model's JSON list of strings, tolerating ``[{"claim": "..."}]`` style objects.
57
+
58
+ Returns ``None`` when the value is not a list at all.
59
+ """
60
+ if not isinstance(value, list):
61
+ return None
62
+ items = []
63
+ for item in value:
64
+ if isinstance(item, dict):
65
+ item = next((v for v in item.values() if isinstance(v, str)), None)
66
+ if isinstance(item, str) and item.strip():
67
+ items.append(item.strip())
68
+ return items
69
+
70
+
71
+ def sanitize_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
72
+ """Make metadata safe for every vector store.
73
+
74
+ Stores differ in what they accept, so keys are reduced to identifier characters, ``None``
75
+ values are dropped and non-scalar values are JSON encoded.
76
+ """
77
+ clean: dict[str, Any] = {}
78
+ for key, value in metadata.items():
79
+ if value is None:
80
+ continue
81
+ name = _METADATA_KEY.sub("_", str(key))
82
+ if not name or name[0].isdigit():
83
+ name = f"m_{name}"
84
+ if isinstance(value, (str, int, float, bool)):
85
+ clean[name] = value
86
+ else:
87
+ clean[name] = json.dumps(value, default=str, ensure_ascii=False)
88
+ return clean
@@ -0,0 +1,109 @@
1
+ """SQLite record of indexed chunks.
2
+
3
+ The record makes re-ingestion incremental, supplies the corpus for BM25, and detects an embedding
4
+ model change that would otherwise mix incompatible vectors in one collection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import sqlite3
12
+ from collections.abc import Iterable, Iterator
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+
16
+ from langchain_core.documents import Document
17
+
18
+ from .errors import ConfigurationError
19
+
20
+ _SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS chunks (
22
+ id TEXT PRIMARY KEY,
23
+ source TEXT NOT NULL,
24
+ content TEXT NOT NULL,
25
+ metadata TEXT NOT NULL
26
+ );
27
+ CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);
28
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
29
+ """
30
+
31
+
32
+ class ChunkStore:
33
+ """Durable map of chunk ids to their content. Safe to share between threads."""
34
+
35
+ def __init__(self, persist_dir: str | Path, collection: str) -> None:
36
+ directory = Path(persist_dir)
37
+ directory.mkdir(parents=True, exist_ok=True)
38
+ self.path = directory / f"{re.sub(r'[^0-9A-Za-z_-]', '_', collection)}.chunks.sqlite"
39
+ with self._connect() as conn:
40
+ conn.executescript(_SCHEMA)
41
+
42
+ @contextmanager
43
+ def _connect(self) -> Iterator[sqlite3.Connection]:
44
+ conn = sqlite3.connect(self.path, timeout=30)
45
+ try:
46
+ with conn:
47
+ yield conn
48
+ finally:
49
+ conn.close()
50
+
51
+ def ensure_fingerprint(self, fingerprint: str) -> None:
52
+ """Bind the collection to one embedding model, or fail if it already has another."""
53
+ with self._connect() as conn:
54
+ row = conn.execute("SELECT value FROM meta WHERE key='fingerprint'").fetchone()
55
+ if row is None:
56
+ conn.execute("INSERT INTO meta VALUES ('fingerprint', ?)", (fingerprint,))
57
+ elif row[0] != fingerprint:
58
+ raise ConfigurationError(
59
+ f"This collection was indexed with embeddings '{row[0]}' but the pipeline "
60
+ f"is configured with '{fingerprint}'. Use a new collection_name or re-index."
61
+ )
62
+
63
+ def revision(self) -> int:
64
+ with self._connect() as conn:
65
+ row = conn.execute("SELECT value FROM meta WHERE key='revision'").fetchone()
66
+ return int(row[0]) if row else 0
67
+
68
+ def ids_for_source(self, source: str) -> set[str]:
69
+ with self._connect() as conn:
70
+ rows = conn.execute("SELECT id FROM chunks WHERE source=?", (source,)).fetchall()
71
+ return {row[0] for row in rows}
72
+
73
+ def sources(self) -> dict[str, int]:
74
+ with self._connect() as conn:
75
+ rows = conn.execute(
76
+ "SELECT source, COUNT(*) FROM chunks GROUP BY source ORDER BY source"
77
+ ).fetchall()
78
+ return {row[0]: row[1] for row in rows}
79
+
80
+ def documents(self) -> list[Document]:
81
+ with self._connect() as conn:
82
+ rows = conn.execute("SELECT content, metadata FROM chunks ORDER BY id").fetchall()
83
+ return [Document(page_content=row[0], metadata=json.loads(row[1])) for row in rows]
84
+
85
+ def upsert(self, chunks: Iterable[Document]) -> None:
86
+ rows = [
87
+ (
88
+ chunk.metadata["chunk_id"],
89
+ str(chunk.metadata.get("source", "")),
90
+ chunk.page_content,
91
+ json.dumps(chunk.metadata, default=str, ensure_ascii=False),
92
+ )
93
+ for chunk in chunks
94
+ ]
95
+ with self._connect() as conn:
96
+ conn.executemany("INSERT OR REPLACE INTO chunks VALUES (?, ?, ?, ?)", rows)
97
+ self._bump(conn)
98
+
99
+ def delete(self, ids: Iterable[str]) -> None:
100
+ with self._connect() as conn:
101
+ conn.executemany("DELETE FROM chunks WHERE id=?", [(item,) for item in ids])
102
+ self._bump(conn)
103
+
104
+ @staticmethod
105
+ def _bump(conn: sqlite3.Connection) -> None:
106
+ conn.execute(
107
+ "INSERT INTO meta VALUES ('revision', '1') "
108
+ "ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1"
109
+ )
@@ -0,0 +1,23 @@
1
+ """Chunking strategies: fixed, recursive, semantic and LLM-guided."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import (
6
+ ChunkerResources,
7
+ assign_chunk_ids,
8
+ build_splitter,
9
+ chunk_documents,
10
+ register_chunker,
11
+ )
12
+ from .llm import LLMChunker
13
+ from .semantic import SemanticChunker
14
+
15
+ __all__ = [
16
+ "ChunkerResources",
17
+ "LLMChunker",
18
+ "SemanticChunker",
19
+ "assign_chunk_ids",
20
+ "build_splitter",
21
+ "chunk_documents",
22
+ "register_chunker",
23
+ ]
@@ -0,0 +1,20 @@
1
+ """Sentence segmentation that keeps offsets into the original text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _BOUNDARY = re.compile(r"(?<=[.!?。!?])\s+|\n\s*\n")
8
+
9
+
10
+ def sentence_spans(text: str) -> list[tuple[int, int]]:
11
+ """Return ``(start, end)`` offsets of non-blank sentences."""
12
+ spans = []
13
+ start = 0
14
+ for match in _BOUNDARY.finditer(text):
15
+ if text[start : match.start()].strip():
16
+ spans.append((start, match.start()))
17
+ start = match.end()
18
+ if text[start:].strip():
19
+ spans.append((start, len(text)))
20
+ return spans
@@ -0,0 +1,171 @@
1
+ """Chunker registry and stable chunk identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import uuid
7
+ from collections import Counter
8
+ from collections.abc import Callable, Sequence
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from langchain_core.documents import Document
13
+ from langchain_core.embeddings import Embeddings
14
+ from langchain_core.language_models import BaseChatModel
15
+ from langchain_text_splitters import (
16
+ RecursiveCharacterTextSplitter,
17
+ TextSplitter,
18
+ TokenTextSplitter,
19
+ )
20
+
21
+ from .._utils import require
22
+ from ..config import ChunkingConfig
23
+ from ..errors import ConfigurationError
24
+ from .llm import LLMChunker
25
+ from .semantic import SemanticChunker
26
+
27
+ _NAMESPACE = uuid.UUID("5f0c1e0a-7c55-4d0b-9c55-6f1f3f0d9a11")
28
+
29
+
30
+ @dataclass
31
+ class ChunkerResources:
32
+ """Models a strategy may need; resolved only by the strategies that use them."""
33
+
34
+ embeddings: Callable[[], Embeddings] | None = None
35
+ llm: Callable[[], BaseChatModel] | None = None
36
+
37
+ def get_embeddings(self) -> Embeddings:
38
+ if self.embeddings is None:
39
+ raise ConfigurationError("The 'semantic' strategy requires embeddings")
40
+ return self.embeddings()
41
+
42
+ def get_llm(self) -> BaseChatModel:
43
+ if self.llm is None:
44
+ raise ConfigurationError("The 'llm' strategy requires an LLM")
45
+ return self.llm()
46
+
47
+
48
+ class FixedWindowSplitter(TextSplitter):
49
+ """Exact sliding windows of ``chunk_size`` characters overlapping by ``chunk_overlap``."""
50
+
51
+ def __init__(self, **kwargs: Any) -> None:
52
+ kwargs["strip_whitespace"] = False
53
+ super().__init__(**kwargs)
54
+
55
+ def split_text(self, text: str) -> list[str]:
56
+ step = self._chunk_size - self._chunk_overlap
57
+ windows = (text[i : i + self._chunk_size] for i in range(0, len(text), step))
58
+ chunks = [window for window in windows if window.strip()]
59
+ # Drop a final window that lies entirely inside the previous one.
60
+ if len(chunks) > 1 and len(chunks[-1]) <= self._chunk_overlap:
61
+ chunks.pop()
62
+ return chunks
63
+
64
+
65
+ ChunkerFactory = Callable[[ChunkingConfig, ChunkerResources], TextSplitter]
66
+ _CHUNKERS: dict[str, ChunkerFactory] = {}
67
+
68
+
69
+ def register_chunker(name: str) -> Callable[[ChunkerFactory], ChunkerFactory]:
70
+ """Register a strategy selectable through ``chunking.strategy``."""
71
+
72
+ def decorator(factory: ChunkerFactory) -> ChunkerFactory:
73
+ _CHUNKERS[name] = factory
74
+ return factory
75
+
76
+ return decorator
77
+
78
+
79
+ def _length_function(config: ChunkingConfig) -> Callable[[str], int]:
80
+ if config.length_unit == "characters":
81
+ return len
82
+ tiktoken = require("tiktoken", "tokens")
83
+ encoding = tiktoken.get_encoding(config.encoding_name)
84
+ return lambda text: len(encoding.encode(text, disallowed_special=()))
85
+
86
+
87
+ @register_chunker("fixed")
88
+ def _fixed(config: ChunkingConfig, resources: ChunkerResources) -> TextSplitter:
89
+ if config.length_unit == "tokens":
90
+ require("tiktoken", "tokens")
91
+ return TokenTextSplitter(
92
+ encoding_name=config.encoding_name,
93
+ chunk_size=config.chunk_size,
94
+ chunk_overlap=config.chunk_overlap,
95
+ add_start_index=True,
96
+ )
97
+ return FixedWindowSplitter(
98
+ chunk_size=config.chunk_size, chunk_overlap=config.chunk_overlap, add_start_index=True
99
+ )
100
+
101
+
102
+ @register_chunker("recursive")
103
+ def _recursive(config: ChunkingConfig, resources: ChunkerResources) -> TextSplitter:
104
+ return RecursiveCharacterTextSplitter(
105
+ chunk_size=config.chunk_size,
106
+ chunk_overlap=config.chunk_overlap,
107
+ length_function=_length_function(config),
108
+ separators=config.separators,
109
+ add_start_index=True,
110
+ )
111
+
112
+
113
+ @register_chunker("semantic")
114
+ def _semantic(config: ChunkingConfig, resources: ChunkerResources) -> TextSplitter:
115
+ return SemanticChunker(
116
+ resources.get_embeddings(),
117
+ breakpoint_percentile=config.semantic.breakpoint_percentile,
118
+ buffer_size=config.semantic.buffer_size,
119
+ chunk_size=config.chunk_size,
120
+ chunk_overlap=config.chunk_overlap,
121
+ length_function=_length_function(config),
122
+ )
123
+
124
+
125
+ @register_chunker("llm")
126
+ def _llm(config: ChunkingConfig, resources: ChunkerResources) -> TextSplitter:
127
+ return LLMChunker(
128
+ resources.get_llm(),
129
+ window_chars=config.llm.window_chars,
130
+ chunk_size=config.chunk_size,
131
+ chunk_overlap=config.chunk_overlap,
132
+ length_function=_length_function(config),
133
+ )
134
+
135
+
136
+ def build_splitter(
137
+ config: ChunkingConfig, resources: ChunkerResources | None = None
138
+ ) -> TextSplitter:
139
+ factory = _CHUNKERS.get(config.strategy)
140
+ if factory is None:
141
+ raise ConfigurationError(
142
+ f"Unknown chunking strategy '{config.strategy}'. "
143
+ f"Available: {', '.join(sorted(_CHUNKERS))}"
144
+ )
145
+ return factory(config, resources or ChunkerResources())
146
+
147
+
148
+ def assign_chunk_ids(chunks: Sequence[Document]) -> list[Document]:
149
+ """Give each chunk an id derived from its source and text.
150
+
151
+ An unchanged chunk keeps its id across runs even if earlier text in the file changes, so
152
+ re-ingestion only embeds what is new. The occurrence count separates identical chunks.
153
+ """
154
+ occurrences: Counter[tuple[str, str]] = Counter()
155
+ for chunk in chunks:
156
+ source = str(chunk.metadata.get("source", ""))
157
+ digest = hashlib.sha256(chunk.page_content.encode("utf-8")).hexdigest()
158
+ occurrences[(source, digest)] += 1
159
+ key = f"{source}\0{digest}\0{occurrences[(source, digest)]}"
160
+ chunk.metadata["chunk_id"] = str(uuid.uuid5(_NAMESPACE, key))
161
+ return list(chunks)
162
+
163
+
164
+ def chunk_documents(
165
+ documents: Sequence[Document],
166
+ config: ChunkingConfig | None = None,
167
+ resources: ChunkerResources | None = None,
168
+ ) -> list[Document]:
169
+ """Split documents with the configured strategy and assign stable ids."""
170
+ splitter = build_splitter(config or ChunkingConfig(), resources)
171
+ return assign_chunk_ids(splitter.split_documents(list(documents)))
@@ -0,0 +1,88 @@
1
+ """Ask a chat model where the topic boundaries in a text are."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from langchain_core.language_models import BaseChatModel
9
+ from langchain_text_splitters import RecursiveCharacterTextSplitter, TextSplitter
10
+
11
+ from .._utils import message_text, parse_json
12
+ from ..prompts import LLM_CHUNK_PROMPT
13
+ from ._text import sentence_spans
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class LLMChunker(TextSplitter):
19
+ """Group sentences into topical chunks chosen by an LLM.
20
+
21
+ Long texts are processed in windows. The final group of each window is carried into the next,
22
+ so a topic is not cut merely because a window ended. If a reply cannot be parsed, that window
23
+ falls back to recursive splitting and a warning is logged.
24
+ """
25
+
26
+ def __init__(self, llm: BaseChatModel, window_chars: int = 8000, **kwargs: Any) -> None:
27
+ kwargs.setdefault("add_start_index", True)
28
+ super().__init__(**kwargs)
29
+ self._llm = llm
30
+ self._window_chars = window_chars
31
+ self._fallback = RecursiveCharacterTextSplitter(
32
+ chunk_size=self._chunk_size,
33
+ chunk_overlap=self._chunk_overlap,
34
+ length_function=self._length_function,
35
+ )
36
+
37
+ def split_text(self, text: str) -> list[str]:
38
+ spans = sentence_spans(text)
39
+ if len(spans) < 2:
40
+ return self._bounded(text)
41
+
42
+ chunks: list[str] = []
43
+ start = 0
44
+ while start < len(spans):
45
+ end = self._window_end(spans, start)
46
+ boundaries = self._ask(text, spans, start, end)
47
+ if boundaries is None:
48
+ logger.warning("Unusable LLM chunking reply; using recursive split for a window")
49
+ chunks.extend(self._fallback.split_text(text[spans[start][0] : spans[end - 1][1]]))
50
+ start = end
51
+ continue
52
+ finished = end == len(spans)
53
+ stop = end if finished or not boundaries else boundaries[-1]
54
+ edges = [start] + [b for b in boundaries if b < stop] + [stop]
55
+ for first, last in zip(edges, edges[1:], strict=False):
56
+ chunks.extend(self._bounded(text[spans[first][0] : spans[last - 1][1]]))
57
+ start = stop
58
+ return chunks
59
+
60
+ def _window_end(self, spans: list[tuple[int, int]], start: int) -> int:
61
+ end = start + 1
62
+ while end < len(spans) and spans[end][1] - spans[start][0] <= self._window_chars:
63
+ end += 1
64
+ return end
65
+
66
+ def _ask(
67
+ self, text: str, spans: list[tuple[int, int]], start: int, end: int
68
+ ) -> list[int] | None:
69
+ """Return sorted sentence indices inside ``(start, end)`` that open a chunk."""
70
+ if end - start < 2:
71
+ return []
72
+ numbered = "\n".join(
73
+ f"[{index}] {text[spans[index][0] : spans[index][1]].strip()}"
74
+ for index in range(start, end)
75
+ )
76
+ reply = self._llm.invoke(LLM_CHUNK_PROMPT.format(sentences=numbered))
77
+ parsed = parse_json(message_text(reply))
78
+ if not isinstance(parsed, list) or not all(isinstance(item, int) for item in parsed):
79
+ return None
80
+ return sorted({item for item in parsed if start < item < end})
81
+
82
+ def _bounded(self, text: str) -> list[str]:
83
+ text = text.strip()
84
+ if not text:
85
+ return []
86
+ if self._length_function(text) <= self._chunk_size:
87
+ return [text]
88
+ return self._fallback.split_text(text)
@@ -0,0 +1,67 @@
1
+ """Split text where the embedding similarity of neighbouring sentences drops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ from langchain_core.embeddings import Embeddings
9
+ from langchain_text_splitters import RecursiveCharacterTextSplitter, TextSplitter
10
+
11
+ from ._text import sentence_spans
12
+
13
+
14
+ class SemanticChunker(TextSplitter):
15
+ """Break at the largest semantic shifts; ``chunk_size`` is enforced as an upper bound."""
16
+
17
+ def __init__(
18
+ self,
19
+ embeddings: Embeddings,
20
+ breakpoint_percentile: float = 95,
21
+ buffer_size: int = 1,
22
+ **kwargs: Any,
23
+ ) -> None:
24
+ kwargs.setdefault("add_start_index", True)
25
+ super().__init__(**kwargs)
26
+ self._embeddings = embeddings
27
+ self._percentile = breakpoint_percentile
28
+ self._buffer = buffer_size
29
+ self._oversize = RecursiveCharacterTextSplitter(
30
+ chunk_size=self._chunk_size,
31
+ chunk_overlap=self._chunk_overlap,
32
+ length_function=self._length_function,
33
+ )
34
+
35
+ def split_text(self, text: str) -> list[str]:
36
+ spans = sentence_spans(text)
37
+ if len(spans) < 3:
38
+ return self._bounded(text)
39
+
40
+ windows = [
41
+ text[
42
+ spans[max(0, i - self._buffer)][0] : spans[min(len(spans) - 1, i + self._buffer)][1]
43
+ ]
44
+ for i in range(len(spans))
45
+ ]
46
+ vectors = np.asarray(self._embeddings.embed_documents(windows), dtype=float)
47
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
48
+ vectors = vectors / np.where(norms == 0, 1, norms)
49
+ distances = 1 - np.sum(vectors[:-1] * vectors[1:], axis=1)
50
+ threshold = np.percentile(distances, self._percentile)
51
+
52
+ chunks: list[str] = []
53
+ first = 0
54
+ for index, distance in enumerate(distances):
55
+ if distance >= threshold and distance > 0:
56
+ chunks.extend(self._bounded(text[spans[first][0] : spans[index][1]]))
57
+ first = index + 1
58
+ chunks.extend(self._bounded(text[spans[first][0] : spans[-1][1]]))
59
+ return chunks
60
+
61
+ def _bounded(self, text: str) -> list[str]:
62
+ text = text.strip()
63
+ if not text:
64
+ return []
65
+ if self._length_function(text) <= self._chunk_size:
66
+ return [text]
67
+ return self._oversize.split_text(text)