rag-blocks 0.6.0__tar.gz
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-0.6.0/.github/workflows/ci.yml +24 -0
- rag_blocks-0.6.0/.gitignore +89 -0
- rag_blocks-0.6.0/AGENTS.md +563 -0
- rag_blocks-0.6.0/ARCHITECTURE.md +493 -0
- rag_blocks-0.6.0/CHANGELOG.md +74 -0
- rag_blocks-0.6.0/CLAUDE.md +11 -0
- rag_blocks-0.6.0/LICENSE +201 -0
- rag_blocks-0.6.0/PKG-INFO +308 -0
- rag_blocks-0.6.0/README.md +260 -0
- rag_blocks-0.6.0/conftest.py +7 -0
- rag_blocks-0.6.0/docs/DR-0001-chunk-index.md +799 -0
- rag_blocks-0.6.0/docs/GUIDE.md +173 -0
- rag_blocks-0.6.0/docs/guide/01-getting-started.md +179 -0
- rag_blocks-0.6.0/docs/guide/02-concepts-and-architecture.md +176 -0
- rag_blocks-0.6.0/docs/guide/03-data-contracts.md +189 -0
- rag_blocks-0.6.0/docs/guide/04-ingestion-and-chunking.md +154 -0
- rag_blocks-0.6.0/docs/guide/05-representations-and-storage.md +282 -0
- rag_blocks-0.6.0/docs/guide/06-retrieval-and-refinement.md +177 -0
- rag_blocks-0.6.0/docs/guide/07-generation-and-citations.md +120 -0
- rag_blocks-0.6.0/docs/guide/08-pipelines.md +152 -0
- rag_blocks-0.6.0/docs/guide/09-extending-and-testing.md +168 -0
- rag_blocks-0.6.0/docs/guide/10-recipes.md +345 -0
- rag_blocks-0.6.0/docs/guide/README.md +47 -0
- rag_blocks-0.6.0/examples/quickstart.py +65 -0
- rag_blocks-0.6.0/pyproject.toml +63 -0
- rag_blocks-0.6.0/rag_blocks/__init__.py +190 -0
- rag_blocks-0.6.0/rag_blocks/chunking/__init__.py +17 -0
- rag_blocks-0.6.0/rag_blocks/chunking/base.py +77 -0
- rag_blocks-0.6.0/rag_blocks/chunking/fixed.py +69 -0
- rag_blocks-0.6.0/rag_blocks/chunking/markdown.py +61 -0
- rag_blocks-0.6.0/rag_blocks/core/__init__.py +66 -0
- rag_blocks-0.6.0/rag_blocks/core/component.py +139 -0
- rag_blocks-0.6.0/rag_blocks/core/contracts.py +389 -0
- rag_blocks-0.6.0/rag_blocks/core/errors.py +84 -0
- rag_blocks-0.6.0/rag_blocks/core/registry.py +128 -0
- rag_blocks-0.6.0/rag_blocks/embedding/__init__.py +21 -0
- rag_blocks-0.6.0/rag_blocks/embedding/base.py +60 -0
- rag_blocks-0.6.0/rag_blocks/embedding/caching.py +116 -0
- rag_blocks-0.6.0/rag_blocks/embedding/hashing.py +74 -0
- rag_blocks-0.6.0/rag_blocks/embedding/sentence_transformer.py +99 -0
- rag_blocks-0.6.0/rag_blocks/embedding/sparse.py +48 -0
- rag_blocks-0.6.0/rag_blocks/enrichment/__init__.py +18 -0
- rag_blocks-0.6.0/rag_blocks/enrichment/base.py +50 -0
- rag_blocks-0.6.0/rag_blocks/enrichment/contextual.py +92 -0
- rag_blocks-0.6.0/rag_blocks/enrichment/heading.py +57 -0
- rag_blocks-0.6.0/rag_blocks/generation/__init__.py +16 -0
- rag_blocks-0.6.0/rag_blocks/generation/anthropic_generator.py +119 -0
- rag_blocks-0.6.0/rag_blocks/generation/base.py +53 -0
- rag_blocks-0.6.0/rag_blocks/generation/extractive.py +36 -0
- rag_blocks-0.6.0/rag_blocks/generation/packing.py +65 -0
- rag_blocks-0.6.0/rag_blocks/indexing/__init__.py +13 -0
- rag_blocks-0.6.0/rag_blocks/indexing/catalog.py +110 -0
- rag_blocks-0.6.0/rag_blocks/indexing/chunk_index.py +228 -0
- rag_blocks-0.6.0/rag_blocks/indexing/sink.py +31 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/__init__.py +28 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/detection.py +127 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/ocr/__init__.py +12 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/ocr/base.py +92 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/ocr/google_docai.py +77 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/ocr/mistral.py +86 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/parsers/__init__.py +6 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/parsers/auto.py +85 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/parsers/base.py +55 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/parsers/docling_parser.py +448 -0
- rag_blocks-0.6.0/rag_blocks/ingestion/parsers/plaintext.py +70 -0
- rag_blocks-0.6.0/rag_blocks/pipeline.py +476 -0
- rag_blocks-0.6.0/rag_blocks/refinement/__init__.py +26 -0
- rag_blocks-0.6.0/rag_blocks/refinement/base.py +43 -0
- rag_blocks-0.6.0/rag_blocks/refinement/cross_encoder.py +71 -0
- rag_blocks-0.6.0/rag_blocks/refinement/keyword.py +51 -0
- rag_blocks-0.6.0/rag_blocks/refinement/neighbor.py +147 -0
- rag_blocks-0.6.0/rag_blocks/refinement/threshold.py +35 -0
- rag_blocks-0.6.0/rag_blocks/retrieval/__init__.py +26 -0
- rag_blocks-0.6.0/rag_blocks/retrieval/base.py +44 -0
- rag_blocks-0.6.0/rag_blocks/retrieval/fusion.py +93 -0
- rag_blocks-0.6.0/rag_blocks/retrieval/fusion_retriever.py +82 -0
- rag_blocks-0.6.0/rag_blocks/retrieval/hybrid.py +79 -0
- rag_blocks-0.6.0/rag_blocks/retrieval/index_retriever.py +80 -0
- rag_blocks-0.6.0/rag_blocks/retrieval/query_shaping.py +136 -0
- rag_blocks-0.6.0/rag_blocks/storage/__init__.py +26 -0
- rag_blocks-0.6.0/rag_blocks/storage/base.py +94 -0
- rag_blocks-0.6.0/rag_blocks/storage/bm25_index.py +164 -0
- rag_blocks-0.6.0/rag_blocks/storage/lexical_index.py +45 -0
- rag_blocks-0.6.0/rag_blocks/storage/local.py +104 -0
- rag_blocks-0.6.0/rag_blocks/storage/memory_store.py +180 -0
- rag_blocks-0.6.0/rag_blocks/storage/minio_store.py +187 -0
- rag_blocks-0.6.0/rag_blocks/storage/qdrant_store.py +415 -0
- rag_blocks-0.6.0/rag_blocks/storage/vector_store.py +109 -0
- rag_blocks-0.6.0/requirements-gpu.txt +31 -0
- rag_blocks-0.6.0/scripts/mini_pytest.py +218 -0
- rag_blocks-0.6.0/tests/chunking/test_fixed_chunker.py +55 -0
- rag_blocks-0.6.0/tests/chunking/test_markdown_chunker.py +51 -0
- rag_blocks-0.6.0/tests/conftest.py +4 -0
- rag_blocks-0.6.0/tests/contract_checks.py +419 -0
- rag_blocks-0.6.0/tests/core/test_component.py +68 -0
- rag_blocks-0.6.0/tests/core/test_contracts.py +56 -0
- rag_blocks-0.6.0/tests/core/test_registry.py +63 -0
- rag_blocks-0.6.0/tests/embedding/test_caching_embedder.py +83 -0
- rag_blocks-0.6.0/tests/embedding/test_hashing_embedder.py +53 -0
- rag_blocks-0.6.0/tests/enrichment/test_enrichers.py +50 -0
- rag_blocks-0.6.0/tests/generation/test_anthropic_complete.py +47 -0
- rag_blocks-0.6.0/tests/generation/test_extractive_generator.py +63 -0
- rag_blocks-0.6.0/tests/helpers.py +43 -0
- rag_blocks-0.6.0/tests/indexing/test_chunk_index.py +105 -0
- rag_blocks-0.6.0/tests/indexing/test_document_catalog.py +101 -0
- rag_blocks-0.6.0/tests/ingestion/test_auto_parser.py +53 -0
- rag_blocks-0.6.0/tests/ingestion/test_detection.py +66 -0
- rag_blocks-0.6.0/tests/ingestion/test_docling_routing.py +142 -0
- rag_blocks-0.6.0/tests/ingestion/test_plaintext_parser.py +46 -0
- rag_blocks-0.6.0/tests/integration/test_anthropic_generator.py +52 -0
- rag_blocks-0.6.0/tests/integration/test_contextual_enricher.py +48 -0
- rag_blocks-0.6.0/tests/integration/test_cross_encoder_refiner.py +46 -0
- rag_blocks-0.6.0/tests/integration/test_docling_pdf.py +29 -0
- rag_blocks-0.6.0/tests/integration/test_minio_integration.py +37 -0
- rag_blocks-0.6.0/tests/integration/test_qdrant_store.py +76 -0
- rag_blocks-0.6.0/tests/integration/test_sentence_transformer_embedder.py +46 -0
- rag_blocks-0.6.0/tests/refinement/test_keyword_refiner.py +36 -0
- rag_blocks-0.6.0/tests/refinement/test_neighbor_expander.py +87 -0
- rag_blocks-0.6.0/tests/refinement/test_threshold.py +33 -0
- rag_blocks-0.6.0/tests/retrieval/test_fusion_retriever.py +82 -0
- rag_blocks-0.6.0/tests/retrieval/test_hybrid_retriever.py +58 -0
- rag_blocks-0.6.0/tests/retrieval/test_index_retriever.py +86 -0
- rag_blocks-0.6.0/tests/retrieval/test_query_shaping.py +82 -0
- rag_blocks-0.6.0/tests/storage/test_bm25_index.py +83 -0
- rag_blocks-0.6.0/tests/storage/test_local_blob_store.py +51 -0
- rag_blocks-0.6.0/tests/storage/test_memory_store.py +103 -0
- rag_blocks-0.6.0/tests/storage/test_minio_store.py +58 -0
- rag_blocks-0.6.0/tests/test_pipeline.py +112 -0
- rag_blocks-0.6.0/tests/test_query_pipeline.py +81 -0
- rag_blocks-0.6.0/tests/test_rag_pipeline.py +101 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Install (dev)
|
|
20
|
+
run: pip install -e ".[dev]"
|
|
21
|
+
- name: Lint
|
|
22
|
+
run: ruff check rag_blocks tests
|
|
23
|
+
- name: Test with coverage
|
|
24
|
+
run: pytest --cov=rag_blocks --cov-report=term-missing
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# ---------------------------------------------------------------------------
|
|
2
|
+
# rag-blocks .gitignore (Python library, GitHub "Python" template + project)
|
|
3
|
+
# ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Local scratch / generated docs (not part of the repo)
|
|
6
|
+
tump_docs/
|
|
7
|
+
|
|
8
|
+
# Byte-compiled / optimized / DLL files
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*$py.class
|
|
12
|
+
|
|
13
|
+
# C extensions
|
|
14
|
+
*.so
|
|
15
|
+
|
|
16
|
+
# Distribution / packaging
|
|
17
|
+
.Python
|
|
18
|
+
build/
|
|
19
|
+
develop-eggs/
|
|
20
|
+
dist/
|
|
21
|
+
downloads/
|
|
22
|
+
eggs/
|
|
23
|
+
.eggs/
|
|
24
|
+
lib/
|
|
25
|
+
lib64/
|
|
26
|
+
parts/
|
|
27
|
+
sdist/
|
|
28
|
+
var/
|
|
29
|
+
wheels/
|
|
30
|
+
share/python-wheels/
|
|
31
|
+
*.egg-info/
|
|
32
|
+
.installed.cfg
|
|
33
|
+
*.egg
|
|
34
|
+
MANIFEST
|
|
35
|
+
|
|
36
|
+
# Unit test / coverage reports
|
|
37
|
+
htmlcov/
|
|
38
|
+
.tox/
|
|
39
|
+
.nox/
|
|
40
|
+
.coverage
|
|
41
|
+
.coverage.*
|
|
42
|
+
.cache
|
|
43
|
+
nosetests.xml
|
|
44
|
+
coverage.xml
|
|
45
|
+
*.cover
|
|
46
|
+
*.py,cover
|
|
47
|
+
.hypothesis/
|
|
48
|
+
.pytest_cache/
|
|
49
|
+
cover/
|
|
50
|
+
|
|
51
|
+
# Type checkers / linters
|
|
52
|
+
.mypy_cache/
|
|
53
|
+
.dmypy.json
|
|
54
|
+
dmypy.json
|
|
55
|
+
.pyre/
|
|
56
|
+
.pytype/
|
|
57
|
+
.ruff_cache/
|
|
58
|
+
|
|
59
|
+
# Environments
|
|
60
|
+
.env
|
|
61
|
+
.venv
|
|
62
|
+
env/
|
|
63
|
+
venv/
|
|
64
|
+
ENV/
|
|
65
|
+
env.bak/
|
|
66
|
+
venv.bak/
|
|
67
|
+
|
|
68
|
+
# Secrets (never commit) — see AGENTS.md §7.4
|
|
69
|
+
*.pem
|
|
70
|
+
*.key
|
|
71
|
+
|
|
72
|
+
# Jupyter Notebook
|
|
73
|
+
.ipynb_checkpoints
|
|
74
|
+
|
|
75
|
+
# IDEs / editors
|
|
76
|
+
.idea/
|
|
77
|
+
.vscode/
|
|
78
|
+
*.swp
|
|
79
|
+
*.swo
|
|
80
|
+
*~
|
|
81
|
+
|
|
82
|
+
# OS cruft
|
|
83
|
+
.DS_Store
|
|
84
|
+
Thumbs.db
|
|
85
|
+
|
|
86
|
+
# Project-local caches (blob store, parse cache, trial logs when run locally)
|
|
87
|
+
.rag_cache/
|
|
88
|
+
*.sqlite
|
|
89
|
+
*.sqlite3
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
# AGENTS.md — rag-blocks agent context
|
|
2
|
+
|
|
3
|
+
**Read this file completely before writing or modifying any code.** It is the
|
|
4
|
+
canonical knowledge transfer from the project's design phase. It contains the
|
|
5
|
+
philosophy, every design decision and its rationale, the semantics of the data
|
|
6
|
+
contracts, specs for components that are designed but NOT yet coded, and the
|
|
7
|
+
rules any contribution must follow. When this file conflicts with your general
|
|
8
|
+
habits, this file wins. When code conflicts with this file, flag it — do not
|
|
9
|
+
silently "fix" either side.
|
|
10
|
+
|
|
11
|
+
Recommended reading order before your first change:
|
|
12
|
+
`AGENTS.md` (this file) → `ARCHITECTURE.md` → `rag_blocks/core/contracts.py`
|
|
13
|
+
→ `rag_blocks/core/component.py` → `rag_blocks/ingestion/parsers/base.py`
|
|
14
|
+
→ one concrete parser (`plaintext.py`, then `docling_parser.py`) →
|
|
15
|
+
`tests/contract_checks.py`. Module docstrings are load-bearing documentation,
|
|
16
|
+
not decoration — they explain *why*, and you are expected to write in the
|
|
17
|
+
same style.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. What this project is
|
|
22
|
+
|
|
23
|
+
`rag-blocks` is an open-source Python library of **composable building blocks
|
|
24
|
+
for production RAG pipelines**: every stage (parsing, chunking, embedding,
|
|
25
|
+
storage, retrieval, reranking, generation, evaluation) is a swappable
|
|
26
|
+
component behind a stable contract, every pipeline is a serializable config,
|
|
27
|
+
and an auto-tuning evaluation suite finds the best component combination for a
|
|
28
|
+
given dataset — with full trial logs and per-stage insights.
|
|
29
|
+
|
|
30
|
+
The differentiators over neighbors (AutoRAG is the closest competitor;
|
|
31
|
+
Haystack/LlamaIndex are the frameworks):
|
|
32
|
+
|
|
33
|
+
1. **Swappability as the product.** Not a framework you live inside — blocks
|
|
34
|
+
you compose. "SWAPPABLE" is the owner's one-word summary of the project.
|
|
35
|
+
2. **Streaming-first ingestion** with per-page OCR routing to *any* engine
|
|
36
|
+
(Mistral, Google Document AI, custom) — memory never scales with document
|
|
37
|
+
size.
|
|
38
|
+
3. **Fingerprint-keyed cross-pipeline caching** that makes tuning tractable
|
|
39
|
+
(shared stage prefixes are computed once across all trial combinations).
|
|
40
|
+
4. **Provenance end to end**: every chunk can answer "which pages of which
|
|
41
|
+
file", enabling citations.
|
|
42
|
+
|
|
43
|
+
Owner context that shapes decisions: this is the maintainer's **first
|
|
44
|
+
open-source project** and is explicitly a learning vehicle for clean design.
|
|
45
|
+
Code quality, pattern discipline, and explanatory docstrings are requirements,
|
|
46
|
+
not nice-to-haves. He is reading *Clean Code*; honor its spirit (small
|
|
47
|
+
functions, intention-revealing names, no clever tricks — there is a comment in
|
|
48
|
+
`plaintext.py` where a "clever" one-liner was deliberately rewritten as two
|
|
49
|
+
clear lines; that is the house style).
|
|
50
|
+
|
|
51
|
+
Current state: **v0.1 — core + ingestion subsystem implemented and tested**
|
|
52
|
+
(64 hermetic tests passing, 1 opt-in integration test). Everything else is
|
|
53
|
+
specified (here and in ARCHITECTURE.md) but not coded.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 2. The prime directive: design principles are hard requirements
|
|
58
|
+
|
|
59
|
+
The owner's explicit instruction: *use design patterns and principles so the
|
|
60
|
+
code and architecture stay as clean as possible.* Every PR is judged against
|
|
61
|
+
these eight principles. They are not aspirational.
|
|
62
|
+
|
|
63
|
+
1. **Contracts, not coupling.** Stages never import each other. They agree
|
|
64
|
+
only on the typed dataclasses in `core/contracts.py`
|
|
65
|
+
(`Source → Page → Document → Chunk → ScoredChunk → Answer`). A Chunker
|
|
66
|
+
must not know what a Parser is.
|
|
67
|
+
2. **Composition over inheritance.** The only mandatory base is `Component`
|
|
68
|
+
plus the stage ABC. Never create deep hierarchies; a hybrid retriever
|
|
69
|
+
*contains* two retrievers.
|
|
70
|
+
3. **Streaming-first.** Data-producing primitives are generators
|
|
71
|
+
(`iter_pages`). Materializing conveniences (`parse()`) are layered on top
|
|
72
|
+
via Template Method. No stage may hold more than one window/batch of data
|
|
73
|
+
at a time. Memory must not scale with input size.
|
|
74
|
+
4. **Open/Closed via the registry.** New capability = new registered class.
|
|
75
|
+
Adding a parser/engine/chunker must require ZERO edits to existing files
|
|
76
|
+
(except an import in the subsystem `__init__.py` for built-ins).
|
|
77
|
+
5. **Config-as-data.** Pipelines are serializable dicts/YAML. Behavior
|
|
78
|
+
differences come from config, never from subclassing-for-configuration.
|
|
79
|
+
6. **Provenance from day one.** Every artifact must answer "where did this
|
|
80
|
+
come from". Never drop offsets, page numbers, or source references —
|
|
81
|
+
they cannot be reconstructed later.
|
|
82
|
+
7. **Batteries optional.** `rag_blocks.core` has ZERO third-party
|
|
83
|
+
dependencies (stdlib dataclasses, not pydantic — deliberate). Every vendor
|
|
84
|
+
SDK is a pip extra, imported lazily *inside the method that uses it*,
|
|
85
|
+
with an actionable ImportError message naming the extra.
|
|
86
|
+
8. **Everything measurable.** Every component has a deterministic
|
|
87
|
+
`fingerprint()` = sha256(kind, name, version, redacted-config)[:16].
|
|
88
|
+
Fingerprints are cache keys and trial identity. **If you change a
|
|
89
|
+
component's behavior, bump its `version`** — that is how caches
|
|
90
|
+
invalidate. Never change fingerprint semantics casually.
|
|
91
|
+
|
|
92
|
+
Corollary the owner has internalized and expects you to apply:
|
|
93
|
+
**"Testability is the first consumer of the architecture. If a change is hard
|
|
94
|
+
to test, the design is wrong"** — fix the design (extract a pure function,
|
|
95
|
+
inject through a seam), don't write a heroic test.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 3. Architecture core: the two-layer class hierarchy
|
|
100
|
+
|
|
101
|
+
This confused the owner once; it is now settled and must not be redesigned.
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
Component layer 1 — shared PLUMBING, no domain logic:
|
|
105
|
+
│ (kind, name, version) identity, config
|
|
106
|
+
│ dataclass merging, describe()/fingerprint()
|
|
107
|
+
│ with secret redaction
|
|
108
|
+
├── Parser (abstract iter_pages) ┐
|
|
109
|
+
├── OcrEngine (abstract recognize) │ layer 2 — one ABC per stage,
|
|
110
|
+
├── Chunker (abstract iter_spans) │ carries the stage CONTRACT
|
|
111
|
+
├── Embedder, VectorStore, Retriever, ... ┘ via @abstractmethod
|
|
112
|
+
└────── concrete implementations (DoclingParser, MistralOcrEngine, ...)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Why one `Component` grandparent: the plumbing is identical for all stages
|
|
116
|
+
(DRY), and the registry/pipelines/tuner need a **common type** to hold
|
|
117
|
+
heterogeneous collections ("give me the fingerprints of all 6 components of
|
|
118
|
+
trial #14"). Why per-stage ABCs on top: each stage has its own contract.
|
|
119
|
+
In Java terms: stage ABC = `interface`, `Component` = shared `abstract class`.
|
|
120
|
+
|
|
121
|
+
Enforcement is three layers — implement all three for any new stage:
|
|
122
|
+
1. `@abstractmethod` → `TypeError` at instantiation (runtime).
|
|
123
|
+
2. Type hints + mypy in CI (Python's "compile time"; ABCs don't check
|
|
124
|
+
signatures, mypy does).
|
|
125
|
+
3. **Behavioral contract tests** (`tests/contract_checks.py`) for what
|
|
126
|
+
neither can check (ordering, span validity, determinism). Every new stage
|
|
127
|
+
kind gets an `assert_<stage>_contract()` helper; every implementation's
|
|
128
|
+
tests must call it.
|
|
129
|
+
|
|
130
|
+
`typing.Protocol` was considered and rejected: we want inherited *behavior*
|
|
131
|
+
(config, fingerprint) and registration, not just structural shape. Do not
|
|
132
|
+
migrate to Protocols.
|
|
133
|
+
|
|
134
|
+
Registry mechanics (`core/registry.py`): `@registry.register` class decorator
|
|
135
|
+
reads `kind`/`name` from the class (single source of truth, decorator takes no
|
|
136
|
+
args). Re-registering the *same* class is idempotent; a *different* class
|
|
137
|
+
under an existing key raises. Third-party plugins load lazily via the
|
|
138
|
+
`rag_blocks.components` entry-point group; a broken plugin must never crash
|
|
139
|
+
core (exceptions are swallowed per entry point). `registry.create(kind, name,
|
|
140
|
+
**overrides)` is the Factory Method everything uses.
|
|
141
|
+
|
|
142
|
+
Config mechanics (`core/component.py`): each component optionally declares a
|
|
143
|
+
nested `@dataclass class Config`. `__init__(config=None, **overrides)` accepts
|
|
144
|
+
a ready Config, keyword overrides, or both (overrides win via
|
|
145
|
+
`dataclasses.replace`); unknown keys → `ConfigError` (fail fast).
|
|
146
|
+
`describe()` redacts any config field whose lowercase name contains one of
|
|
147
|
+
`("key", "token", "secret", "password", "credential")` and normalizes enums to
|
|
148
|
+
`.value`. `fingerprint()` hashes the *redacted* describe — consequence:
|
|
149
|
+
rotating an API key never invalidates caches, and secrets can never appear in
|
|
150
|
+
logs or trial records.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 4. Data contract semantics (the subtle parts)
|
|
155
|
+
|
|
156
|
+
The dataclasses are in `core/contracts.py`; what follows is the *meaning* an
|
|
157
|
+
agent must preserve.
|
|
158
|
+
|
|
159
|
+
**`Source`** is a lazy pointer (path or small in-memory bytes), frozen.
|
|
160
|
+
Never eagerly read content; access via `open()` (streams), `head(n)` (sniff),
|
|
161
|
+
`content_hash()` (streaming sha256 — a future cache key). Derive variants
|
|
162
|
+
with `with_format()` / `dataclasses.replace`, never mutate.
|
|
163
|
+
|
|
164
|
+
**`Page`** is the streaming unit of ingestion. 1-based `number`.
|
|
165
|
+
`ocr_applied=True` only when we *know* OCR produced the text (external engine
|
|
166
|
+
or FORCE); docling AUTO OCRs bitmap regions selectively and doesn't tell us,
|
|
167
|
+
so we don't lie.
|
|
168
|
+
|
|
169
|
+
**`Document` is a fact; a chunking is an interpretation of it.** A Document is
|
|
170
|
+
parsed content + provenance, cached under (source hash × parser fingerprint).
|
|
171
|
+
It must NEVER hold chunks, know about chunkers, or grow a `get_chunk(i)`
|
|
172
|
+
method — the same document legitimately has many simultaneous chunkings (the
|
|
173
|
+
tuner depends on this). **Arrows point backward, like database foreign keys:**
|
|
174
|
+
`Chunk` carries `doc_id`; `Document` has no forward references. "Get chunk by
|
|
175
|
+
index" lives where chunks live — the vector store, via payload filter
|
|
176
|
+
(`doc_id == X AND index IN (...)`).
|
|
177
|
+
|
|
178
|
+
**`PageSpan`** records char offsets `[start, end)` of each page inside
|
|
179
|
+
`Document.markdown` (assembled with `PAGE_SEPARATOR = "\n\n"`). The invariant
|
|
180
|
+
tests enforce: `doc.markdown[span.start:span.end] == page.markdown`, spans
|
|
181
|
+
ordered and non-overlapping. **`Document.pages_for_span(start, end)` is the
|
|
182
|
+
designed bridge between parsing and chunking** — chunkers resolve page
|
|
183
|
+
provenance through it and through nothing else.
|
|
184
|
+
|
|
185
|
+
**`Chunk` field semantics:**
|
|
186
|
+
- `id`: deterministic, `f"{doc_id}:{index}"` → idempotent re-indexing
|
|
187
|
+
(re-running upserts overwrites instead of duplicating).
|
|
188
|
+
- `index`: **reading-order position within the document, contiguous 0-based,
|
|
189
|
+
NO holes** — even when whitespace-only spans are skipped, the counter must
|
|
190
|
+
not skip (use a manual counter, not `enumerate` over raw spans). Reason:
|
|
191
|
+
neighbor expansion at query time fetches `index ± 1` from the store to give
|
|
192
|
+
the generator surrounding context; relevance order (retrieval) and reading
|
|
193
|
+
order (index) are different orderings and both are needed.
|
|
194
|
+
- `page_start` / `page_end`: a *range* because chunks legitimately cross page
|
|
195
|
+
boundaries. They are `Optional` meaning **"not always applicable"** — for
|
|
196
|
+
any chunk sliced from a parsed document the base chunker ALWAYS fills them;
|
|
197
|
+
`None` is reserved for synthetic chunks (enricher-generated summaries,
|
|
198
|
+
synthesized Q/A) that never came from a document's markdown. A doc-derived
|
|
199
|
+
chunk with `None` pages is a bug.
|
|
200
|
+
- Committed contract change for v0.2: **promote `char_start`/`char_end` to
|
|
201
|
+
first-class `Chunk` fields** (currently they'd sit in metadata). Char
|
|
202
|
+
offsets are the primary provenance; pages are derived from them.
|
|
203
|
+
- `metadata: dict` exists on every contract as a pressure valve so extensions
|
|
204
|
+
never force schema changes.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 5. Pattern glossary (use these names in docstrings and reviews)
|
|
209
|
+
|
|
210
|
+
| Pattern | Where it lives | Why |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| Strategy | every stage interface | swap algorithms without touching callers — the product thesis |
|
|
213
|
+
| Adapter | `DoclingParser`, `MistralOcrEngine`, `GoogleDocAiOcrEngine`; future `ChonkieChunker`, `RagasEvaluator`, `MinioBlobStore`, `QdrantStore` | vendor churn stays inside one file |
|
|
214
|
+
| Registry + Factory Method | `core/registry.py` | string → instance; pipelines become data; plugin ecosystem via entry points |
|
|
215
|
+
| Facade | `rk.ingest()`, `AutoParser`, future `RagPipeline` | one obvious call for the 90% case |
|
|
216
|
+
| Template Method | `Parser.parse()` over `iter_pages()`; committed for `Chunker.chunk()` over `iter_spans()` | bookkeeping written once, correctly; strategies implement ONE primitive |
|
|
217
|
+
| Iterator / generator pipeline | `iter_pages`, `recognize_batch`, future `iter_spans` | O(batch) memory, backpressure free |
|
|
218
|
+
| Composite | `AutoParser`, `HybridRetriever`, `FusionRetriever` | components made of components, uniform to callers |
|
|
219
|
+
| Null Object (as empty chain) | empty `refine=[]` / `enrich=[]` | optional stages without `if x is not None` litter; the empty chain *is* the null object (no `NoOp*` classes — DR-0001 v2) |
|
|
220
|
+
| Immutable value objects | `Source`, `PageSpan`, `PageImage` | safe across caches/threads |
|
|
221
|
+
| Lazy initialization | vendor imports, docling converter cache, OCR clients | zero-dep core; heavy models built once, reused |
|
|
222
|
+
|
|
223
|
+
**Forbidden anti-patterns:** god objects ("PipelineManager" that knows
|
|
224
|
+
everything), inheritance-for-configuration, eager whole-file reads, stages
|
|
225
|
+
importing sibling stages, `localStorage`-style hidden global state inside
|
|
226
|
+
components (components must be pure functions of (config, inputs) or
|
|
227
|
+
fingerprint caching becomes unsound), swallowing exceptions without context,
|
|
228
|
+
returning strings where offsets/spans are available.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 6. Ingestion subsystem — operational knowledge
|
|
233
|
+
|
|
234
|
+
Flow: `detect_format` (magic bytes first — files lie about extensions; ZIP
|
|
235
|
+
family disambiguated by member paths `word/`→docx, `ppt/`→pptx, `xl/`→xlsx;
|
|
236
|
+
extension only as tiebreaker for signatureless text) → `AutoParser` routes via
|
|
237
|
+
its `routes` config dict (data, overridable) → delegate parser.
|
|
238
|
+
|
|
239
|
+
**OCR is two orthogonal axes — never merge them:**
|
|
240
|
+
- `OcrPolicy` = WHEN (a decision): `AUTO` probe each page's embedded text
|
|
241
|
+
layer, OCR only pages below `min_chars_digital` (32 chars — above stray
|
|
242
|
+
page-number noise, below real content); `FORCE` = OCR everything (rescues
|
|
243
|
+
scanner-generated garbage text layers); `NEVER` = text layer only.
|
|
244
|
+
- `OcrEngine` = HOW (a Strategy): tiny interface, `recognize(PageImage) →
|
|
245
|
+
OcrResult`. Engines know nothing about PDFs/pages/documents.
|
|
246
|
+
|
|
247
|
+
Dispatch matrix in `DoclingParser._iter_pdf` (tested exhaustively in
|
|
248
|
+
`test_docling_routing.py::test_pdf_dispatch_matrix` — keep that test green):
|
|
249
|
+
no external engine → delegate policy to docling's own OCR options; external
|
|
250
|
+
engine + NEVER → docling no-OCR; external engine + AUTO → hybrid per-page
|
|
251
|
+
routing (pdfium char-count probe, consecutive same-kind pages grouped into
|
|
252
|
+
segments so docling keeps efficient windows); external + FORCE → every page
|
|
253
|
+
rendered (200 dpi PNG) → engine.
|
|
254
|
+
|
|
255
|
+
Memory strategy: PDFs are random-access → processed in windows of
|
|
256
|
+
`page_batch_size` (8) pages via docling `page_range`; office formats have no
|
|
257
|
+
sub-file random access → converted whole (deliberate asymmetry — they're
|
|
258
|
+
rarely huge). The pdfium document opens ONCE per file for probe + rendering;
|
|
259
|
+
one page bitmap in memory at a time; images stream through
|
|
260
|
+
`recognize_batch()` (the parallelism hook engines may override).
|
|
261
|
+
|
|
262
|
+
Throughput rule: docling converters (layout models) and OCR HTTP clients are
|
|
263
|
+
expensive — **cache per option-set on the parser instance, reuse across all
|
|
264
|
+
windows and documents**. Never construct per file/page.
|
|
265
|
+
|
|
266
|
+
Known API-drift guards (do not remove; verify on dependency bumps):
|
|
267
|
+
docling `page_range` requires >= 2.15; per-page
|
|
268
|
+
`export_to_markdown(page_no=...)` is wrapped in `try/except TypeError` with a
|
|
269
|
+
window-level fallback (Page carries `metadata["page_span"]` — provenance
|
|
270
|
+
degrades honestly, never wrongly); pypdfium2 `count_chars()` falls back to
|
|
271
|
+
`len(get_text_bounded())`; the `mistralai` call signature
|
|
272
|
+
(`client.ocr.process(model=..., document={"type": "image_url", ...})`) was
|
|
273
|
+
written against SDK 1.x and must be re-verified against current docs before a
|
|
274
|
+
release. `GoogleDocAiOcrEngine` returns plain text (valid markdown);
|
|
275
|
+
reconstructing headings/tables from DocAI layout entities is a welcome
|
|
276
|
+
improvement that must stay entirely inside that adapter.
|
|
277
|
+
|
|
278
|
+
File-naming note: the docling module is `docling_parser.py`, not `docling.py`
|
|
279
|
+
— avoids tooling confusion with the real `docling` package. Follow the same
|
|
280
|
+
caution for future vendor adapters.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## 7. Committed designs NOT yet coded (implement to these specs)
|
|
285
|
+
|
|
286
|
+
These were decided in design discussion with the owner and exist only here.
|
|
287
|
+
Do not re-litigate them; do refine details that don't contradict them.
|
|
288
|
+
|
|
289
|
+
### 7.1 Chunker (v0.2 — the next milestone)
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
class Chunker(Component):
|
|
293
|
+
kind = "chunker"
|
|
294
|
+
|
|
295
|
+
@abstractmethod
|
|
296
|
+
def iter_spans(self, document: Document) -> Iterator[tuple[int, int]]:
|
|
297
|
+
"""The ONLY strategy decision: WHERE to cut, as half-open char
|
|
298
|
+
offsets [start, end) into document.markdown. Cut coordinates,
|
|
299
|
+
not copies."""
|
|
300
|
+
|
|
301
|
+
def chunk(self, document: Document) -> Iterator[Chunk]:
|
|
302
|
+
# Template Method — ALL bookkeeping lives here, once:
|
|
303
|
+
# - slice text = document.markdown[start:end]
|
|
304
|
+
# - skip whitespace-only slices WITHOUT advancing index
|
|
305
|
+
# (manual counter; index stays contiguous 0-based, no holes)
|
|
306
|
+
# - id = f"{document.id}:{index}" (deterministic)
|
|
307
|
+
# - pages = document.pages_for_span(start, end);
|
|
308
|
+
# page_start/page_end ALWAYS filled here
|
|
309
|
+
# - char_start/char_end stored as first-class Chunk fields
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Rules: spans yielded in reading order of `start`; **overlapping spans are
|
|
313
|
+
legal** (overlap strategies express naturally in coordinates — this is *why*
|
|
314
|
+
strategies emit spans, not strings: return strings and provenance,
|
|
315
|
+
overlap, and neighbor merging all die). Strategies are config-only.
|
|
316
|
+
|
|
317
|
+
Planned implementations: `fixed` (chunk_chars=1600, overlap_chars=200; prefer
|
|
318
|
+
cutting at `\n\n`, refuse a soft cut that would leave < size/2 — mirror the
|
|
319
|
+
newline-preference logic in `PlainTextParser._cut_point`), `markdown-aware`
|
|
320
|
+
(cut at heading positions — this is the payoff of normalizing ingestion to
|
|
321
|
+
markdown: structure survives to the cutting decision), `chonkie` (Adapter —
|
|
322
|
+
Chonkie chunks already expose `start_index`/`end_index`, map them straight to
|
|
323
|
+
spans; Chonkie is a preferred dependency, extra `[chonkie]`), `semantic`
|
|
324
|
+
(later). **No `chunk_stream` in v0.2** — deliberate YAGNI: markdown of even a
|
|
325
|
+
2,000-page PDF is a few MB; keep the streaming hook documented for a future
|
|
326
|
+
version, don't build it now. Ship
|
|
327
|
+
`tests/contract_checks.py::assert_chunker_contract` (index contiguity, span
|
|
328
|
+
ordering/bounds, slices match text, page fields filled, determinism) alongside.
|
|
329
|
+
|
|
330
|
+
### 7.2 BlobStore (with v0.3 storage)
|
|
331
|
+
|
|
332
|
+
New component kind `"blob_store"`: `put(key: str, data: bytes)`,
|
|
333
|
+
`get(key) -> bytes`, `exists(key) -> bool` (streaming variants may come
|
|
334
|
+
later). Implementations: `LocalBlobStore` (filesystem, zero-dep, default) and
|
|
335
|
+
`MinioBlobStore` (Adapter over the `minio` SDK — which is Apache-2.0 even
|
|
336
|
+
though the MinIO *server* is AGPL; extra `[minio]`, S3-compatible so it covers
|
|
337
|
+
AWS too).
|
|
338
|
+
|
|
339
|
+
Content-addressed layout — note the second key IS the tuner's parse cache
|
|
340
|
+
materialized (this is why `Source.content_hash()` and `fingerprint()` exist
|
|
341
|
+
since v0.1):
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
raw/{sha256}/original{ext} immutable source of truth (dedup free)
|
|
345
|
+
parsed/{sha256}/{parser_fingerprint}.md the parse cache
|
|
346
|
+
parsed/{sha256}/{parser_fingerprint}.meta.json spans, ocr pages, doc metadata
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Principle: **blob store = truth; Qdrant = derived and rebuildable.** Chunk
|
|
350
|
+
text + `{doc_id, index, page_start, page_end}` are duplicated into the Qdrant
|
|
351
|
+
payload so query time never touches the blob store; re-embedding with a new
|
|
352
|
+
model reads markdown from the blob store and never re-parses. Trial logs are
|
|
353
|
+
NOT blobs — they go to JSONL + SQLite.
|
|
354
|
+
|
|
355
|
+
### 7.3 Evaluation & tuning (v0.6–0.7)
|
|
356
|
+
|
|
357
|
+
`Evaluator` kind with `stage: "retrieval" | "generation"`. Two families by
|
|
358
|
+
cost: classic IR metrics (recall@k, MRR, nDCG — pure math, no LLM) and
|
|
359
|
+
LLM-judged. **RAGAS integrates as `RagasEvaluator`, an Adapter** translating
|
|
360
|
+
our trial data (question, retrieved contexts, answer, ground truth) into a
|
|
361
|
+
RAGAS `EvaluationDataset` (faithfulness, answer_relevancy,
|
|
362
|
+
context_precision/recall) and mapping scores back to our `MetricReport`.
|
|
363
|
+
Two-phase evaluation: phase 1 screens ALL combinations with IR metrics;
|
|
364
|
+
phase 2 runs RAGAS/LLM-judge on the top-N (default 5) only. Judge verdicts
|
|
365
|
+
cached by (question, answer, judge-model) hash.
|
|
366
|
+
|
|
367
|
+
Tuner is a Strategy (`grid`, `random`; `bayesian`/successive-halving later).
|
|
368
|
+
Stage-output cache key = `sha256(dataset/source hashes + fingerprint chain of
|
|
369
|
+
stages 1..N)` — shared pipeline prefixes across trials are computed once
|
|
370
|
+
(e.g., 24 combos → 1 parse, 2 chunk runs, 4 embed runs). `Trial` records:
|
|
371
|
+
trial_id, full `describe()` per stage (secrets already redacted by design),
|
|
372
|
+
fingerprints, metrics, cost (latency_ms, tokens, api_usd), cache_hits,
|
|
373
|
+
timestamps → JSONL + SQLite. Leaderboard computes **per-stage marginal
|
|
374
|
+
analysis** ("averaged over all else, the cross-encoder refiner adds +0.07 nDCG
|
|
375
|
+
for +180 ms/query") — quality AND cost attribution is the "deep insights"
|
|
376
|
+
deliverable.
|
|
377
|
+
|
|
378
|
+
### 7.4 Secrets policy (applies to every adapter you write)
|
|
379
|
+
|
|
380
|
+
- Credential resolution pattern, exactly:
|
|
381
|
+
`self.config.api_key or os.environ.get("<VENDOR>_API_KEY")` — explicit
|
|
382
|
+
config wins (users with their own secret managers), env var is the default.
|
|
383
|
+
**Use the vendor-standard env name** (`MISTRAL_API_KEY`), never a
|
|
384
|
+
toolkit-prefixed one — least surprise.
|
|
385
|
+
- The library **NEVER** calls `load_dotenv()`, never writes secrets, never
|
|
386
|
+
logs them. Populating the environment is the application's job.
|
|
387
|
+
- **Pipeline specs / YAML / trial logs must never contain secrets.** Config
|
|
388
|
+
names *which* engine; the environment supplies *its* credentials. A future
|
|
389
|
+
config loader may support `${ENV_VAR}` interpolation resolved at load time.
|
|
390
|
+
- Custom-engine authors must name credential fields with a redaction marker
|
|
391
|
+
substring (`api_key`, `auth_token`, ...) to get automatic redaction —
|
|
392
|
+
document this in the extension guide.
|
|
393
|
+
- Google uses Application Default Credentials (no key field at all —
|
|
394
|
+
`GOOGLE_APPLICATION_CREDENTIALS` or ambient identity). Vendor-specific
|
|
395
|
+
credential mechanics belong inside the vendor's Adapter.
|
|
396
|
+
- Repo hygiene: `.env` gitignored; committed `.env.example` with placeholder
|
|
397
|
+
keys (`MISTRAL_API_KEY=`, `GOOGLE_APPLICATION_CREDENTIALS=`,
|
|
398
|
+
`rag_blocks_TEST_PDF=`); CI secrets via GitHub Actions secrets. Fork PRs
|
|
399
|
+
don't receive secrets — which is fine BY DESIGN because the default test
|
|
400
|
+
suite is hermetic; only `-m integration` needs keys (run on main/nightly).
|
|
401
|
+
|
|
402
|
+
### 7.5 License
|
|
403
|
+
|
|
404
|
+
Apache-2.0 (decided; already in pyproject). Before first publish: add
|
|
405
|
+
verbatim `LICENSE` text (never edited), optional one-line `NOTICE`
|
|
406
|
+
(`rag-blocks — Copyright 2026 Mohamed Elamine Bentarzi`), prefer PEP 639
|
|
407
|
+
form `license = "Apache-2.0"` + `license-files = ["LICENSE"]` in pyproject.
|
|
408
|
+
Rationale: explicit patent grant, contribution licensing (§5), ecosystem norm
|
|
409
|
+
for RAG infra. Decided while sole-author — do not merge external PRs before
|
|
410
|
+
LICENSE lands.
|
|
411
|
+
|
|
412
|
+
### 7.6 ChunkIndex, composition algebra & multi-representation retrieval (DR-0001 v2)
|
|
413
|
+
|
|
414
|
+
All retrieval representations of a corpus are owned by one `ChunkIndex`:
|
|
415
|
+
`add(chunks)` writes every representation; `search(name, TEXT, k, filters)`
|
|
416
|
+
encodes the query with the same encoder that encoded the corpus — never
|
|
417
|
+
reimplement query encoding elsewhere. Constructor uses progressive disclosure:
|
|
418
|
+
`dense=embedder` auto-names; mappings only for multiple representations.
|
|
419
|
+
**Standing design rule (progressive disclosure):** the common case reads like
|
|
420
|
+
English; the rare case is possible; the rare case's ceremony never leaks into
|
|
421
|
+
the common case. Chunks NEVER carry vectors. The composition algebra:
|
|
422
|
+
pre-retrieval variation = composite retrievers
|
|
423
|
+
(`Fusion`/`Hybrid`/`MultiQuery`/`Hyde` — never new pipeline slots);
|
|
424
|
+
post-retrieval variation = the `refine` chain
|
|
425
|
+
(`Refiner.refine(query, candidates, k)`; the `reranker` kind is retired into
|
|
426
|
+
it); write-side = `enrich` chain + `sinks` fan-out (`ChunkSink` — the one
|
|
427
|
+
sanctioned `typing.Protocol`: a capability seam, not a stage contract; stage
|
|
428
|
+
contracts remain ABCs). Fusion always: dedup by `chunk.id`, filters fan out to
|
|
429
|
+
every sub-search, per-source rank attribution in `metadata["sources"]`.
|
|
430
|
+
`VectorStore` is named+typed multi-vector with `ensure_schema` create-or-validate,
|
|
431
|
+
`fetch(filters, limit)` (list values = membership), `update_vectors`. Classic
|
|
432
|
+
BM25 stays a mounted corpus-stats `LexicalIndex`; SPLADE-style sparse is a
|
|
433
|
+
`SparseEncoder` representation. `CachingEmbedder` is fingerprint-transparent with
|
|
434
|
+
separate passage/query namespaces. Bare LLM completion is a `Callable[[str], str]`
|
|
435
|
+
seam (`generator.complete`); do not invent a `completer` kind until a third
|
|
436
|
+
independent consumer demands it. Empty chains are the null objects
|
|
437
|
+
(`NoOpReranker`/`NoOpEnricher` are deleted — do not recreate them). Do not
|
|
438
|
+
re-litigate: no retriever write-side, no `chunk.vectors`, no `QueryTransform`
|
|
439
|
+
kind, no DAG framework, no capability negotiation. The architecture's acceptance
|
|
440
|
+
test: the tuner must index once and enumerate retrieval/refinement strategies
|
|
441
|
+
with ZERO tuner-motivated parameters on `ChunkIndex` — if one appears, stop and
|
|
442
|
+
write DR-0002.
|
|
443
|
+
|
|
444
|
+
Kinds (v2): `vector_store` (renamed from `store`), `embedder`, `sparse_encoder`,
|
|
445
|
+
`lexical_index`, `index` (ChunkIndex — aggregate, wired from instances, not
|
|
446
|
+
registry-built), `retriever`, `refiner` (replaces `reranker`), `enricher`,
|
|
447
|
+
`generator`, `blob_store`, plus ingestion kinds. `ChunkSink` is a Protocol, not
|
|
448
|
+
a kind.
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## 8. Coding conventions
|
|
453
|
+
|
|
454
|
+
- Python >= 3.10, `from __future__ import annotations` everywhere. Full type
|
|
455
|
+
hints. Line length 88 (ruff configured).
|
|
456
|
+
- **Core stays stdlib-only.** Contracts and configs are plain `@dataclass`
|
|
457
|
+
(pydantic was considered and rejected for the hot path; wrapping at app
|
|
458
|
+
edges is fine for users, not for us).
|
|
459
|
+
- Lazy vendor imports, exact idiom: import inside the method that needs it,
|
|
460
|
+
wrap `ImportError`, raise a toolkit error naming the extra:
|
|
461
|
+
`"... requires 'docling'. Install with: pip install 'rag-blocks[docling]'"`.
|
|
462
|
+
- Errors: single root `RagToolkitError`; raise narrow subclasses with context
|
|
463
|
+
(`ParseError(msg, source_uri=..., page_number=...)`) — "PDF failed" is
|
|
464
|
+
useless in a 10k-document batch. Fail fast at construction (unknown engine
|
|
465
|
+
name explodes in `__init__`, not on page 500). Never swallow exceptions
|
|
466
|
+
without normalizing them into toolkit errors with `from exc`.
|
|
467
|
+
- Docstrings explain **WHY and the decision/tradeoff**, not what the code
|
|
468
|
+
restates. Pattern names are cited explicitly. Every module opens with a
|
|
469
|
+
design-rationale docstring — match `contracts.py`/`docling_parser.py` style.
|
|
470
|
+
- Naming: stages are agent nouns (`Parser`, `Chunker`, `Embedder`), artifacts
|
|
471
|
+
plain nouns (`Source`, `Page`, `Chunk`), routers/facades prefixed `Auto`,
|
|
472
|
+
policies are str-Enums, test doubles prefixed `Fake`. `kind` is the stage
|
|
473
|
+
slot, `name` the implementation.
|
|
474
|
+
- Built-ins register via module import side effect; wire new modules into the
|
|
475
|
+
subsystem `__init__.py` and export in `__all__` (top-level `rag_blocks/
|
|
476
|
+
__init__.py` for user-facing names).
|
|
477
|
+
- Bump the component `version` on ANY behavioral change (cache invalidation).
|
|
478
|
+
- Public API discipline: pre-1.0, adding is cheap, removing is a breaking
|
|
479
|
+
event — keep surface small; when in doubt, keep it private (`_helper`).
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## 9. Testing rules (non-negotiable)
|
|
484
|
+
|
|
485
|
+
- Framework: pytest; layout mirrors the package under `tests/`. Default run
|
|
486
|
+
is **fast and hermetic** — zero vendor deps, zero network, zero keys
|
|
487
|
+
(`addopts = "-m 'not integration'"`). Real-stack tests live in
|
|
488
|
+
`tests/integration/`, marked `integration`, opt-in
|
|
489
|
+
(`rag_blocks_TEST_PDF=... pytest -m integration`).
|
|
490
|
+
- **Tests ship WITH the feature, same PR.** A component without tests does
|
|
491
|
+
not exist.
|
|
492
|
+
- Test OUR logic, not vendors': extract pure functions (`_plan_segments`,
|
|
493
|
+
`_windows`) and test them directly; verify dispatch with `monkeypatch`;
|
|
494
|
+
inject `FakeOcrEngine` (`tests/helpers.py`) through the same registry seam
|
|
495
|
+
production uses. Never mock what you can fake through a designed seam.
|
|
496
|
+
- Every stage gets contract checks in `tests/contract_checks.py`; every
|
|
497
|
+
implementation calls them. Key existing invariants to never break:
|
|
498
|
+
`doc.markdown[span.start:span.end] == page.markdown`; secret redaction +
|
|
499
|
+
key-rotation keeps fingerprint stable; the PDF dispatch matrix; the UTF-8
|
|
500
|
+
multibyte-across-block-boundary regression in the plaintext parser.
|
|
501
|
+
- Style: table-driven `parametrize` for many-cases-one-behavior;
|
|
502
|
+
`pytest.raises(..., match=...)` for error paths; `tmp_path` for files;
|
|
503
|
+
fresh `Registry()` instances in registry tests (never assert exact contents
|
|
504
|
+
of the global registry — other test modules register fakes into it).
|
|
505
|
+
- `scripts/mini_pytest.py` is a fallback runner for offline environments
|
|
506
|
+
(emulates the pytest subset this suite uses). It is intentionally frozen:
|
|
507
|
+
**extend the tests, never the shim**; if a new pytest feature is needed and
|
|
508
|
+
the shim can't run it, the shim loses.
|
|
509
|
+
- CI (`.github/workflows/ci.yml`): ruff + pytest w/ coverage on 3.10–3.12.
|
|
510
|
+
Keep it green; run lint/type/tests locally before proposing changes.
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## 10. Definition of Done — any new component
|
|
515
|
+
|
|
516
|
+
1. Class with `kind`, `name`, `version`, optional nested `Config` dataclass;
|
|
517
|
+
registered with `@registry.register`; wired into subsystem `__init__.py`.
|
|
518
|
+
2. Implements exactly the stage ABC primitive(s); depends only on
|
|
519
|
+
`core.contracts` + its own stage's abstractions (Dependency Inversion —
|
|
520
|
+
e.g., parsers depend on `OcrEngine`, never on Mistral).
|
|
521
|
+
3. Vendor deps: lazy import + pyproject extra + actionable ImportError.
|
|
522
|
+
4. Credential fields named for auto-redaction; env-var fallback per §7.4.
|
|
523
|
+
5. Pure function of (config, inputs); heavy resources cached on the instance.
|
|
524
|
+
6. Streaming discipline if data-producing; provenance fields populated.
|
|
525
|
+
7. Hermetic tests incl. the stage contract check; integration test only if a
|
|
526
|
+
real vendor is involved (marked, env-gated).
|
|
527
|
+
8. Docstrings state the pattern and the why; README/ARCHITECTURE touched if
|
|
528
|
+
user-visible.
|
|
529
|
+
9. `ruff check` clean, `mypy` clean, full suite green.
|
|
530
|
+
|
|
531
|
+
## 11. Roadmap (build in this order)
|
|
532
|
+
|
|
533
|
+
v0.2 chunking (`fixed`, `markdown-aware`, `chonkie` per §7.1) + `Chunk`
|
|
534
|
+
char-offset fields + chunker contract checks → v0.2+ thin
|
|
535
|
+
`IndexingPipeline`/`QueryPipeline`/`RagPipeline` (dumb for-loops over
|
|
536
|
+
generators + tracing hooks; intelligence in components, wiring in config) →
|
|
537
|
+
v0.3 embedding (`bge-m3` first, `embed_query` separate from `embed_texts` —
|
|
538
|
+
instruction-prefix asymmetry) + storage (`memory` store for tests/tuning,
|
|
539
|
+
`qdrant`, `LocalBlobStore`/`MinioBlobStore` per §7.2) → v0.4 retrieval
|
|
540
|
+
(`dense`, `bm25`, `hybrid` Composite w/ RRF) + reranking (`bge-reranker`,
|
|
541
|
+
`noop`) → v0.5 generation (context packing, token budget, citation markers
|
|
542
|
+
resolved through chunk→page provenance) → **v0.6 the DR-0001 v2 restructure
|
|
543
|
+
(§7.6): `ChunkIndex` aggregate + multi-vector `vector_store`; retrieval collapses
|
|
544
|
+
into the composition axis (`index`/`hybrid`/`fusion`/`multi-query`/`hyde`) and
|
|
545
|
+
reranking dissolves into the `refiner` chain; `CachingEmbedder`; `sparse_encoder`
|
|
546
|
+
interface** → v0.7 evaluation (IR metrics + `RagasEvaluator` per §7.3) + tuning
|
|
547
|
+
(SearchSpace, grid/random tuners, trial log, leaderboard w/ marginal analysis).
|
|
548
|
+
Each milestone ships **at least two interchangeable implementations per stage** —
|
|
549
|
+
swapping is the proof the library works.
|
|
550
|
+
|
|
551
|
+
## 12. When uncertain, decide like this
|
|
552
|
+
|
|
553
|
+
Does it belong in `core`? Only if every stage needs it AND it's stdlib-only —
|
|
554
|
+
otherwise it's a component. New dependency? → optional extra + lazy import,
|
|
555
|
+
core stays clean. Two components share logic? → extract a helper module or a
|
|
556
|
+
value object; do NOT create an inheritance link between stages. Tempted to add
|
|
557
|
+
a parameter to a contract dataclass? → prefer `metadata` first; promote to a
|
|
558
|
+
field only when multiple stages rely on it (as done for char offsets).
|
|
559
|
+
Behavior change? → bump `version`, update tests, note it. Can't test it
|
|
560
|
+
cleanly? → the design is wrong; add a seam or extract a pure function.
|
|
561
|
+
Ambiguity between this file, ARCHITECTURE.md, and code? → flag it to the
|
|
562
|
+
owner; don't guess silently. And keep the owner's bar in mind: he is learning
|
|
563
|
+
from this codebase — every shortcut you take teaches him the wrong lesson.
|