s4nkar commited on
Commit
ae2ebed
·
1 Parent(s): 79330a8

feat: implement recursive RAG text section-aware chunking utility

Browse files
backend/app/rag/chunker.py CHANGED
@@ -1 +1,120 @@
1
- """Section-aware recursive chunking of PDF pages."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ import pymupdf
5
+
6
+ from app.config import settings
7
+
8
+ _SEPARATORS = ["\n\n", "\n", ". ", " "]
9
+
10
+
11
+ @dataclass
12
+ class Chunk:
13
+ text: str
14
+ filename: str
15
+ page: int
16
+ section: str
17
+ chunk_index: int
18
+
19
+
20
+ def _is_heading(line: str) -> bool:
21
+ line = line.strip()
22
+ if not line or not 3 <= len(line) <= 80:
23
+ return False
24
+ if line[-1] in ".,:;!?":
25
+ return False
26
+ if not (line[0].isupper() or line[0].isdigit()):
27
+ return False
28
+ if len(line.split()) > 8:
29
+ return False
30
+ return True
31
+
32
+
33
+ def _merge_pieces(pieces: list[str], chunk_size: int, chunk_overlap: int) -> list[str]:
34
+ """Merge small pieces into chunks with piece-boundary overlap (LangChain-style)."""
35
+ chunks: list[str] = []
36
+ window: list[str] = []
37
+ window_len = 0
38
+
39
+ for piece in pieces:
40
+ piece_len = len(piece)
41
+ if window and window_len + 1 + piece_len > chunk_size:
42
+ chunks.append(" ".join(window))
43
+ # Slide window back: drop pieces from the front until overlap fits
44
+ while window and (window_len > chunk_overlap or window_len + 1 + piece_len > chunk_size):
45
+ window_len -= len(window[0]) + (1 if len(window) > 1 else 0)
46
+ window.pop(0)
47
+ window.append(piece)
48
+ window_len += piece_len + (1 if len(window) > 1 else 0)
49
+
50
+ if window:
51
+ chunks.append(" ".join(window))
52
+ return chunks
53
+
54
+
55
+ def _split_recursive(text: str, separators: list[str], chunk_size: int, chunk_overlap: int) -> list[str]:
56
+ if not separators:
57
+ step = max(1, chunk_size - chunk_overlap)
58
+ return [text[i : i + chunk_size] for i in range(0, len(text), step)]
59
+
60
+ # Find the first separator that actually appears in the text
61
+ sep = separators[-1]
62
+ rest: list[str] = []
63
+ for i, s in enumerate(separators):
64
+ if s in text:
65
+ sep, rest = s, separators[i + 1:]
66
+ break
67
+
68
+ pieces: list[str] = []
69
+ for part in text.split(sep):
70
+ part = part.strip()
71
+ if not part:
72
+ continue
73
+ if len(part) <= chunk_size:
74
+ pieces.append(part)
75
+ else:
76
+ pieces.extend(_split_recursive(part, rest, chunk_size, chunk_overlap))
77
+
78
+ return _merge_pieces(pieces, chunk_size, chunk_overlap)
79
+
80
+
81
+ def chunk_pdf(path: Path) -> list[Chunk]:
82
+ doc = pymupdf.open(str(path))
83
+ all_chunks: list[Chunk] = []
84
+ chunk_index = 0
85
+ current_section = ""
86
+
87
+ for page_num in range(doc.page_count):
88
+ text = doc[page_num].get_text("text")
89
+ if not text.strip():
90
+ continue
91
+
92
+ for line in text.splitlines():
93
+ if _is_heading(line):
94
+ current_section = line.strip()
95
+ break
96
+
97
+ page_chunks = _split_recursive(
98
+ text,
99
+ _SEPARATORS,
100
+ settings.chunk_size,
101
+ settings.chunk_overlap,
102
+ )
103
+
104
+ for chunk_text in page_chunks:
105
+ chunk_text = chunk_text.strip()
106
+ if not chunk_text:
107
+ continue
108
+ all_chunks.append(
109
+ Chunk(
110
+ text=chunk_text,
111
+ filename=path.name,
112
+ page=page_num + 1,
113
+ section=current_section,
114
+ chunk_index=chunk_index,
115
+ )
116
+ )
117
+ chunk_index += 1
118
+
119
+ doc.close()
120
+ return all_chunks
backend/app/routes/upload.py CHANGED
@@ -1,6 +1,6 @@
1
  from pathlib import Path
2
 
3
- import fitz
4
  from fastapi import APIRouter, File, HTTPException, UploadFile
5
  from pydantic import BaseModel
6
 
@@ -36,7 +36,7 @@ async def upload(files: list[UploadFile] = File(...)) -> list[DocumentMeta]:
36
  dest = UPLOAD_DIR / (file.filename or "upload.pdf")
37
  dest.write_bytes(contents)
38
 
39
- doc = fitz.open(stream=contents, filetype="pdf")
40
  pages = doc.page_count
41
  doc.close()
42
 
 
1
  from pathlib import Path
2
 
3
+ import pymupdf
4
  from fastapi import APIRouter, File, HTTPException, UploadFile
5
  from pydantic import BaseModel
6
 
 
36
  dest = UPLOAD_DIR / (file.filename or "upload.pdf")
37
  dest.write_bytes(contents)
38
 
39
+ doc = pymupdf.open(stream=contents, filetype="pdf")
40
  pages = doc.page_count
41
  doc.close()
42
 
backend/tests/conftest.py CHANGED
@@ -1,11 +1,20 @@
1
  from pathlib import Path
2
 
3
- import fitz
4
  import pytest
5
  from fastapi.testclient import TestClient
6
 
7
  from app.main import app
8
 
 
 
 
 
 
 
 
 
 
9
 
10
  @pytest.fixture
11
  def client() -> TestClient:
@@ -14,11 +23,19 @@ def client() -> TestClient:
14
 
15
  @pytest.fixture
16
  def sample_pdf(tmp_path: Path) -> Path:
17
- doc = fitz.open()
18
- page = doc.new_page()
19
- page.insert_text((72, 72), "Page one of a test document.")
20
- doc.new_page()
21
- doc[-1].insert_text((72, 72), "Page two of a test document.")
 
 
 
 
 
 
 
 
22
  path = tmp_path / "sample.pdf"
23
  doc.save(str(path))
24
  doc.close()
 
1
  from pathlib import Path
2
 
3
+ import pymupdf
4
  import pytest
5
  from fastapi.testclient import TestClient
6
 
7
  from app.main import app
8
 
9
+ _PARA = (
10
+ "Retrieval-augmented generation combines a dense retrieval step with a "
11
+ "generative language model to produce grounded, cited answers. "
12
+ "The retriever selects the most relevant passages from a large corpus, "
13
+ "and the generator conditions its output on those passages. "
14
+ "Hybrid retrieval systems that combine dense vector search with sparse "
15
+ "keyword methods such as BM25 consistently outperform either approach alone. "
16
+ )
17
+
18
 
19
  @pytest.fixture
20
  def client() -> TestClient:
 
23
 
24
  @pytest.fixture
25
  def sample_pdf(tmp_path: Path) -> Path:
26
+ doc = pymupdf.open()
27
+
28
+ for heading, body in [
29
+ ("Introduction", _PARA * 5),
30
+ ("Methods", _PARA * 5),
31
+ ]:
32
+ page = doc.new_page()
33
+ page.insert_textbox(
34
+ pymupdf.Rect(72, 72, 540, 720),
35
+ f"{heading}\n\n{body}",
36
+ fontsize=11,
37
+ )
38
+
39
  path = tmp_path / "sample.pdf"
40
  doc.save(str(path))
41
  doc.close()
backend/tests/test_chunker.py CHANGED
@@ -1 +1,50 @@
1
- """Tests for rag/chunker.py — added in Phase 7."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from app.config import settings
4
+ from app.rag.chunker import chunk_pdf
5
+
6
+
7
+ def test_produces_chunks(sample_pdf: Path) -> None:
8
+ chunks = chunk_pdf(sample_pdf)
9
+ assert len(chunks) > 0
10
+
11
+
12
+ def test_no_chunk_exceeds_max_size(sample_pdf: Path) -> None:
13
+ max_allowed = settings.chunk_size + settings.chunk_overlap
14
+ for chunk in chunk_pdf(sample_pdf):
15
+ assert len(chunk.text) <= max_allowed, (
16
+ f"Chunk {chunk.chunk_index} has {len(chunk.text)} chars"
17
+ )
18
+
19
+
20
+ def test_chunk_index_is_sequential(sample_pdf: Path) -> None:
21
+ chunks = chunk_pdf(sample_pdf)
22
+ assert [c.chunk_index for c in chunks] == list(range(len(chunks)))
23
+
24
+
25
+ def test_page_numbers_are_valid(sample_pdf: Path) -> None:
26
+ chunks = chunk_pdf(sample_pdf)
27
+ pages = {c.page for c in chunks}
28
+ assert pages <= {1, 2}
29
+ assert all(c.page >= 1 for c in chunks)
30
+
31
+
32
+ def test_no_chunk_spans_pages(sample_pdf: Path) -> None:
33
+ # Chunking is done per page so every chunk carries exactly one page number.
34
+ chunks = chunk_pdf(sample_pdf)
35
+ assert all(isinstance(c.page, int) for c in chunks)
36
+
37
+
38
+ def test_filename_is_preserved(sample_pdf: Path) -> None:
39
+ chunks = chunk_pdf(sample_pdf)
40
+ assert all(c.filename == "sample.pdf" for c in chunks)
41
+
42
+
43
+ def test_section_heading_detected(sample_pdf: Path) -> None:
44
+ chunks = chunk_pdf(sample_pdf)
45
+ sections = {c.section for c in chunks}
46
+ assert any(s in ("Introduction", "Methods") for s in sections)
47
+
48
+
49
+ def test_no_empty_chunk_text(sample_pdf: Path) -> None:
50
+ assert all(c.text.strip() for c in chunk_pdf(sample_pdf))