rag-blocks 0.6.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.
- rag_blocks/__init__.py +190 -0
- rag_blocks/chunking/__init__.py +17 -0
- rag_blocks/chunking/base.py +77 -0
- rag_blocks/chunking/fixed.py +69 -0
- rag_blocks/chunking/markdown.py +61 -0
- rag_blocks/core/__init__.py +66 -0
- rag_blocks/core/component.py +139 -0
- rag_blocks/core/contracts.py +389 -0
- rag_blocks/core/errors.py +84 -0
- rag_blocks/core/registry.py +128 -0
- rag_blocks/embedding/__init__.py +21 -0
- rag_blocks/embedding/base.py +60 -0
- rag_blocks/embedding/caching.py +116 -0
- rag_blocks/embedding/hashing.py +74 -0
- rag_blocks/embedding/sentence_transformer.py +99 -0
- rag_blocks/embedding/sparse.py +48 -0
- rag_blocks/enrichment/__init__.py +18 -0
- rag_blocks/enrichment/base.py +50 -0
- rag_blocks/enrichment/contextual.py +92 -0
- rag_blocks/enrichment/heading.py +57 -0
- rag_blocks/generation/__init__.py +16 -0
- rag_blocks/generation/anthropic_generator.py +119 -0
- rag_blocks/generation/base.py +53 -0
- rag_blocks/generation/extractive.py +36 -0
- rag_blocks/generation/packing.py +65 -0
- rag_blocks/indexing/__init__.py +13 -0
- rag_blocks/indexing/catalog.py +110 -0
- rag_blocks/indexing/chunk_index.py +228 -0
- rag_blocks/indexing/sink.py +31 -0
- rag_blocks/ingestion/__init__.py +28 -0
- rag_blocks/ingestion/detection.py +127 -0
- rag_blocks/ingestion/ocr/__init__.py +12 -0
- rag_blocks/ingestion/ocr/base.py +92 -0
- rag_blocks/ingestion/ocr/google_docai.py +77 -0
- rag_blocks/ingestion/ocr/mistral.py +86 -0
- rag_blocks/ingestion/parsers/__init__.py +6 -0
- rag_blocks/ingestion/parsers/auto.py +85 -0
- rag_blocks/ingestion/parsers/base.py +55 -0
- rag_blocks/ingestion/parsers/docling_parser.py +448 -0
- rag_blocks/ingestion/parsers/plaintext.py +70 -0
- rag_blocks/pipeline.py +476 -0
- rag_blocks/refinement/__init__.py +26 -0
- rag_blocks/refinement/base.py +43 -0
- rag_blocks/refinement/cross_encoder.py +71 -0
- rag_blocks/refinement/keyword.py +51 -0
- rag_blocks/refinement/neighbor.py +147 -0
- rag_blocks/refinement/threshold.py +35 -0
- rag_blocks/retrieval/__init__.py +26 -0
- rag_blocks/retrieval/base.py +44 -0
- rag_blocks/retrieval/fusion.py +93 -0
- rag_blocks/retrieval/fusion_retriever.py +82 -0
- rag_blocks/retrieval/hybrid.py +79 -0
- rag_blocks/retrieval/index_retriever.py +80 -0
- rag_blocks/retrieval/query_shaping.py +136 -0
- rag_blocks/storage/__init__.py +26 -0
- rag_blocks/storage/base.py +94 -0
- rag_blocks/storage/bm25_index.py +164 -0
- rag_blocks/storage/lexical_index.py +45 -0
- rag_blocks/storage/local.py +104 -0
- rag_blocks/storage/memory_store.py +180 -0
- rag_blocks/storage/minio_store.py +187 -0
- rag_blocks/storage/qdrant_store.py +415 -0
- rag_blocks/storage/vector_store.py +109 -0
- rag_blocks-0.6.0.dist-info/METADATA +308 -0
- rag_blocks-0.6.0.dist-info/RECORD +67 -0
- rag_blocks-0.6.0.dist-info/WHEEL +4 -0
- rag_blocks-0.6.0.dist-info/licenses/LICENSE +201 -0
rag_blocks/__init__.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""rag-blocks: composable building blocks for production RAG pipelines.
|
|
2
|
+
|
|
3
|
+
Design in one paragraph: stages are swappable Strategies registered under a
|
|
4
|
+
(kind, name) key; they communicate only through typed data contracts
|
|
5
|
+
(Source → Page → Document → Chunk → …); ingestion is streaming-first so
|
|
6
|
+
memory never scales with document size; every component carries a config
|
|
7
|
+
fingerprint that later powers the auto-tuning suite's cross-pipeline caching.
|
|
8
|
+
|
|
9
|
+
Quick start:
|
|
10
|
+
|
|
11
|
+
import rag_blocks as rk
|
|
12
|
+
|
|
13
|
+
doc = rk.ingest("report.pdf") # sane defaults
|
|
14
|
+
doc = rk.ingest("scan.pdf", ocr_engine="mistral", # cloud OCR
|
|
15
|
+
ocr_policy=rk.OcrPolicy.FORCE)
|
|
16
|
+
|
|
17
|
+
parser = rk.AutoParser() # streaming access
|
|
18
|
+
for page in parser.iter_pages(rk.Source.from_path("huge.pdf")):
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .core import (
|
|
27
|
+
Answer,
|
|
28
|
+
Chunk,
|
|
29
|
+
Citation,
|
|
30
|
+
Component,
|
|
31
|
+
Document,
|
|
32
|
+
EmbeddingError,
|
|
33
|
+
GenerationError,
|
|
34
|
+
Page,
|
|
35
|
+
PageSpan,
|
|
36
|
+
Query,
|
|
37
|
+
RagToolkitError,
|
|
38
|
+
ScoredChunk,
|
|
39
|
+
Source,
|
|
40
|
+
SourceFormat,
|
|
41
|
+
SparseVector,
|
|
42
|
+
StorageError,
|
|
43
|
+
VectorSpec,
|
|
44
|
+
VectorValue,
|
|
45
|
+
registry,
|
|
46
|
+
)
|
|
47
|
+
from .embedding import (
|
|
48
|
+
CachingEmbedder,
|
|
49
|
+
Embedder,
|
|
50
|
+
HashingEmbedder,
|
|
51
|
+
SentenceTransformerEmbedder,
|
|
52
|
+
SparseEncoder,
|
|
53
|
+
)
|
|
54
|
+
from .indexing import ChunkIndex, ChunkSink, DocumentCatalog, DocumentRef
|
|
55
|
+
from .enrichment import (
|
|
56
|
+
ContextualEnricher,
|
|
57
|
+
Enricher,
|
|
58
|
+
HeadingEnricher,
|
|
59
|
+
)
|
|
60
|
+
from .generation import AnthropicGenerator, ExtractiveGenerator, Generator
|
|
61
|
+
from .retrieval import (
|
|
62
|
+
FusionRetriever,
|
|
63
|
+
HybridRetriever,
|
|
64
|
+
HydeRetriever,
|
|
65
|
+
IndexRetriever,
|
|
66
|
+
MultiQueryRetriever,
|
|
67
|
+
Retriever,
|
|
68
|
+
)
|
|
69
|
+
from .ingestion import (
|
|
70
|
+
AutoParser,
|
|
71
|
+
DoclingParser,
|
|
72
|
+
OcrEngine,
|
|
73
|
+
OcrPolicy,
|
|
74
|
+
OcrResult,
|
|
75
|
+
PageImage,
|
|
76
|
+
Parser,
|
|
77
|
+
PlainTextParser,
|
|
78
|
+
detect_format,
|
|
79
|
+
)
|
|
80
|
+
from .chunking import Chunker, FixedChunker, MarkdownChunker
|
|
81
|
+
from .refinement import (
|
|
82
|
+
CrossEncoderReranker,
|
|
83
|
+
KeywordRefiner,
|
|
84
|
+
NeighborExpander,
|
|
85
|
+
Refiner,
|
|
86
|
+
ScoreThreshold,
|
|
87
|
+
)
|
|
88
|
+
from .pipeline import IndexingPipeline, QueryPipeline, RagPipeline, TraceEvent
|
|
89
|
+
from .storage import (
|
|
90
|
+
BM25Index,
|
|
91
|
+
BlobStore,
|
|
92
|
+
LexicalIndex,
|
|
93
|
+
LocalBlobStore,
|
|
94
|
+
MemoryVectorStore,
|
|
95
|
+
MinioBlobStore,
|
|
96
|
+
QdrantVectorStore,
|
|
97
|
+
VectorStore,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
__version__ = "0.6.0"
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"ingest",
|
|
104
|
+
"registry",
|
|
105
|
+
"Component",
|
|
106
|
+
"Source",
|
|
107
|
+
"SourceFormat",
|
|
108
|
+
"Page",
|
|
109
|
+
"PageSpan",
|
|
110
|
+
"Document",
|
|
111
|
+
"Chunk",
|
|
112
|
+
"Query",
|
|
113
|
+
"ScoredChunk",
|
|
114
|
+
"Citation",
|
|
115
|
+
"Answer",
|
|
116
|
+
"Parser",
|
|
117
|
+
"AutoParser",
|
|
118
|
+
"DoclingParser",
|
|
119
|
+
"PlainTextParser",
|
|
120
|
+
"OcrEngine",
|
|
121
|
+
"OcrPolicy",
|
|
122
|
+
"OcrResult",
|
|
123
|
+
"PageImage",
|
|
124
|
+
"detect_format",
|
|
125
|
+
"Chunker",
|
|
126
|
+
"FixedChunker",
|
|
127
|
+
"MarkdownChunker",
|
|
128
|
+
"Embedder",
|
|
129
|
+
"SparseEncoder",
|
|
130
|
+
"CachingEmbedder",
|
|
131
|
+
"HashingEmbedder",
|
|
132
|
+
"SentenceTransformerEmbedder",
|
|
133
|
+
"SparseVector",
|
|
134
|
+
"VectorSpec",
|
|
135
|
+
"VectorValue",
|
|
136
|
+
"ChunkIndex",
|
|
137
|
+
"ChunkSink",
|
|
138
|
+
"DocumentCatalog",
|
|
139
|
+
"DocumentRef",
|
|
140
|
+
"Enricher",
|
|
141
|
+
"HeadingEnricher",
|
|
142
|
+
"ContextualEnricher",
|
|
143
|
+
"BlobStore",
|
|
144
|
+
"LocalBlobStore",
|
|
145
|
+
"MinioBlobStore",
|
|
146
|
+
"VectorStore",
|
|
147
|
+
"MemoryVectorStore",
|
|
148
|
+
"QdrantVectorStore",
|
|
149
|
+
"LexicalIndex",
|
|
150
|
+
"BM25Index",
|
|
151
|
+
"Retriever",
|
|
152
|
+
"IndexRetriever",
|
|
153
|
+
"FusionRetriever",
|
|
154
|
+
"HybridRetriever",
|
|
155
|
+
"MultiQueryRetriever",
|
|
156
|
+
"HydeRetriever",
|
|
157
|
+
"Refiner",
|
|
158
|
+
"CrossEncoderReranker",
|
|
159
|
+
"KeywordRefiner",
|
|
160
|
+
"NeighborExpander",
|
|
161
|
+
"ScoreThreshold",
|
|
162
|
+
"Generator",
|
|
163
|
+
"ExtractiveGenerator",
|
|
164
|
+
"AnthropicGenerator",
|
|
165
|
+
"IndexingPipeline",
|
|
166
|
+
"QueryPipeline",
|
|
167
|
+
"RagPipeline",
|
|
168
|
+
"TraceEvent",
|
|
169
|
+
"RagToolkitError",
|
|
170
|
+
"StorageError",
|
|
171
|
+
"EmbeddingError",
|
|
172
|
+
"GenerationError",
|
|
173
|
+
"EnrichmentError",
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ingest(path: str | Path, **docling_overrides) -> Document:
|
|
178
|
+
"""One-call Facade: file path → markdown Document with provenance.
|
|
179
|
+
|
|
180
|
+
Keyword arguments are forwarded to the PDF/office parser (DoclingParser),
|
|
181
|
+
since that is where 95% of tuning happens:
|
|
182
|
+
|
|
183
|
+
ingest("scan.pdf", ocr_engine="mistral", page_batch_size=4)
|
|
184
|
+
|
|
185
|
+
For full control (custom routes, streaming, in-memory sources), drop one
|
|
186
|
+
level down to AutoParser / DoclingParser directly.
|
|
187
|
+
"""
|
|
188
|
+
parser_configs = {"docling": docling_overrides} if docling_overrides else {}
|
|
189
|
+
parser = AutoParser(parser_configs=parser_configs)
|
|
190
|
+
return parser.parse(Source.from_path(path))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Chunking subsystem: Document → stream of retrieval Chunks.
|
|
2
|
+
|
|
3
|
+
Importing this package registers the built-in chunkers (module import is the
|
|
4
|
+
registration side effect the registry relies on). Strategies decide only WHERE
|
|
5
|
+
to cut (char-offset spans); the Template Method in `base.Chunker.chunk` owns all
|
|
6
|
+
bookkeeping and provenance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .base import Chunker
|
|
10
|
+
from .fixed import FixedChunker
|
|
11
|
+
from .markdown import MarkdownChunker
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Chunker",
|
|
15
|
+
"FixedChunker",
|
|
16
|
+
"MarkdownChunker",
|
|
17
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Chunker: the retrieval-unit Strategy interface.
|
|
2
|
+
|
|
3
|
+
The single design decision mirrored from the Parser: the *primitive* a strategy
|
|
4
|
+
implements is `iter_spans(document) -> Iterator[(start, end)]` — WHERE to cut,
|
|
5
|
+
as half-open character offsets into `document.markdown`. `chunk()` is a Template
|
|
6
|
+
Method layered on top that does every piece of bookkeeping once, correctly, for
|
|
7
|
+
every strategy.
|
|
8
|
+
|
|
9
|
+
Why spans (coordinates), not strings (copies)?
|
|
10
|
+
Return strings and you throw away everything that makes RAG citations work:
|
|
11
|
+
provenance (which chars → which pages), overlap (two chunks sharing a
|
|
12
|
+
region), and neighbor merging (fetch index±1 at query time). All three fall
|
|
13
|
+
out naturally when a strategy only decides *coordinates* and the base class
|
|
14
|
+
owns the slicing. This is the whole reason the interface emits offsets.
|
|
15
|
+
|
|
16
|
+
What the Template Method guarantees (so strategies never re-implement it):
|
|
17
|
+
- `text = document.markdown[start:end]` — the base slices, not the strategy.
|
|
18
|
+
- whitespace-only slices are skipped WITHOUT advancing the index, so
|
|
19
|
+
`index` stays contiguous 0-based with NO holes (a manual counter, never
|
|
20
|
+
`enumerate` over raw spans). Query-time neighbor expansion fetches
|
|
21
|
+
`index ± 1`, so a hole would silently drop a real neighbor.
|
|
22
|
+
- `id = f"{document.id}:{index}"` — deterministic ⇒ re-indexing overwrites
|
|
23
|
+
instead of duplicating.
|
|
24
|
+
- `char_start`/`char_end` are the primary provenance; `page_start`/
|
|
25
|
+
`page_end` are derived from them via `Document.pages_for_span` and are
|
|
26
|
+
ALWAYS filled for a doc-derived chunk.
|
|
27
|
+
|
|
28
|
+
Contract for strategies: yield spans in reading order of `start`. Overlapping
|
|
29
|
+
spans are LEGAL (that is how overlap strategies express themselves — in
|
|
30
|
+
coordinates). Strategies are configured by their `Config`, never by subclassing.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from abc import abstractmethod
|
|
36
|
+
from typing import Iterator
|
|
37
|
+
|
|
38
|
+
from ..core.component import Component
|
|
39
|
+
from ..core.contracts import Chunk, Document
|
|
40
|
+
|
|
41
|
+
__all__ = ["Chunker"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Chunker(Component):
|
|
45
|
+
"""Turns a Document into a stream of retrieval Chunks."""
|
|
46
|
+
|
|
47
|
+
kind = "chunker"
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
|
|
51
|
+
"""Yield half-open `(start, end)` char offsets into
|
|
52
|
+
`document.markdown`, in reading order of `start`. Overlaps allowed."""
|
|
53
|
+
|
|
54
|
+
def chunk(self, document: Document) -> Iterator[Chunk]:
|
|
55
|
+
"""Template Method: turn spans into Chunks, owning all bookkeeping.
|
|
56
|
+
|
|
57
|
+
Strategies implement `iter_spans`; this method — written once — is the
|
|
58
|
+
only place chunk ids, index contiguity, and provenance are decided.
|
|
59
|
+
"""
|
|
60
|
+
index = 0
|
|
61
|
+
for start, end in self.iter_spans(document):
|
|
62
|
+
text = document.markdown[start:end]
|
|
63
|
+
if not text.strip():
|
|
64
|
+
# Skip empties WITHOUT advancing index (contiguity, no holes).
|
|
65
|
+
continue
|
|
66
|
+
pages = document.pages_for_span(start, end)
|
|
67
|
+
yield Chunk(
|
|
68
|
+
id=f"{document.id}:{index}",
|
|
69
|
+
doc_id=document.id,
|
|
70
|
+
text=text,
|
|
71
|
+
index=index,
|
|
72
|
+
char_start=start,
|
|
73
|
+
char_end=end,
|
|
74
|
+
page_start=pages[0] if pages else None,
|
|
75
|
+
page_end=pages[-1] if pages else None,
|
|
76
|
+
)
|
|
77
|
+
index += 1
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""FixedChunker: fixed-size character windows with overlap.
|
|
2
|
+
|
|
3
|
+
The workhorse baseline: walk the markdown in windows of ~`chunk_chars`, with
|
|
4
|
+
`overlap_chars` of trailing context repeated into the next window so a fact
|
|
5
|
+
split across a boundary still lands whole in at least one chunk.
|
|
6
|
+
|
|
7
|
+
The one bit of finesse — cut on a boundary, not mid-sentence — reuses the exact
|
|
8
|
+
logic proven in `PlainTextParser._cut_point`: prefer to end a window at a
|
|
9
|
+
paragraph break (`\n\n`), else a line break (`\n`), but REFUSE a soft cut that
|
|
10
|
+
would leave a chunk shorter than half the target (otherwise a document full of
|
|
11
|
+
short lines would produce a swarm of tiny chunks). No boundary in range ⇒ a
|
|
12
|
+
hard cut at `chunk_chars`.
|
|
13
|
+
|
|
14
|
+
Overlap is expressed purely in coordinates (the next window starts
|
|
15
|
+
`overlap_chars` before the previous end), which is exactly why the Chunker
|
|
16
|
+
interface emits spans rather than strings — overlap and provenance survive.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Iterator
|
|
23
|
+
|
|
24
|
+
from ..core.contracts import Document
|
|
25
|
+
from ..core.registry import registry
|
|
26
|
+
from .base import Chunker
|
|
27
|
+
|
|
28
|
+
__all__ = ["FixedChunker"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@registry.register
|
|
32
|
+
class FixedChunker(Chunker):
|
|
33
|
+
name = "fixed"
|
|
34
|
+
version = "0.1.0"
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Config:
|
|
38
|
+
chunk_chars: int = 1600
|
|
39
|
+
overlap_chars: int = 200
|
|
40
|
+
|
|
41
|
+
def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
|
|
42
|
+
text = document.markdown
|
|
43
|
+
n = len(text)
|
|
44
|
+
start = 0
|
|
45
|
+
while start < n:
|
|
46
|
+
end = self._cut_end(text, start, n)
|
|
47
|
+
yield start, end
|
|
48
|
+
if end >= n:
|
|
49
|
+
break
|
|
50
|
+
# Step back by the overlap for the next window; guarantee forward
|
|
51
|
+
# progress even under a pathological overlap >= window size.
|
|
52
|
+
next_start = end - self.config.overlap_chars
|
|
53
|
+
start = next_start if next_start > start else end
|
|
54
|
+
|
|
55
|
+
def _cut_end(self, text: str, start: int, n: int) -> int:
|
|
56
|
+
"""Where to end the window that begins at `start` (mirror of
|
|
57
|
+
PlainTextParser._cut_point, adapted to absolute offsets)."""
|
|
58
|
+
hard_end = start + self.config.chunk_chars
|
|
59
|
+
if hard_end >= n:
|
|
60
|
+
return n
|
|
61
|
+
# Only accept a soft cut in the back half of the window.
|
|
62
|
+
floor = start + self.config.chunk_chars // 2
|
|
63
|
+
para = text.rfind("\n\n", floor, hard_end)
|
|
64
|
+
if para != -1:
|
|
65
|
+
return para + 2
|
|
66
|
+
line = text.rfind("\n", floor, hard_end)
|
|
67
|
+
if line != -1:
|
|
68
|
+
return line + 1
|
|
69
|
+
return hard_end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""MarkdownChunker: cut on heading boundaries.
|
|
2
|
+
|
|
3
|
+
This is the payoff of normalizing every input to markdown during ingestion:
|
|
4
|
+
document *structure* survives all the way to the cutting decision. Instead of
|
|
5
|
+
slicing blindly every N characters, we cut at ATX headings (`#`..`######`), so
|
|
6
|
+
each chunk is a coherent section that starts with its own heading — far better
|
|
7
|
+
retrieval units than arbitrary windows.
|
|
8
|
+
|
|
9
|
+
Coordinates only (like every Chunker): we locate heading line offsets and emit
|
|
10
|
+
the spans *between* them. Content before the first heading is its own span; a
|
|
11
|
+
document with no headings yields a single span covering the whole thing. The
|
|
12
|
+
base Template Method then skips any whitespace-only section without disturbing
|
|
13
|
+
index contiguity.
|
|
14
|
+
|
|
15
|
+
Deliberately simple for v0.2: no secondary size cap on a very long section yet
|
|
16
|
+
(a `max_chars` split is an easy, non-breaking add later). Structure first.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from typing import Iterator
|
|
23
|
+
|
|
24
|
+
from ..core.contracts import Document
|
|
25
|
+
from ..core.registry import registry
|
|
26
|
+
from .base import Chunker
|
|
27
|
+
|
|
28
|
+
__all__ = ["MarkdownChunker"]
|
|
29
|
+
|
|
30
|
+
#: An ATX heading: 1–6 '#', then whitespace, then the title. Up to three
|
|
31
|
+
#: leading spaces are allowed by CommonMark; `# no-space` is NOT a heading.
|
|
32
|
+
_HEADING = re.compile(r"^ {0,3}#{1,6}\s")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_heading(line: str) -> bool:
|
|
36
|
+
return _HEADING.match(line) is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@registry.register
|
|
40
|
+
class MarkdownChunker(Chunker):
|
|
41
|
+
name = "markdown-aware"
|
|
42
|
+
version = "0.1.0"
|
|
43
|
+
|
|
44
|
+
def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
|
|
45
|
+
text = document.markdown
|
|
46
|
+
n = len(text)
|
|
47
|
+
if n == 0:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Collect cut points: the document start, plus every heading's offset.
|
|
51
|
+
cut_points = [0]
|
|
52
|
+
offset = 0
|
|
53
|
+
for line in text.splitlines(keepends=True):
|
|
54
|
+
if offset != 0 and _is_heading(line):
|
|
55
|
+
cut_points.append(offset)
|
|
56
|
+
offset += len(line)
|
|
57
|
+
cut_points.append(n)
|
|
58
|
+
|
|
59
|
+
for start, end in zip(cut_points, cut_points[1:]):
|
|
60
|
+
if end > start: # consecutive headings can coincide; skip empties
|
|
61
|
+
yield start, end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Core layer: contracts, component model, registry, errors.
|
|
2
|
+
|
|
3
|
+
Nothing in here knows about RAG specifics — it is the framework the stages
|
|
4
|
+
are built on. Zero third-party dependencies by design.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .component import Component
|
|
8
|
+
from .contracts import (
|
|
9
|
+
Answer,
|
|
10
|
+
Chunk,
|
|
11
|
+
Citation,
|
|
12
|
+
Document,
|
|
13
|
+
Page,
|
|
14
|
+
PageSpan,
|
|
15
|
+
Query,
|
|
16
|
+
ScoredChunk,
|
|
17
|
+
Source,
|
|
18
|
+
SourceFormat,
|
|
19
|
+
SparseVector,
|
|
20
|
+
VectorSpec,
|
|
21
|
+
VectorValue,
|
|
22
|
+
)
|
|
23
|
+
from .errors import (
|
|
24
|
+
ComponentNotFoundError,
|
|
25
|
+
ConfigError,
|
|
26
|
+
DuplicateComponentError,
|
|
27
|
+
EmbeddingError,
|
|
28
|
+
EnrichmentError,
|
|
29
|
+
GenerationError,
|
|
30
|
+
OcrError,
|
|
31
|
+
ParseError,
|
|
32
|
+
RagToolkitError,
|
|
33
|
+
StorageError,
|
|
34
|
+
UnsupportedFormatError,
|
|
35
|
+
)
|
|
36
|
+
from .registry import Registry, registry
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Component",
|
|
40
|
+
"Registry",
|
|
41
|
+
"registry",
|
|
42
|
+
"Source",
|
|
43
|
+
"SourceFormat",
|
|
44
|
+
"Page",
|
|
45
|
+
"PageSpan",
|
|
46
|
+
"Document",
|
|
47
|
+
"Chunk",
|
|
48
|
+
"Query",
|
|
49
|
+
"ScoredChunk",
|
|
50
|
+
"Citation",
|
|
51
|
+
"Answer",
|
|
52
|
+
"SparseVector",
|
|
53
|
+
"VectorSpec",
|
|
54
|
+
"VectorValue",
|
|
55
|
+
"RagToolkitError",
|
|
56
|
+
"ComponentNotFoundError",
|
|
57
|
+
"DuplicateComponentError",
|
|
58
|
+
"ConfigError",
|
|
59
|
+
"UnsupportedFormatError",
|
|
60
|
+
"ParseError",
|
|
61
|
+
"OcrError",
|
|
62
|
+
"StorageError",
|
|
63
|
+
"EmbeddingError",
|
|
64
|
+
"GenerationError",
|
|
65
|
+
"EnrichmentError",
|
|
66
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Component: the common base of every swappable building block.
|
|
2
|
+
|
|
3
|
+
Why one base class?
|
|
4
|
+
-------------------
|
|
5
|
+
Every stage implementation (a parser, an OCR engine, a chunker, a reranker…)
|
|
6
|
+
needs the same plumbing: a (kind, name) identity for the registry, a typed
|
|
7
|
+
config, and a stable *fingerprint*. Centralizing that here keeps each concrete
|
|
8
|
+
component focused purely on its algorithm (Single Responsibility), and it
|
|
9
|
+
gives the evaluation suite a uniform way to describe/cache/compare components
|
|
10
|
+
without knowing anything about them (Dependency Inversion: the tuner depends
|
|
11
|
+
on `Component`, never on `DoclingParser`).
|
|
12
|
+
|
|
13
|
+
The fingerprint is the quiet superpower: sha256(kind | name | version |
|
|
14
|
+
canonical-config). Two pipeline variants that share the same parser config
|
|
15
|
+
share the same fingerprint ⇒ the tuner can reuse the cached parse output
|
|
16
|
+
instead of re-parsing 5 GB of PDFs per combination. Bump `version` whenever
|
|
17
|
+
you change a component's behavior so stale caches invalidate themselves.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import dataclasses
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from abc import ABC
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Any, ClassVar, Optional, Type
|
|
28
|
+
|
|
29
|
+
from .errors import ConfigError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _plain(value: Any) -> Any:
|
|
33
|
+
"""Normalize config values to log/JSON-friendly primitives.
|
|
34
|
+
|
|
35
|
+
Enums become their `.value` (so `OcrPolicy.AUTO` logs as "auto"), and
|
|
36
|
+
containers are walked recursively. Keeps describe() output — and thus
|
|
37
|
+
trial logs — clean and diffable.
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(value, Enum):
|
|
40
|
+
return value.value
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {k: _plain(v) for k, v in value.items()}
|
|
43
|
+
if isinstance(value, (list, tuple)):
|
|
44
|
+
return [_plain(v) for v in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
__all__ = ["Component"]
|
|
48
|
+
|
|
49
|
+
# Config field names containing these substrings are redacted from
|
|
50
|
+
# describe()/fingerprint(): secrets must never leak into logs or cache keys,
|
|
51
|
+
# and rotating an API key must not invalidate caches.
|
|
52
|
+
_SECRET_MARKERS = ("key", "token", "secret", "password", "credential")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Component(ABC):
|
|
56
|
+
"""Base class for every pluggable building block.
|
|
57
|
+
|
|
58
|
+
Subclass contract:
|
|
59
|
+
kind: the stage slot this fills ("parser", "ocr", "chunker", ...)
|
|
60
|
+
name: unique name within the kind ("docling", "mistral", ...)
|
|
61
|
+
version: bump on behavior changes (cache invalidation)
|
|
62
|
+
Config: optional dataclass type describing the configuration
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
kind: ClassVar[str]
|
|
66
|
+
name: ClassVar[str]
|
|
67
|
+
version: ClassVar[str] = "0.1.0"
|
|
68
|
+
Config: ClassVar[Optional[Type[Any]]] = None
|
|
69
|
+
|
|
70
|
+
#: The resolved config instance (or None when Config is undeclared).
|
|
71
|
+
#: Declared as Any so subclasses read `self.config.<field>` without mypy
|
|
72
|
+
#: narrowing it to `Any | None` at every access — the config's real shape
|
|
73
|
+
#: is the nested `Config` dataclass, checked where it is constructed.
|
|
74
|
+
config: Any
|
|
75
|
+
|
|
76
|
+
def __init__(self, config: Any = None, **overrides: Any) -> None:
|
|
77
|
+
if self.Config is None:
|
|
78
|
+
if config is not None or overrides:
|
|
79
|
+
raise ConfigError(
|
|
80
|
+
f"{type(self).__name__} takes no configuration"
|
|
81
|
+
)
|
|
82
|
+
self.config = None
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if config is not None and not isinstance(config, self.Config):
|
|
86
|
+
raise ConfigError(
|
|
87
|
+
f"{type(self).__name__} expected config of type "
|
|
88
|
+
f"{self.Config.__name__}, got {type(config).__name__}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Ergonomics: accept a ready Config, keyword overrides, or both.
|
|
92
|
+
# `MyParser(page_batch_size=4)` beats building a config object by hand.
|
|
93
|
+
base = config if config is not None else self._default_config()
|
|
94
|
+
try:
|
|
95
|
+
self.config = (
|
|
96
|
+
dataclasses.replace(base, **overrides) if overrides else base
|
|
97
|
+
)
|
|
98
|
+
except TypeError as exc: # unknown field name → clear error, fail fast
|
|
99
|
+
raise ConfigError(f"{type(self).__name__}: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
def _default_config(self) -> Any:
|
|
102
|
+
try:
|
|
103
|
+
return self.Config() # type: ignore[misc]
|
|
104
|
+
except TypeError as exc:
|
|
105
|
+
raise ConfigError(
|
|
106
|
+
f"{type(self).__name__}.Config has required fields; "
|
|
107
|
+
f"pass them explicitly: {exc}"
|
|
108
|
+
) from exc
|
|
109
|
+
|
|
110
|
+
# -- identity ------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def describe(self) -> dict:
|
|
113
|
+
"""Loggable, secret-free description of this exact component setup.
|
|
114
|
+
|
|
115
|
+
This dict is what lands in every evaluation Trial record, so a result
|
|
116
|
+
is always reproducible from its log line alone.
|
|
117
|
+
"""
|
|
118
|
+
cfg: dict[str, Any] = {}
|
|
119
|
+
if self.config is not None:
|
|
120
|
+
for k, v in dataclasses.asdict(self.config).items():
|
|
121
|
+
lowered = k.lower()
|
|
122
|
+
if any(m in lowered for m in _SECRET_MARKERS):
|
|
123
|
+
cfg[k] = "<redacted>"
|
|
124
|
+
else:
|
|
125
|
+
cfg[k] = _plain(v)
|
|
126
|
+
return {
|
|
127
|
+
"kind": self.kind,
|
|
128
|
+
"name": self.name,
|
|
129
|
+
"version": self.version,
|
|
130
|
+
"config": cfg,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def fingerprint(self) -> str:
|
|
134
|
+
"""Stable hash of (kind, name, version, config) — the cache key."""
|
|
135
|
+
canonical = json.dumps(self.describe(), sort_keys=True, default=str)
|
|
136
|
+
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str: # helpful in logs and notebooks
|
|
139
|
+
return f"<{type(self).__name__} {self.kind}:{self.name} v{self.version}>"
|