feat: implement recursive RAG text section-aware chunking utility
Browse files- backend/app/rag/chunker.py +120 -1
- backend/app/routes/upload.py +2 -2
- backend/tests/conftest.py +23 -6
- backend/tests/test_chunker.py +50 -1
backend/app/rag/chunker.py
CHANGED
|
@@ -1 +1,120 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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
|
| 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 =
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|