-
Notifications
You must be signed in to change notification settings - Fork 226
feat: add LiteParseLoader (LiteParse optional extra) #534
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cdc0cf1
945cd26
32503c7
fb54b43
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"] |
| 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.""" | ||
| 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, | ||
| ), | ||
| ) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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", | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PdfLoader.load_fileandMarkdownLoader.load_fileboth use file as the parameter name, but LiteParseLoader.load_file usesfilepath. This can be renamed tofileto stay consistent.