rag-hygiene 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rag-hygiene contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: rag-hygiene
3
+ Version: 0.1.0
4
+ Summary: Find and automatically repair broken RAG chunks before you embed them. Zero dependencies.
5
+ Author: OnProject
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/OnProject/rag-hygiene
8
+ Project-URL: Repository, https://github.com/OnProject/rag-hygiene
9
+ Project-URL: Issues, https://github.com/OnProject/rag-hygiene/issues
10
+ Keywords: rag,chunking,retrieval,llm,embeddings,vector-database,linter,cleanup
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Text Processing :: Linguistic
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Requires-Dist: build; extra == "dev"
28
+ Requires-Dist: twine; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # rag-hygiene
32
+
33
+ **Find and repair broken RAG chunks before you embed them.**
34
+
35
+ Most RAG failures are decided before retrieval ever runs. A chunk that opens in
36
+ the middle of a sentence embeds two unrelated claims. A table split from its
37
+ header embeds a grid of numbers with no column names. A PDF that broke a word
38
+ across lines embeds `docu` and `ment` as one token. None of this is visible until
39
+ answers start being subtly wrong months later.
40
+
41
+ `rag-hygiene` sits between your splitter and your vector database. It diagnoses
42
+ nine classes of chunk damage and **repairs seven of them automatically** — no
43
+ LLM calls, no embeddings, no eval set, no network, no dependencies.
44
+
45
+ ```bash
46
+ pip install rag-hygiene
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```bash
52
+ rag-hygiene scan chunks.json # what is wrong?
53
+ rag-hygiene fix chunks.json # repair it → chunks.clean.json
54
+ rag-hygiene fix chunks.json --dry-run -v # preview the diff first
55
+ ```
56
+
57
+ ```
58
+ 2 chunks scanned
59
+
60
+ mid-sentence-start Chunk resumes a cut sentence
61
+ [high] c2 opens mid-sentence ('40% year over year, beat'); its embedding mixes two unrelated claims ·fixable
62
+
63
+ 2 finding(s) 1 high 1 low · 1 auto-fixable
64
+ run `rag-hygiene fix` to repair, or add --dry-run to preview
65
+ ```
66
+
67
+ ## The repair that matters
68
+
69
+ ```
70
+ before after
71
+ ──────────────────────────────────── ────────────────────────────────────
72
+ [chunk 7] …因此公司营收同比增长 [chunk 7] …因此公司营收同比增长 40%,
73
+ [chunk 8] 40%,超出市场预期。 超出市场预期。
74
+ ✓ merged ✓ heading "Q3 财报" injected
75
+ ```
76
+
77
+ Every repair is **lossless or a rejoin of text that was already adjacent**.
78
+ Nothing is summarised, paraphrased, or invented — which is what lets it run
79
+ unattended in a build step.
80
+
81
+ ## Rules
82
+
83
+ | Rule | Severity | Auto-fix | What it catches |
84
+ |---|---|---|---|
85
+ | `empty-chunk` | high | ✅ removed | Whitespace-only chunks; wasted embedding slots |
86
+ | `mid-sentence-start` | high | ✅ merged | Chunk resumes a sentence the previous one cut |
87
+ | `tiny-chunk` | medium | ✅ merged | Under 20 chars of prose — no meaning to embed |
88
+ | `unclosed-fence` | medium | ✅ closed | Code fence opens, never closes — rest of prompt becomes code |
89
+ | `orphan-table` | medium | ✅ header restored | Markdown table rows whose header landed in another chunk |
90
+ | `near-duplicate` | medium | — | ≥72% word-sequence overlap; dupes split ranking mass |
91
+ | `oversized-chunk` | low | — | Over 4000 chars — retrieval dilutes |
92
+ | `pdf-noise` | low | ✅ normalised | Hyphenated line breaks, column padding, page gaps |
93
+ | `missing-heading` | low | ✅ injected | A heading exists in the document but the chunk can't see it |
94
+
95
+ `near-duplicate` and `oversized-chunk` are deliberately **not** auto-repaired:
96
+ dropping a duplicate or truncating text changes what the retriever can find, and
97
+ that is an editorial decision, not a mechanical one. The tool reports them and
98
+ stays out of the way.
99
+
100
+ ## Python API
101
+
102
+ ```python
103
+ from rag_hygiene import check, repair
104
+
105
+ chunks = [
106
+ {"id": "a", "text": "Revenue grew by", "source": "q3.md", "order": 0},
107
+ {"id": "b", "text": "40% year over year.", "source": "q3.md", "order": 1},
108
+ {"id": "c", "text": " ", "source": "q3.md", "order": 2},
109
+ ]
110
+
111
+ report = check(chunks)
112
+ report.count("high") # 2
113
+ report.by_rule() # {'empty-chunk': 1, 'mid-sentence-start': 1, ...}
114
+ report.fails("medium") # True
115
+
116
+ clean, report = repair(chunks)
117
+ clean[0].text # 'Revenue grew by 40% year over year.'
118
+ clean[0].metadata['merged_ids'] # ['b'] ← provenance is never lost
119
+ ```
120
+
121
+ Accepts `Chunk` objects, dicts (`text` / `page_content` / `content`), or bare
122
+ strings. Works with LangChain and LlamaIndex document exports unchanged.
123
+
124
+ ## CI gate
125
+
126
+ The exit code follows the linter convention, so it drops into any pipeline:
127
+
128
+ ```yaml
129
+ - run: pip install rag-hygiene
130
+ - run: rag-hygiene scan chunks.json --fail-on high
131
+ ```
132
+
133
+ | exit | meaning |
134
+ |---|---|
135
+ | 0 | clean, or the `--fail-on` gate was not tripped |
136
+ | 1 | a finding at or above `--fail-on` remains |
137
+ | 2 | bad input (missing file, unsupported format, unknown rule) |
138
+ | 3 | internal error |
139
+
140
+ ## Formats
141
+
142
+ Reads `.json`, `.jsonl`, and `.txt` / `.md`. A JSON payload may be a bare list or
143
+ an object with a `chunks` / `documents` / `data` / `items` key. Output preserves
144
+ the input format.
145
+
146
+ ## Design notes
147
+
148
+ Three decisions shape the behaviour:
149
+
150
+ **It joins only definite continuations.** A chunk opening with `40% …`, `, and
151
+ then`, or a lowercase word is provably a continuation, so it gets merged. A chunk
152
+ opening with `Revenue grew` *might* have lost its predecessor — that is reported
153
+ at high severity but **not** auto-merged, because guessing would rewrite evidence.
154
+ `rag-hygiene scan --fail-on high` still catches it; you decide.
155
+
156
+ **Repairs are idempotent.** Running `fix` twice produces the same bytes as running
157
+ it once. That is asserted in the test suite (`test_repair_converges`), because a
158
+ formatter that keeps drifting is worse than no formatter.
159
+
160
+ **`missing-heading` converges.** A document that carries no heading anywhere gets
161
+ one `info`-level finding, not one per chunk — there is nothing to repair, and
162
+ flooding the report would make the gate useless. A document with a heading but
163
+ unlabelled chunks gets per-chunk findings that `fix` actually resolves.
164
+
165
+ ## Comparison
166
+
167
+ `chunklint` (May 2026) performs overlapping static checks and depends on
168
+ pydantic, rich, typer, rapidfuzz and pyyaml. `rag-hygiene` differs in three ways:
169
+ it **repairs** rather than only reporting, it has **zero dependencies**, and it
170
+ ships CJK sentence segmentation (Chinese/Japanese/Korean text has no spaces after
171
+ `。`, so the usual whitespace heuristics silently do nothing on it).
172
+
173
+ ## License
174
+
175
+ MIT
@@ -0,0 +1,145 @@
1
+ # rag-hygiene
2
+
3
+ **Find and repair broken RAG chunks before you embed them.**
4
+
5
+ Most RAG failures are decided before retrieval ever runs. A chunk that opens in
6
+ the middle of a sentence embeds two unrelated claims. A table split from its
7
+ header embeds a grid of numbers with no column names. A PDF that broke a word
8
+ across lines embeds `docu` and `ment` as one token. None of this is visible until
9
+ answers start being subtly wrong months later.
10
+
11
+ `rag-hygiene` sits between your splitter and your vector database. It diagnoses
12
+ nine classes of chunk damage and **repairs seven of them automatically** — no
13
+ LLM calls, no embeddings, no eval set, no network, no dependencies.
14
+
15
+ ```bash
16
+ pip install rag-hygiene
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ rag-hygiene scan chunks.json # what is wrong?
23
+ rag-hygiene fix chunks.json # repair it → chunks.clean.json
24
+ rag-hygiene fix chunks.json --dry-run -v # preview the diff first
25
+ ```
26
+
27
+ ```
28
+ 2 chunks scanned
29
+
30
+ mid-sentence-start Chunk resumes a cut sentence
31
+ [high] c2 opens mid-sentence ('40% year over year, beat'); its embedding mixes two unrelated claims ·fixable
32
+
33
+ 2 finding(s) 1 high 1 low · 1 auto-fixable
34
+ run `rag-hygiene fix` to repair, or add --dry-run to preview
35
+ ```
36
+
37
+ ## The repair that matters
38
+
39
+ ```
40
+ before after
41
+ ──────────────────────────────────── ────────────────────────────────────
42
+ [chunk 7] …因此公司营收同比增长 [chunk 7] …因此公司营收同比增长 40%,
43
+ [chunk 8] 40%,超出市场预期。 超出市场预期。
44
+ ✓ merged ✓ heading "Q3 财报" injected
45
+ ```
46
+
47
+ Every repair is **lossless or a rejoin of text that was already adjacent**.
48
+ Nothing is summarised, paraphrased, or invented — which is what lets it run
49
+ unattended in a build step.
50
+
51
+ ## Rules
52
+
53
+ | Rule | Severity | Auto-fix | What it catches |
54
+ |---|---|---|---|
55
+ | `empty-chunk` | high | ✅ removed | Whitespace-only chunks; wasted embedding slots |
56
+ | `mid-sentence-start` | high | ✅ merged | Chunk resumes a sentence the previous one cut |
57
+ | `tiny-chunk` | medium | ✅ merged | Under 20 chars of prose — no meaning to embed |
58
+ | `unclosed-fence` | medium | ✅ closed | Code fence opens, never closes — rest of prompt becomes code |
59
+ | `orphan-table` | medium | ✅ header restored | Markdown table rows whose header landed in another chunk |
60
+ | `near-duplicate` | medium | — | ≥72% word-sequence overlap; dupes split ranking mass |
61
+ | `oversized-chunk` | low | — | Over 4000 chars — retrieval dilutes |
62
+ | `pdf-noise` | low | ✅ normalised | Hyphenated line breaks, column padding, page gaps |
63
+ | `missing-heading` | low | ✅ injected | A heading exists in the document but the chunk can't see it |
64
+
65
+ `near-duplicate` and `oversized-chunk` are deliberately **not** auto-repaired:
66
+ dropping a duplicate or truncating text changes what the retriever can find, and
67
+ that is an editorial decision, not a mechanical one. The tool reports them and
68
+ stays out of the way.
69
+
70
+ ## Python API
71
+
72
+ ```python
73
+ from rag_hygiene import check, repair
74
+
75
+ chunks = [
76
+ {"id": "a", "text": "Revenue grew by", "source": "q3.md", "order": 0},
77
+ {"id": "b", "text": "40% year over year.", "source": "q3.md", "order": 1},
78
+ {"id": "c", "text": " ", "source": "q3.md", "order": 2},
79
+ ]
80
+
81
+ report = check(chunks)
82
+ report.count("high") # 2
83
+ report.by_rule() # {'empty-chunk': 1, 'mid-sentence-start': 1, ...}
84
+ report.fails("medium") # True
85
+
86
+ clean, report = repair(chunks)
87
+ clean[0].text # 'Revenue grew by 40% year over year.'
88
+ clean[0].metadata['merged_ids'] # ['b'] ← provenance is never lost
89
+ ```
90
+
91
+ Accepts `Chunk` objects, dicts (`text` / `page_content` / `content`), or bare
92
+ strings. Works with LangChain and LlamaIndex document exports unchanged.
93
+
94
+ ## CI gate
95
+
96
+ The exit code follows the linter convention, so it drops into any pipeline:
97
+
98
+ ```yaml
99
+ - run: pip install rag-hygiene
100
+ - run: rag-hygiene scan chunks.json --fail-on high
101
+ ```
102
+
103
+ | exit | meaning |
104
+ |---|---|
105
+ | 0 | clean, or the `--fail-on` gate was not tripped |
106
+ | 1 | a finding at or above `--fail-on` remains |
107
+ | 2 | bad input (missing file, unsupported format, unknown rule) |
108
+ | 3 | internal error |
109
+
110
+ ## Formats
111
+
112
+ Reads `.json`, `.jsonl`, and `.txt` / `.md`. A JSON payload may be a bare list or
113
+ an object with a `chunks` / `documents` / `data` / `items` key. Output preserves
114
+ the input format.
115
+
116
+ ## Design notes
117
+
118
+ Three decisions shape the behaviour:
119
+
120
+ **It joins only definite continuations.** A chunk opening with `40% …`, `, and
121
+ then`, or a lowercase word is provably a continuation, so it gets merged. A chunk
122
+ opening with `Revenue grew` *might* have lost its predecessor — that is reported
123
+ at high severity but **not** auto-merged, because guessing would rewrite evidence.
124
+ `rag-hygiene scan --fail-on high` still catches it; you decide.
125
+
126
+ **Repairs are idempotent.** Running `fix` twice produces the same bytes as running
127
+ it once. That is asserted in the test suite (`test_repair_converges`), because a
128
+ formatter that keeps drifting is worse than no formatter.
129
+
130
+ **`missing-heading` converges.** A document that carries no heading anywhere gets
131
+ one `info`-level finding, not one per chunk — there is nothing to repair, and
132
+ flooding the report would make the gate useless. A document with a heading but
133
+ unlabelled chunks gets per-chunk findings that `fix` actually resolves.
134
+
135
+ ## Comparison
136
+
137
+ `chunklint` (May 2026) performs overlapping static checks and depends on
138
+ pydantic, rich, typer, rapidfuzz and pyyaml. `rag-hygiene` differs in three ways:
139
+ it **repairs** rather than only reporting, it has **zero dependencies**, and it
140
+ ships CJK sentence segmentation (Chinese/Japanese/Korean text has no spaces after
141
+ `。`, so the usual whitespace heuristics silently do nothing on it).
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rag-hygiene"
7
+ version = "0.1.0"
8
+ description = "Find and automatically repair broken RAG chunks before you embed them. Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "OnProject" }]
14
+ keywords = [
15
+ "rag",
16
+ "chunking",
17
+ "retrieval",
18
+ "llm",
19
+ "embeddings",
20
+ "vector-database",
21
+ "linter",
22
+ "cleanup",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Topic :: Software Development :: Quality Assurance",
34
+ "Topic :: Text Processing :: Linguistic",
35
+ "Typing :: Typed",
36
+ ]
37
+ dependencies = []
38
+
39
+ [project.optional-dependencies]
40
+ dev = ["pytest>=7", "build", "twine"]
41
+
42
+ [project.scripts]
43
+ rag-hygiene = "rag_hygiene.cli:main"
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/OnProject/rag-hygiene"
47
+ Repository = "https://github.com/OnProject/rag-hygiene"
48
+ Issues = "https://github.com/OnProject/rag-hygiene/issues"
49
+
50
+ [tool.setuptools]
51
+ package-dir = { "" = "src" }
52
+
53
+ [tool.setuptools.packages.find]
54
+ where = ["src"]
55
+
56
+ [tool.setuptools.package-data]
57
+ rag_hygiene = ["py.typed"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,87 @@
1
+ """rag-hygiene — find and repair broken RAG chunks before you embed them.
2
+
3
+ >>> from rag_hygiene import check, repair
4
+ >>> chunks = [{"id": "a", "text": "Revenue grew by"}, {"id": "b", "text": "40% last quarter."}]
5
+ >>> report = check(chunks)
6
+ >>> report.count("high")
7
+ 1
8
+ >>> clean, fixes = repair(chunks)
9
+ >>> clean[0].text
10
+ 'Revenue grew by 40% last quarter.'
11
+
12
+ Zero dependencies, no network, no model calls — every rule is deterministic, so
13
+ this can run in a pre-commit hook or a CI job.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Iterable
19
+
20
+ from .fix import fix_chunks
21
+ from .models import Chunk, Finding, Fix, Report
22
+ from .rules import RULES
23
+
24
+ __version__ = "0.1.0"
25
+ __all__ = [
26
+ "Chunk",
27
+ "Finding",
28
+ "Fix",
29
+ "Report",
30
+ "RULES",
31
+ "check",
32
+ "repair",
33
+ "explain",
34
+ "__version__",
35
+ ]
36
+
37
+
38
+ def _coerce(chunks: Iterable[Any]) -> list[Chunk]:
39
+ return [Chunk.from_obj(c, i) for i, c in enumerate(chunks)]
40
+
41
+
42
+ def check(chunks: Iterable[Any], *, min_severity: str = "info") -> Report:
43
+ """Diagnose ``chunks`` without changing them.
44
+
45
+ Accepts a list of :class:`Chunk`, dicts with a ``text`` (or ``page_content``)
46
+ key, or bare strings.
47
+ """
48
+ from .rules import scan
49
+
50
+ items = _coerce(chunks)
51
+ report = Report(chunks_scanned=len(items), chunks_after=len(items))
52
+ report.findings = scan(items, min_severity=min_severity)
53
+ report.chars_before = report.chars_after = sum(len(c.text) for c in items)
54
+ return report
55
+
56
+
57
+ def repair(
58
+ chunks: Iterable[Any],
59
+ *,
60
+ only: set[str] | None = None,
61
+ inject_heading: bool = True,
62
+ ) -> tuple[list[Chunk], Report]:
63
+ """Repair ``chunks`` and return ``(repaired_chunks, report)``.
64
+
65
+ The report carries both the repairs that were applied and the problems that
66
+ remain (duplicates, oversized chunks) because those need a human decision.
67
+ """
68
+ from .rules import scan
69
+
70
+ items = _coerce(chunks)
71
+ repaired, fix_report = fix_chunks(items, only=only, inject_heading=inject_heading)
72
+ findings = scan(repaired)
73
+ fix_report.findings = findings
74
+ return repaired, fix_report
75
+
76
+
77
+ def explain(rule_id: str) -> str:
78
+ """Why a rule exists and whether it auto-repairs."""
79
+ from .rules import RULE_INDEX
80
+
81
+ if rule_id not in RULE_INDEX:
82
+ raise KeyError(f"unknown rule {rule_id!r}; run `rag-hygiene rules`")
83
+ rule = RULE_INDEX[rule_id]
84
+ return (
85
+ f"{rule.id} ({rule.severity}, {'auto-fixable' if rule.fixable else 'manual'})\n"
86
+ f" {rule.title}"
87
+ )