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.
- ragfoundry/__init__.py +36 -0
- ragfoundry/_utils.py +88 -0
- ragfoundry/chunk_store.py +109 -0
- ragfoundry/chunking/__init__.py +23 -0
- ragfoundry/chunking/_text.py +20 -0
- ragfoundry/chunking/base.py +171 -0
- ragfoundry/chunking/llm.py +88 -0
- ragfoundry/chunking/semantic.py +67 -0
- ragfoundry/cli.py +162 -0
- ragfoundry/config.py +248 -0
- ragfoundry/errors.py +19 -0
- ragfoundry/evaluation/__init__.py +35 -0
- ragfoundry/evaluation/dataset.py +46 -0
- ragfoundry/evaluation/generation.py +116 -0
- ragfoundry/evaluation/ragas_adapter.py +84 -0
- ragfoundry/evaluation/retrieval.py +71 -0
- ragfoundry/evaluation/runner.py +190 -0
- ragfoundry/graphs/__init__.py +14 -0
- ragfoundry/graphs/ingest.py +99 -0
- ragfoundry/graphs/query.py +197 -0
- ragfoundry/loaders/__init__.py +15 -0
- ragfoundry/loaders/base.py +162 -0
- ragfoundry/loaders/excel.py +64 -0
- ragfoundry/loaders/web.py +136 -0
- ragfoundry/models/__init__.py +64 -0
- ragfoundry/observability/__init__.py +65 -0
- ragfoundry/pipeline.py +353 -0
- ragfoundry/prompts.py +90 -0
- ragfoundry/retrieval/__init__.py +19 -0
- ragfoundry/retrieval/fusion.py +32 -0
- ragfoundry/retrieval/rerank.py +62 -0
- ragfoundry/retrieval/retriever.py +100 -0
- ragfoundry/retrieval/transforms.py +53 -0
- ragfoundry/vectorstores/__init__.py +8 -0
- ragfoundry/vectorstores/factory.py +232 -0
- ragfoundry/vectorstores/index.py +59 -0
- ragfoundry-0.1.0.dist-info/METADATA +802 -0
- ragfoundry-0.1.0.dist-info/RECORD +40 -0
- ragfoundry-0.1.0.dist-info/WHEEL +4 -0
- 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)
|