Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ EMBEDDING_BATCH_SIZE=32
INGEST_RECURSIVE=true
INGEST_ROOTS=[]

# OCR fallback for scanned/image-only PDF pages (requires the `tesseract` binary
# on the host; see docs/ocr.md). Set OCR_ENABLED=false to disable.
OCR_ENABLED=true
OCR_LANGUAGE=eng
OCR_MIN_CHARS_PER_PAGE=20

RAG_TOP_K=5
RETRIEVAL_MODE=hybrid
BM25_WEIGHT=0.5
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ FROM python:3.13-slim AS base

WORKDIR /app

# tesseract-ocr: required at runtime for scanned/image-only PDF pages (see docs/ocr.md).
RUN apt-get update \
&& apt-get install --no-install-recommends -y tesseract-ocr \
&& rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir uv

COPY pyproject.toml README.md /app/
Expand Down
2 changes: 2 additions & 0 deletions docs/agent-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Agents (and humans) move faster when they:
| Architecture decisions | `docs/adr/` |
| CLI commands | `localrag/cli/app.py`, `localrag/cli/commands/*.py` |
| Parsing a file type | `localrag/ingestion/parsers/`, `localrag/ingestion/loader.py` |
| PDF OCR (scanned/image-only pages) | `localrag/ingestion/parsers/pdf.py`, `OCR_*` in `localrag/settings.py`, [ocr.md](ocr.md) |
| Chunking strategy and boundaries | `localrag/ingestion/structural_chunker.py`, `localrag/ingestion/chunker.py`, `localrag/settings.py` |
| Embeddings / Ollama HTTP for embed | `localrag/ingestion/embedder.py` |
| Ingest orchestration | `localrag/ingestion/service.py` |
Expand All @@ -48,6 +49,7 @@ Agents (and humans) move faster when they:
| Ollama HTTP request/response shapes | `localrag/ollama/schemas.py` (used by embedder, RAG engine, health, setup) |
| Prompt / answer streaming | `localrag/rag/prompt.py`, `localrag/rag/engine.py` |
| Human Ollama install (not Python) | [ollama.md](ollama.md) |
| Human Tesseract install (not Python) | [ocr.md](ocr.md) |

## Commands (uv)

Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ flowchart LR
| HTTP API (persistence) | `localrag/api/repository.py` | `ChromaCollectionRepository` → `VectorStore` for collection list/delete and health’s collection list |
| CLI | `localrag/cli/app.py`, `localrag/cli/commands/*` | `localrag` Typer entry (`pyproject` `[project.scripts]`) |
| Ingestion orchestration | `localrag/ingestion/service.py` | `IngestionService`: paths → parse → chunk → embed → upsert |
| File formats | `localrag/ingestion/parsers/*` | pdf, docx, markdown, text, code |
| File formats | `localrag/ingestion/parsers/*` | pdf (with OCR fallback via pypdfium2 + pytesseract), docx, markdown, text, code |
| Chunking / embed | `localrag/ingestion/structural_chunker.py`, `localrag/ingestion/chunker.py`, `localrag/ingestion/embedder.py` | Structural chunking by markdown/code/text boundaries with fixed fallback; Ollama **`POST /api/embed`** (see `localrag/ollama/schemas.py`) |
| Storage | `localrag/storage/vector_store.py` | Chroma client wrapper |
| RAG | `localrag/rag/retriever.py`, `bm25_index.py`, `engine.py`, `prompt.py` | Hybrid retrieval (vector + BM25), freshness decay reranking, prompt build, LLM call |
Expand Down Expand Up @@ -104,6 +104,7 @@ The `reasoning` field records which path was taken. The router in `localrag/api/
## Extension points

- **New file type:** add a parser under `localrag/ingestion/parsers/`, register it via `loader` / parser dispatch (see `localrag/ingestion/loader.py`).
- **PDF OCR behavior:** edit `localrag/ingestion/parsers/pdf.py` and the `OCR_*` settings in `localrag/settings.py`; see [ocr.md](ocr.md) for the Tesseract install requirement.
- **Chunking behavior:** edit `localrag/ingestion/structural_chunker.py` (or fixed fallback `localrag/ingestion/chunker.py`) and related knobs in `localrag/settings.py`.
- **New HTTP surface:** add schemas in `localrag/api/schemas.py`, application logic in `localrag/api/service.py`, persistence in `localrag/api/repository.py` (if new storage access), thin router in `localrag/api/routers/`, wire DI in `localrag/api/dependencies.py`, include the router in `localrag/api/main.py`.
- **New CLI command:** new module under `localrag/cli/commands/`, register in `localrag/cli/app.py`.
Expand Down
25 changes: 25 additions & 0 deletions docs/ocr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# PDF OCR

LocalRAG falls back to **OCR** for scanned/image-only PDF pages during ingestion. `pypdf` extracts each page's text layer first; any page whose extracted text is shorter than `OCR_MIN_CHARS_PER_PAGE` is rasterized with **pypdfium2** and read with **Tesseract** via `pytesseract`. This is implemented in `localrag/ingestion/parsers/pdf.py`.

Tesseract is a separate binary—not a Python package—so it must be installed on whatever host or container runs ingestion.

## Settings

| Env var | Default | Meaning |
| --- | --- | --- |
| `OCR_ENABLED` | `true` | Set to `false` to disable OCR entirely; scanned pages then yield empty text, as before this feature existed. |
| `OCR_LANGUAGE` | `eng` | Tesseract language code (`ollama`-style tag list: `tesseract --list-langs`). Install the matching `tesseract-ocr-<lang>` package for non-English text. |
| `OCR_MIN_CHARS_PER_PAGE` | `20` | Pages with a `pypdf` text layer shorter than this are treated as scanned and sent through OCR. |

## Installing Tesseract

- **Debian/Ubuntu (and this project's Docker image):** `apt-get install tesseract-ocr` (add `tesseract-ocr-<lang>` for extra languages, e.g. `tesseract-ocr-spa`).
- **macOS:** `brew install tesseract`.
- **Windows:** see the [Tesseract wiki install guide](https://github.com/tesseract-ocr/tesseract/blob/main/INSTALL.md).

If `tesseract` is missing from `PATH`, OCR fails silently per page (logged as a warning) and ingestion keeps whatever text `pypdf` extracted—ingestion never fails because of a missing OCR binary.

## Docker

The provided `Dockerfile` installs `tesseract-ocr` (English only) in the base image. To OCR other languages inside Docker, add the relevant `tesseract-ocr-<lang>` package to the `apt-get install` line and set `OCR_LANGUAGE` accordingly.
45 changes: 41 additions & 4 deletions localrag/ingestion/parsers/pdf.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,50 @@
from __future__ import annotations

import logging
from pathlib import Path

from pypdf import PdfReader
import pypdfium2 as pdfium
import pytesseract
from pypdf import PageObject, PdfReader

from localrag.settings import Settings, get_settings

logger = logging.getLogger(__name__)


def parse_pdf(path: Path) -> str:
settings = get_settings()
reader = PdfReader(str(path))
parts: list[str] = []
for page in reader.pages:
parts.append(page.extract_text() or "")
ocr_doc = pdfium.PdfDocument(str(path)) if settings.ocr_enabled else None
try:
parts = [
_extract_page_text(page, index, ocr_doc, settings)
for index, page in enumerate(reader.pages)
]
finally:
if ocr_doc is not None:
ocr_doc.close()
return "\n".join(parts).strip()


def _extract_page_text(
page: PageObject,
index: int,
ocr_doc: pdfium.PdfDocument | None,
settings: Settings,
) -> str:
text = (page.extract_text() or "").strip()
if ocr_doc is None or len(text) >= settings.ocr_min_chars_per_page:
return text
ocr_text = _ocr_page(ocr_doc, index, settings.ocr_language)
return ocr_text or text


def _ocr_page(ocr_doc: pdfium.PdfDocument, index: int, language: str) -> str:
try:
bitmap = ocr_doc[index].render(scale=2.0)
image = bitmap.to_pil()
return pytesseract.image_to_string(image, lang=language).strip()
except Exception:
logger.warning("ocr_page_failed page=%d", index, exc_info=True)
return ""
11 changes: 11 additions & 0 deletions localrag/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class Settings(BaseSettings):
non-empty, only files and directories under those paths (after resolving) are
allowed through the HTTP ingest API; an empty list disables that restriction.

**PDF OCR** — When ``ocr_enabled`` is true (default), PDF pages whose extracted
text layer is shorter than ``ocr_min_chars_per_page`` (scanned/image-only pages)
are rasterized and run through Tesseract OCR (``ocr_language`` is a Tesseract
language code, e.g. ``eng``). Requires the ``tesseract`` binary on the host; if
it is missing, OCR fails silently per-page and the original (possibly empty)
text-layer output is kept. See `docs/ocr.md`.

**RAG** — ``rag_top_k`` is how many chunks are retrieved for context.
``rag_system_prompt`` is the system message for the answering model.

Expand Down Expand Up @@ -68,6 +75,10 @@ class Settings(BaseSettings):
ingest_recursive: bool = True
ingest_roots: list[str] = []

ocr_enabled: bool = True
ocr_language: str = "eng"
ocr_min_chars_per_page: int = 20

rag_top_k: int = 5
retrieval_mode: str = "hybrid"
bm25_weight: float = 0.5
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ dependencies = [
"chromadb>=1.0",
"httpx>=0.28",
"pypdf>=5.4",
"pypdfium2>=4.30",
"pytesseract>=0.3.13",
"python-docx>=1.1",
"pydantic-settings>=2.8",
"rich>=13",
Expand Down Expand Up @@ -114,7 +116,6 @@ source = ["localrag"]
omit = [
"localrag/cli/*",
"localrag/cli/**",
"localrag/ingestion/parsers/pdf.py",
]

[tool.coverage.report]
Expand Down
119 changes: 119 additions & 0 deletions tests/test_pdf_ocr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

import pytest

from localrag.ingestion.parsers import pdf as pdf_module
from localrag.settings import Settings


class _FakePage:
def __init__(self, text: str) -> None:
self._text = text

def extract_text(self) -> str:
return self._text


class _FakeReader:
def __init__(self, pages: list[_FakePage]) -> None:
self.pages = pages


class _FakeBitmap:
def to_pil(self) -> object:
return object()


class _FakePdfPage:
def render(self, scale: float = 2.0) -> _FakeBitmap:
return _FakeBitmap()


class _FakePdfDocument:
def __init__(self, page_count: int) -> None:
self._pages = [_FakePdfPage() for _ in range(page_count)]
self.closed = False

def __getitem__(self, index: int) -> _FakePdfPage:
return self._pages[index]

def close(self) -> None:
self.closed = True


def _settings(**overrides: Any) -> Settings:
return Settings(**overrides)


def test_parse_pdf_uses_text_layer_when_long_enough(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
path = tmp_path / "a.pdf"
path.write_bytes(b"%PDF-1.4\n")

monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("x" * 50)]))
monkeypatch.setattr(pdf_module, "get_settings", lambda: _settings(ocr_min_chars_per_page=20))
monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", lambda _: _FakePdfDocument(1))

def _fail_ocr(*_args: Any, **_kwargs: Any) -> str:
raise AssertionError("OCR should not run when the text layer is long enough")

monkeypatch.setattr(pdf_module.pytesseract, "image_to_string", _fail_ocr)

assert pdf_module.parse_pdf(path) == "x" * 50


def test_parse_pdf_falls_back_to_ocr_when_text_layer_too_short(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
path = tmp_path / "a.pdf"
path.write_bytes(b"%PDF-1.4\n")

fake_doc = _FakePdfDocument(1)
monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("")]))
monkeypatch.setattr(
pdf_module, "get_settings", lambda: _settings(ocr_min_chars_per_page=20, ocr_language="eng")
)
monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", lambda _: fake_doc)
monkeypatch.setattr(
pdf_module.pytesseract, "image_to_string", lambda _image, lang: f"OCR:{lang}"
)

assert pdf_module.parse_pdf(path) == "OCR:eng"
assert fake_doc.closed


def test_parse_pdf_skips_ocr_when_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
path = tmp_path / "a.pdf"
path.write_bytes(b"%PDF-1.4\n")

monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("")]))
monkeypatch.setattr(pdf_module, "get_settings", lambda: _settings(ocr_enabled=False))

def _fail_open(*_args: Any, **_kwargs: Any) -> _FakePdfDocument:
raise AssertionError("PdfDocument should not be opened when OCR is disabled")

monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", _fail_open)

assert pdf_module.parse_pdf(path) == ""


def test_parse_pdf_ocr_failure_falls_back_to_text_layer(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
path = tmp_path / "a.pdf"
path.write_bytes(b"%PDF-1.4\n")

monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("short")]))
monkeypatch.setattr(pdf_module, "get_settings", lambda: _settings(ocr_min_chars_per_page=20))
monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", lambda _: _FakePdfDocument(1))

def _raise(*_args: Any, **_kwargs: Any) -> str:
raise RuntimeError("tesseract not found")

monkeypatch.setattr(pdf_module.pytesseract, "image_to_string", _raise)

assert pdf_module.parse_pdf(path) == "short"
Loading
Loading