Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Next

### Added

- Experimental: `LiteParseLoader` — an alternative PDF/document loader backed by [LiteParse](https://github.com/run-llama/liteparse), a local Rust-based parser with optional OCR support. Install with `pip install "neo4j-graphrag[liteparse]"` and pass it as `file_loader` to `SimpleKGPipeline`.

### Changed

- Experimental: `GraphSchema` validation now rejects `KEY` and `EXISTENCE` constraints on the same node or relationship property (including composite KEY members), since KEY already implies mandatory presence. Legacy `PropertyType.required` migration no longer adds redundant EXISTENCE constraints for KEY-covered properties. The schema-from-text extraction prompt includes the same rule.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Example: using LiteParseLoader as an alternative PDF loader.

LiteParse (https://github.com/run-llama/liteparse) is a local, zero-cloud PDF
parser with optional OCR support. Install the optional extra before running:

pip install "neo4j-graphrag[liteparse]"

Usage::

from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline
from neo4j_graphrag.experimental.components.liteparse_loader import LiteParseLoader

pipeline = SimpleKGPipeline(
llm=...,
driver=...,
embedder=...,
file_loader=LiteParseLoader(ocr_enabled=True),
from_file=True,
)
await pipeline.run_async(file_path="document.pdf")
"""

# LiteParseLoader lives in the main package — import it from there.
from neo4j_graphrag.experimental.components.liteparse_loader import LiteParseLoader

__all__ = ["LiteParseLoader"]
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ examples = [
# only advertise/install the `nlp` extra on Python <3.14.
nlp = ["spacy==3.8.7; python_version < '3.14'"]
fuzzy-matching = ["rapidfuzz>=3.12.2,<4.0.0"]
liteparse = ["liteparse>=2.1.0,<3.0.0"]

[dependency-groups]
dev = [
Expand Down Expand Up @@ -115,6 +116,7 @@ exclude = ["docs"]
module = [
"weaviate.*",
"sentence_transformers.*",
"liteparse.*",
"conftest",
]
ignore_missing_imports = true
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Document loaders: base class, PDF, Markdown, and extension-based file dispatch."""
"""Document loaders: base class, PDF, and Markdown."""

from __future__ import annotations

Expand Down
134 changes: 134 additions & 0 deletions src/neo4j_graphrag/experimental/components/liteparse_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Copyright (c) "Neo4j"
# Neo4j Sweden AB [https://neo4j.com]
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""LiteParse-backed document loader (optional extra: ``neo4j-graphrag[liteparse]``)."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Dict, Optional, Union

import fsspec
from fsspec import AbstractFileSystem
from fsspec.implementations.local import LocalFileSystem

from neo4j_graphrag.exceptions import PdfLoaderError
from neo4j_graphrag.experimental.components.data_loader import DataLoader, is_default_fs
from neo4j_graphrag.experimental.components.types import (
DocumentInfo,
DocumentType,
LoadedDocument,
)

try:
from liteparse import LiteParse as _LiteParse

_LITEPARSE_AVAILABLE = True
except ImportError:
_LiteParse = None # type: ignore[assignment,misc]
_LITEPARSE_AVAILABLE = False


class LiteParseLoader(DataLoader):
"""Loads and parses documents using LiteParse (local, no cloud dependency).

LiteParse uses a compiled Rust core and optional Tesseract OCR to extract text
from PDFs, DOCX, XLSX, PPTX, and images. It runs fully offline — no API key
or network access required.

Requires the ``liteparse`` optional extra::

pip install "neo4j-graphrag[liteparse]"

Args:
ocr_enabled: Enable Tesseract OCR for scanned pages.
ocr_server_url: URL of a remote OCR server (optional).
ocr_language: Tesseract language code, e.g. ``"eng"``, ``"fra"``.
dpi: Rendering resolution for OCR (default 300).
target_pages: Page range string, e.g. ``"1-5,10,15-20"``.
password: Password for encrypted PDFs.
output_format: Output format — ``"text"`` (default, plain text) or
``"markdown"`` (requires LiteParse >=2.1). Markdown output preserves
``#``/``##`` section headers, making it suitable for use with
``HierarchicalTextSplitter(header_strategy="markdown")``.
"""

def __init__(
self,
ocr_enabled: bool = False,
ocr_server_url: Optional[str] = None,
ocr_language: Optional[str] = None,
dpi: Optional[int] = None,
target_pages: Optional[str] = None,
password: Optional[str] = None,
output_format: str = "text",
) -> None:
optional = {
"ocr_server_url": ocr_server_url,
"ocr_language": ocr_language,
"dpi": dpi,
"target_pages": target_pages,
"password": password,
}
self._kwargs: Dict[str, Any] = {
"ocr_enabled": ocr_enabled,
"output_format": output_format,
**{k: v for k, v in optional.items() if v is not None},
}
self._parser: Any = None # lazily initialised

def _get_parser(self) -> Any:
if not _LITEPARSE_AVAILABLE:
raise ImportError(
"liteparse is required for LiteParseLoader. "
'Install it with: pip install "neo4j-graphrag[liteparse]"'
)
if self._parser is None:
self._parser = _LiteParse(**self._kwargs)
return self._parser

def load_file(self, filepath: str, fs: AbstractFileSystem) -> str:
"""Parse a document and return the full extracted text."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PdfLoader.load_file and MarkdownLoader.load_file both use file as the parameter name, but LiteParseLoader.load_file uses filepath. This can be renamed to file to stay consistent.

parser = self._get_parser() # ImportError propagates; not caught below
try:
if is_default_fs(fs):
result = parser.parse(filepath)
else:
with fs.open(filepath, "rb") as fp:
result = parser.parse(fp.read())
return str(result.text)
except Exception as e:
raise PdfLoaderError(e) from e

async def run(
self,
filepath: Union[str, Path],
metadata: Optional[Dict[str, str]] = None,
fs: Optional[Union[AbstractFileSystem, str]] = None,
) -> LoadedDocument:
if not isinstance(filepath, str):
filepath = str(filepath)
if isinstance(fs, str):
fs = fsspec.filesystem(fs)
elif fs is None:
fs = LocalFileSystem()
text = self.load_file(filepath, fs)
return LoadedDocument(
text=text,
document_info=DocumentInfo(
path=filepath,
metadata=self.get_document_metadata(text, metadata),
document_type=DocumentType.PDF,
),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering LiteParse also parses other document types such as DOCX, XLSX, PPTX, and images, loading a non-PDF file will result in document_type="pdf" in the returned DocumentInfo. Since the field is optional, it would be more accurate to infer the type from the file extension and fall back to None for unsupported types.

167 changes: 167 additions & 0 deletions tests/unit/experimental/components/test_liteparse_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Copyright (c) "Neo4j"
# Neo4j Sweden AB [https://neo4j.com]
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests for LiteParseLoader — liteparse is mocked throughout so no install needed."""

import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Generator
from unittest.mock import MagicMock, patch

import pytest
from fsspec.implementations.local import LocalFileSystem

from neo4j_graphrag.exceptions import PdfLoaderError
from neo4j_graphrag.experimental.components.liteparse_loader import LiteParseLoader
from neo4j_graphrag.experimental.components.types import DocumentType

SAMPLE_PDF = str(Path(__file__).parent / "sample_data/lorem_ipsum.pdf")
SAMPLE_TEXT = "Lorem ipsum dolor sit amet."


def _make_liteparse_stub(parse_text: str = SAMPLE_TEXT) -> ModuleType:
result = SimpleNamespace(text=parse_text)
LiteParse = MagicMock(return_value=MagicMock(parse=MagicMock(return_value=result)))
stub = ModuleType("liteparse")
stub.LiteParse = LiteParse # type: ignore[attr-defined]
return stub


@pytest.fixture(autouse=True)
def inject_liteparse_stub() -> Generator[ModuleType, None, None]:
stub = _make_liteparse_stub()
with patch.dict(sys.modules, {"liteparse": stub}), patch(
"neo4j_graphrag.experimental.components.liteparse_loader._LiteParse",
stub.LiteParse,
), patch(
"neo4j_graphrag.experimental.components.liteparse_loader._LITEPARSE_AVAILABLE",
True,
):
yield stub


def test_load_file_local_fs_returns_text(inject_liteparse_stub: ModuleType) -> None:
loader = LiteParseLoader()
text = loader.load_file(SAMPLE_PDF, fs=LocalFileSystem())
assert text == SAMPLE_TEXT
inject_liteparse_stub.LiteParse.return_value.parse.assert_called_once_with(
SAMPLE_PDF
)


def test_load_file_non_local_fs_uses_bytes(inject_liteparse_stub: ModuleType) -> None:
fake_fs = MagicMock()
fake_bytes = b"%PDF fake"
fake_fs.open.return_value.__enter__ = MagicMock(
return_value=MagicMock(read=MagicMock(return_value=fake_bytes))
)
fake_fs.open.return_value.__exit__ = MagicMock(return_value=False)

loader = LiteParseLoader()
with patch(
"neo4j_graphrag.experimental.components.liteparse_loader.is_default_fs",
return_value=False,
):
text = loader.load_file(SAMPLE_PDF, fs=fake_fs)

assert text == SAMPLE_TEXT
inject_liteparse_stub.LiteParse.return_value.parse.assert_called_once_with(
fake_bytes
)


def test_load_file_wraps_parse_error_as_pdf_loader_error(
inject_liteparse_stub: ModuleType,
) -> None:
inject_liteparse_stub.LiteParse.return_value.parse.side_effect = RuntimeError(
"corrupt PDF"
)
loader = LiteParseLoader()
with pytest.raises(PdfLoaderError):
loader.load_file(SAMPLE_PDF, fs=LocalFileSystem())


def test_missing_liteparse_raises_import_error() -> None:
with patch(
"neo4j_graphrag.experimental.components.liteparse_loader._LITEPARSE_AVAILABLE",
False,
):
loader = LiteParseLoader()
with pytest.raises(ImportError, match="pip install"):
loader.load_file(SAMPLE_PDF, fs=LocalFileSystem())


@pytest.mark.asyncio
async def test_run_returns_loaded_document() -> None:
loader = LiteParseLoader()
doc = await loader.run(filepath=SAMPLE_PDF)
assert doc.text == SAMPLE_TEXT
assert doc.document_info.document_type == DocumentType.PDF
assert doc.document_info.path == SAMPLE_PDF


@pytest.mark.asyncio
async def test_run_with_path_object() -> None:
loader = LiteParseLoader()
doc = await loader.run(filepath=Path(SAMPLE_PDF))
assert doc.document_info.path == SAMPLE_PDF


@pytest.mark.asyncio
async def test_run_passes_metadata() -> None:
loader = LiteParseLoader()
meta = {"source": "test", "lang": "en"}
doc = await loader.run(filepath=SAMPLE_PDF, metadata=meta)
assert doc.document_info.metadata == meta


@pytest.mark.asyncio
async def test_run_fs_string_resolves() -> None:
loader = LiteParseLoader()
doc = await loader.run(filepath=SAMPLE_PDF, fs="file")
assert doc.text == SAMPLE_TEXT


def test_constructor_kwargs_forwarded_to_liteparse(
inject_liteparse_stub: ModuleType,
) -> None:
loader = LiteParseLoader(
ocr_enabled=True,
ocr_language="fra",
dpi=300,
target_pages="1-3",
password="s3cr3t",
)
loader.load_file(SAMPLE_PDF, fs=LocalFileSystem())
inject_liteparse_stub.LiteParse.assert_called_once_with(
ocr_enabled=True,
output_format="text",
ocr_language="fra",
dpi=300,
target_pages="1-3",
password="s3cr3t",
)


def test_output_format_markdown_forwarded_to_liteparse(
inject_liteparse_stub: ModuleType,
) -> None:
loader = LiteParseLoader(output_format="markdown")
loader.load_file(SAMPLE_PDF, fs=LocalFileSystem())
inject_liteparse_stub.LiteParse.assert_called_once_with(
ocr_enabled=False,
output_format="markdown",
)
Loading
Loading