mdkeychunker 0.3.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.
@@ -0,0 +1,7 @@
1
+ """MDKeyChunker — Markdown chunking with LLM enrichment for RAG."""
2
+ __version__ = "0.3.0"
3
+ from .pipeline import Pipeline
4
+ from .models import Chunk
5
+ from .config import Config
6
+
7
+ __all__ = ["Pipeline", "Chunk", "Config"]
@@ -0,0 +1,402 @@
1
+ """
2
+ Markdown structural chunker.
3
+
4
+ Splits Markdown documents following structure (headers, lists, tables, code blocks)
5
+ without breaking semantic units.
6
+ """
7
+
8
+ import re
9
+ from typing import List, Tuple, Optional
10
+ from dataclasses import dataclass
11
+
12
+ from .models import Chunk
13
+ from .config import Config
14
+
15
+
16
+ _THEMATIC_BREAK = re.compile(r"^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
17
+ _FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
18
+
19
+
20
+ def _is_thematic_break(line: str) -> bool:
21
+ return bool(_THEMATIC_BREAK.match(line))
22
+
23
+
24
+ def _fence_open(line: str) -> Optional[re.Match]:
25
+ """A fence opener; a backtick fence's info string may not contain backticks."""
26
+ m = _FENCE_OPEN.match(line)
27
+ if m and m.group(1)[0] == "`" and "`" in m.group(2):
28
+ return None
29
+ return m
30
+
31
+
32
+ @dataclass
33
+ class _Block:
34
+ """Internal structural block in Markdown."""
35
+ type: str # header, paragraph, list, code, table, blockquote, yaml_front_matter
36
+ content: str
37
+ start_line: int
38
+ end_line: int
39
+ level: int = 0 # For headers
40
+ section_title: str = ""
41
+
42
+
43
+ class MarkdownChunker:
44
+ """
45
+ Chunks Markdown documents following structural boundaries.
46
+
47
+ Never splits:
48
+ - Code blocks
49
+ - Tables
50
+ - YAML front matter
51
+ - List items mid-item
52
+ - Blockquotes mid-paragraph
53
+ """
54
+
55
+ def __init__(self, config: Config):
56
+ self.config = config
57
+ self.min_chunk_size = config.min_chunk_size
58
+ self.max_chunk_size = config.max_chunk_size
59
+ self._header_stack: List[Tuple[int, str]] = []
60
+
61
+ def chunk(self, markdown_text: str) -> List[Chunk]:
62
+ """Chunk a Markdown document into structural units."""
63
+ blocks = self._parse_blocks(markdown_text)
64
+ chunks = self._group_blocks(blocks)
65
+ return chunks
66
+
67
+ # ------------------------------------------------------------------ #
68
+ # Parsing #
69
+ # ------------------------------------------------------------------ #
70
+
71
+ def _parse_blocks(self, text: str) -> List[_Block]:
72
+ """Parse Markdown into structural blocks."""
73
+ blocks: List[_Block] = []
74
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
75
+ i = 0
76
+ current_section = ""
77
+ self._header_stack = []
78
+
79
+ while i < len(lines):
80
+ line = lines[i]
81
+
82
+ # YAML front matter
83
+ if i == 0 and line.lstrip("\ufeff").strip() == "---":
84
+ block, consumed = self._parse_yaml_front_matter(lines, i)
85
+ if block:
86
+ blocks.append(block)
87
+ i += consumed
88
+ continue
89
+
90
+ # Fenced code blocks (a backtick info string may not contain backticks)
91
+ if _fence_open(line):
92
+ block, consumed = self._parse_code_block(lines, i)
93
+ block.section_title = current_section
94
+ blocks.append(block)
95
+ i += consumed
96
+ continue
97
+
98
+ # Headers
99
+ header_match = re.match(r"^(#{1,6})\s+(.+?)(?:\s+#+)?\s*$", line)
100
+ setext_match = (
101
+ not header_match
102
+ and line.strip()
103
+ and i + 1 < len(lines)
104
+ and re.match(r"^ {0,3}(=+|-+)\s*$", lines[i + 1])
105
+ and not _is_thematic_break(line)
106
+ and not self._is_list_line(line)
107
+ and not line.strip().startswith(">")
108
+ )
109
+ if header_match or setext_match:
110
+ if header_match:
111
+ level = len(header_match.group(1))
112
+ title = header_match.group(2).strip()
113
+ content, end = line, i
114
+ else:
115
+ level = 1 if lines[i + 1].strip().startswith("=") else 2
116
+ title = line.strip()
117
+ content, end = line + "\n" + lines[i + 1], i + 1
118
+ self._update_header_stack(level, title)
119
+ current_section = self._get_current_section()
120
+ blocks.append(_Block(
121
+ type="header",
122
+ content=content,
123
+ start_line=i,
124
+ end_line=end,
125
+ level=level,
126
+ section_title=current_section,
127
+ ))
128
+ i = end + 1
129
+ continue
130
+
131
+ # Tables
132
+ if self._is_table_line(line, lines, i):
133
+ block, consumed = self._parse_table(lines, i)
134
+ block.section_title = current_section
135
+ blocks.append(block)
136
+ i += consumed
137
+ continue
138
+
139
+ # Lists
140
+ if self._is_list_line(line):
141
+ block, consumed = self._parse_list(lines, i)
142
+ block.section_title = current_section
143
+ blocks.append(block)
144
+ i += consumed
145
+ continue
146
+
147
+ # Blockquotes
148
+ if line.strip().startswith(">"):
149
+ block, consumed = self._parse_blockquote(lines, i)
150
+ block.section_title = current_section
151
+ blocks.append(block)
152
+ i += consumed
153
+ continue
154
+
155
+ # Horizontal rules
156
+ if _is_thematic_break(line):
157
+ blocks.append(_Block(
158
+ type="horizontal_rule",
159
+ content=line,
160
+ start_line=i,
161
+ end_line=i,
162
+ section_title=current_section,
163
+ ))
164
+ i += 1
165
+ continue
166
+
167
+ # Paragraphs
168
+ if line.strip():
169
+ block, consumed = self._parse_paragraph(lines, i)
170
+ block.section_title = current_section
171
+ blocks.append(block)
172
+ i += consumed
173
+ continue
174
+
175
+ i += 1 # empty line
176
+
177
+ return blocks
178
+
179
+ # ------------------------------------------------------------------ #
180
+ # Block parsers #
181
+ # ------------------------------------------------------------------ #
182
+
183
+ def _parse_yaml_front_matter(
184
+ self, lines: List[str], start: int
185
+ ) -> Tuple[Optional[_Block], int]:
186
+ end = start + 1
187
+ while end < len(lines) and lines[end].strip() != "---":
188
+ end += 1
189
+ if end >= len(lines):
190
+ return None, 0
191
+ content = "\n".join(lines[start: end + 1])
192
+ return _Block(
193
+ type="yaml_front_matter",
194
+ content=content,
195
+ start_line=start,
196
+ end_line=end,
197
+ ), end - start + 1
198
+
199
+ def _parse_code_block(self, lines: List[str], start: int) -> Tuple[_Block, int]:
200
+ opener = lines[start].strip()
201
+ fence_char = opener[0]
202
+ fence_len = len(opener) - len(opener.lstrip(fence_char))
203
+ # CommonMark: closes only on a line of the same char, at least as long,
204
+ # indented at most 3 spaces, with nothing else on the line
205
+ closer = re.compile(rf"^ {{0,3}}{re.escape(fence_char)}{{{fence_len},}}[ \t]*$")
206
+ end = start + 1
207
+ while end < len(lines):
208
+ if closer.match(lines[end]):
209
+ end += 1
210
+ break
211
+ end += 1
212
+ content = "\n".join(lines[start:end])
213
+ return _Block(
214
+ type="code", content=content, start_line=start, end_line=end - 1
215
+ ), end - start
216
+
217
+ def _is_table_line(self, line: str, lines: list[str], idx: int) -> bool:
218
+ """Check if this line starts a table (header row followed by separator)."""
219
+ if "|" not in line:
220
+ return False
221
+ # Must have a separator line immediately after — only detect from the header row
222
+ if idx + 1 < len(lines):
223
+ return bool(re.match(r"^\s*\|?[\s:-]+\|[\s|:-]+\|?\s*$", lines[idx + 1]))
224
+ return False
225
+
226
+ def _parse_table(self, lines: List[str], start: int) -> Tuple[_Block, int]:
227
+ end = start
228
+ while end < len(lines) and ("|" in lines[end] and lines[end].strip()):
229
+ end += 1
230
+ content = "\n".join(lines[start:end])
231
+ return _Block(
232
+ type="table", content=content, start_line=start, end_line=end - 1
233
+ ), end - start
234
+
235
+ def _is_list_line(self, line: str) -> bool:
236
+ stripped = line.strip()
237
+ return bool(
238
+ re.match(r"^[\*\-\+]\s+",
239
+ stripped) or re.match(r"^\d+[.)]\s+", stripped)
240
+ )
241
+
242
+ def _parse_list(self, lines: List[str], start: int) -> Tuple[_Block, int]:
243
+ end = start
244
+ base_indent = len(lines[start]) - len(lines[start].lstrip())
245
+ end += 1
246
+ while end < len(lines):
247
+ line = lines[end]
248
+ if not line.strip():
249
+ peek = end + 1
250
+ while peek < len(lines) and not lines[peek].strip():
251
+ peek += 1
252
+ if peek < len(lines) and (
253
+ self._is_list_line(lines[peek])
254
+ or len(lines[peek]) - len(lines[peek].lstrip()) > base_indent
255
+ ):
256
+ end += 1
257
+ continue
258
+ break
259
+ indent = len(line) - len(line.lstrip())
260
+ if self._is_list_line(line) or indent > base_indent:
261
+ end += 1
262
+ else:
263
+ break
264
+ content = "\n".join(lines[start:end])
265
+ return _Block(
266
+ type="list", content=content, start_line=start, end_line=end - 1
267
+ ), end - start
268
+
269
+ def _parse_blockquote(self, lines: List[str], start: int) -> Tuple[_Block, int]:
270
+ end = start
271
+ while end < len(lines) and lines[end].strip().startswith(">"):
272
+ end += 1
273
+ content = "\n".join(lines[start:end])
274
+ return _Block(
275
+ type="blockquote", content=content, start_line=start, end_line=end - 1
276
+ ), end - start
277
+
278
+ def _parse_paragraph(self, lines: List[str], start: int) -> Tuple[_Block, int]:
279
+ end = start
280
+ while end < len(lines):
281
+ line = lines[end]
282
+ if not line.strip():
283
+ break
284
+ if end > start and (
285
+ line.strip().startswith("#")
286
+ or _fence_open(line)
287
+ or self._is_list_line(line)
288
+ or line.strip().startswith(">")
289
+ or "|" in line
290
+ ):
291
+ break
292
+ end += 1
293
+ content = "\n".join(lines[start:end])
294
+ return _Block(
295
+ type="paragraph", content=content, start_line=start, end_line=end - 1
296
+ ), max(end - start, 1)
297
+
298
+ # ------------------------------------------------------------------ #
299
+ # Header stack #
300
+ # ------------------------------------------------------------------ #
301
+
302
+ def _update_header_stack(self, level: int, title: str) -> None:
303
+ self._header_stack = [(l, t)
304
+ for l, t in self._header_stack if l < level]
305
+ self._header_stack.append((level, title))
306
+
307
+ def _get_current_section(self) -> str:
308
+ return " > ".join(t for _, t in self._header_stack)
309
+
310
+ # ------------------------------------------------------------------ #
311
+ # Grouping #
312
+ # ------------------------------------------------------------------ #
313
+
314
+ def _group_blocks(self, blocks: List[_Block]) -> List[Chunk]:
315
+ """Group blocks into chunks respecting structure and size.
316
+
317
+ Rules: every header starts a new chunk once the current chunk has body
318
+ text; a chunk holding only headers is never flushed for size, so headers
319
+ always travel with their content; atomic blocks are never split.
320
+ """
321
+ chunks: List[Chunk] = []
322
+ current: List[_Block] = []
323
+ current_size = 0
324
+ has_body = False
325
+ ATOMIC = {"code", "table", "yaml_front_matter", "blockquote"}
326
+
327
+ def flush() -> None:
328
+ nonlocal current, current_size, has_body
329
+ chunks.append(self._make_chunk(current))
330
+ current, current_size, has_body = [], 0, False
331
+
332
+ for block in blocks:
333
+ bsize = len(block.content)
334
+ if block.type == "header":
335
+ if has_body:
336
+ flush()
337
+ elif has_body and current_size + bsize > self.max_chunk_size:
338
+ flush()
339
+
340
+ current.append(block)
341
+ current_size += bsize
342
+ if block.type != "header":
343
+ has_body = True
344
+ if block.type in ATOMIC and current_size >= self.max_chunk_size:
345
+ flush()
346
+
347
+ if current:
348
+ tail = self._make_chunk(current)
349
+ if not has_body and chunks:
350
+ # Trailing headers with no content (e.g. an empty last section)
351
+ chunks[-1] = self._join(chunks[-1], tail)
352
+ else:
353
+ chunks.append(tail)
354
+
355
+ return self._merge_small_chunks(chunks)
356
+
357
+ def _make_chunk(self, blocks: List[_Block]) -> Chunk:
358
+ text = "\n\n".join(b.content for b in blocks)
359
+ # Section of the content; preamble or front matter falls back to the first header's
360
+ section_title = next(
361
+ (b.section_title for b in blocks if b.type != "header" and b.section_title),
362
+ next((b.section_title for b in blocks if b.section_title), ""),
363
+ )
364
+ content_types = list(set(b.type for b in blocks))
365
+ return Chunk(
366
+ text=text,
367
+ section_title=section_title,
368
+ start_line=blocks[0].start_line,
369
+ end_line=blocks[-1].end_line,
370
+ content_types=content_types,
371
+ )
372
+
373
+ @staticmethod
374
+ def _join(a: Chunk, b: Chunk) -> Chunk:
375
+ """Concatenate two adjacent chunks; the larger part names the section."""
376
+ major = a if len(a.text) >= len(b.text) else b
377
+ return Chunk(
378
+ text=a.text + "\n\n" + b.text,
379
+ section_title=major.section_title or a.section_title or b.section_title,
380
+ start_line=a.start_line,
381
+ end_line=b.end_line,
382
+ content_types=list(set(a.content_types + b.content_types)),
383
+ )
384
+
385
+ def _merge_small_chunks(self, chunks: List[Chunk]) -> List[Chunk]:
386
+ """Merge chunks below min_chunk_size forward (repeatedly), capped at 2x max."""
387
+ cap = self.max_chunk_size * 2
388
+ merged: List[Chunk] = []
389
+ for c in chunks:
390
+ if not c.text.strip():
391
+ continue
392
+ if merged and len(merged[-1].text) < self.min_chunk_size \
393
+ and len(merged[-1].text) + len(c.text) <= cap:
394
+ merged[-1] = self._join(merged[-1], c)
395
+ else:
396
+ merged.append(c)
397
+ # A too-small final chunk joins the previous one, within the same cap
398
+ if len(merged) >= 2 and len(merged[-1].text) < self.min_chunk_size \
399
+ and len(merged[-2].text) + len(merged[-1].text) <= cap:
400
+ last = merged.pop()
401
+ merged[-1] = self._join(merged[-1], last)
402
+ return merged
mdkeychunker/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ """CLI for MDKeyChunker."""
2
+ import argparse
3
+ import sys
4
+ from pathlib import Path
5
+ from .config import Config
6
+ from .pipeline import Pipeline
7
+
8
+
9
+ def main() -> int:
10
+ p = argparse.ArgumentParser(
11
+ description="MDKeyChunker — Markdown chunking for RAG")
12
+ p.add_argument("input", help="Input Markdown file")
13
+ p.add_argument("-o", "--output", help="Output JSONL file")
14
+ p.add_argument("--env", help="Path to .env file")
15
+ p.add_argument("--stats", action="store_true", help="Print statistics")
16
+ p.add_argument("--no-merge", action="store_true",
17
+ help="Disable key-based chunk merging")
18
+ p.add_argument("--summary", help="Output summary text file")
19
+ p.add_argument("--provider", choices=["openai", "anthropic", "openai_compatible"],
20
+ help="LLM provider (overrides .env)")
21
+ p.add_argument("--model", help="LLM model name (overrides .env)")
22
+ p.add_argument(
23
+ "--base-url", help="LLM base URL for Ollama/vLLM (overrides .env)")
24
+ args = p.parse_args()
25
+
26
+ input_path = Path(args.input)
27
+ if not input_path.exists():
28
+ print(f"Error: {args.input} not found", file=sys.stderr)
29
+ return 1
30
+
31
+ config = Config.from_env(args.env)
32
+
33
+ if args.no_merge:
34
+ config.merge_by_keys = False
35
+ if args.provider:
36
+ config.llm_provider = args.provider
37
+ if args.model:
38
+ config.llm_model = args.model
39
+ if args.base_url:
40
+ config.llm_base_url = args.base_url
41
+
42
+ pipeline = Pipeline(config)
43
+ chunks = pipeline.process_file(str(input_path))
44
+
45
+ out = args.output or str(input_path.with_suffix(".jsonl"))
46
+ pipeline.save_jsonl(chunks, out)
47
+ print(f"✓ {len(chunks)} chunks → {out}")
48
+
49
+ if args.summary:
50
+ pipeline.save_summary(chunks, args.summary)
51
+ print(f"✓ Summary → {args.summary}")
52
+
53
+ if args.stats:
54
+ print(f" Keys: {len(set(c.key for c in chunks if c.key))}")
55
+ print(f" Entities: {sum(len(c.entities) for c in chunks)}")
56
+ print(
57
+ f" Avg tokens: {sum(c.token_count for c in chunks) // max(len(chunks), 1)}"
58
+ )
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())
mdkeychunker/config.py ADDED
@@ -0,0 +1,40 @@
1
+ """Configuration for MDKeyChunker."""
2
+ import os
3
+ from dataclasses import dataclass
4
+ from dotenv import load_dotenv
5
+
6
+
7
+ @dataclass
8
+ class Config:
9
+ # LLM
10
+ llm_provider: str = "openai" # openai | anthropic | openai_compatible
11
+ llm_api_key: str = ""
12
+ llm_base_url: str = "" # For Ollama/vLLM: http://localhost:11434/v1
13
+ llm_model: str = "gpt-4o-mini"
14
+ # Chunking
15
+ min_chunk_size: int = 100 # Minimum chars per chunk
16
+ max_chunk_size: int = 1500 # Soft max chars per chunk
17
+ # Restructuring
18
+ merge_by_keys: bool = True # Enable key-based chunk merging
19
+ max_merged_size: int = 3000 # Max size after merging
20
+ # Below this, keyless chunks get context enrichment
21
+ min_orphan_size: int = 200
22
+ # General
23
+ log_level: str = "INFO"
24
+
25
+ @classmethod
26
+ def from_env(cls, env_file: str | None = None) -> "Config":
27
+ load_dotenv(env_file or ".env")
28
+ return cls(
29
+ llm_provider=os.getenv("LLM_PROVIDER", "openai"),
30
+ llm_api_key=os.getenv("LLM_API_KEY", ""),
31
+ llm_base_url=os.getenv("LLM_BASE_URL", ""),
32
+ llm_model=os.getenv("LLM_MODEL", "gpt-4o-mini"),
33
+ min_chunk_size=int(os.getenv("MIN_CHUNK_SIZE", "100")),
34
+ max_chunk_size=int(os.getenv("MAX_CHUNK_SIZE", "1500")),
35
+ merge_by_keys=os.getenv(
36
+ "MERGE_BY_KEYS", "true").lower() in ("true", "1", "yes"),
37
+ max_merged_size=int(os.getenv("MAX_MERGED_SIZE", "3000")),
38
+ min_orphan_size=int(os.getenv("MIN_ORPHAN_SIZE", "200")),
39
+ log_level=os.getenv("LOG_LEVEL", "INFO"),
40
+ )