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
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Local-first Retrieval-Augmented Generation (RAG) toolkit built entirely on top o
- **Deterministic and offline** – default embedding model is local; no network calls unless explicitly configured.
- **Hybrid retrieval** – combines BM25 via FTS5 with cosine similarity over stored vectors.
- **Python and CLI** – flexible API plus a friendly Typer-based CLI for scripting.
- **Extensible** – pluggable parsers, chunkers, embedding backends, and adapters for LangChain / LlamaIndex.
- **Extensible** – pluggable parsers, chunkers, embedding backends, rerankers, and adapters for LangChain / LlamaIndex.
- **Multi-user ready** – optional FastAPI server turns the SQLite database into a shared retrieval service.

## Installation

Expand Down Expand Up @@ -45,6 +46,10 @@ raglite query "example text" --db knowledge.db -k 8 --hybrid 0.6
```bash
raglite query "How do I deploy?" --db knowledge.db -k 5 --hybrid 0.7
```
- Apply a cross-encoder reranker at query time:
```bash
raglite query "release checklist" --db knowledge.db --reranker cross-encoder --reranker-option model_name=cross-encoder/ms-marco-MiniLM-L-6-v2
```
- Filter by tag or doc id:
```bash
raglite query "release checklist" --db knowledge.db --filter tag=internal --filter doc_id=release-notes
Expand All @@ -61,6 +66,32 @@ raglite query "example text" --db knowledge.db -k 8 --hybrid 0.6
```bash
raglite vacuum --db knowledge.db
```
- Serve a REST API for multi-user access (requires `pip install raglite-sqlite[server]`):
```bash
raglite serve --db knowledge.db --host 0.0.0.0 --port 8080
```

## REST API

RagLite can expose a FastAPI-powered REST server for shared deployments. Install the optional extras first:

```bash
pip install raglite-sqlite[server]
```

Then launch the server with `raglite serve`. Key endpoints:

- `GET /health` – basic health check.
- `GET /stats` – retrieve database statistics.
- `POST /index` – index new files on disk.
- `POST /query` – perform hybrid search with optional reranking.
- `POST /delete` – remove a document by `doc_id`.

All endpoints operate directly on the shared SQLite database, making it easy to expose retrieval to multiple users without extra infrastructure.

## Document formats & OCR

Beyond plain text and Markdown, RagLite understands PDF, HTML, DOCX, CSV, JSON, PPTX, and image files. Image ingestion uses OCR via `pytesseract`/`Pillow`—install them with `pip install raglite-sqlite[ocr]`. Custom parser options can be passed via `RagLite.index(..., parser_opts={...})` or the REST API.

## Python API

Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ dependencies = [

[project.optional-dependencies]
openai = ["openai>=1.0.0"]
ocr = [
"pytesseract>=0.3.10",
"Pillow>=10.0.0",
]
server = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0",
]
dev = [
"pytest>=8.1.0",
"pytest-cov>=4.1.0",
Expand Down
7 changes: 6 additions & 1 deletion raglite_sqlite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@

from .api import RagLite

__all__ = ["RagLite"]
try: # pragma: no cover - optional dependency
from .server import create_app
except Exception: # pragma: no cover - FastAPI not installed
create_app = None # type: ignore[assignment]

__all__ = ["RagLite", "create_app"]
26 changes: 25 additions & 1 deletion raglite_sqlite/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
from .parsers.csv import CSVParser
from .parsers.docx import DocxParser
from .parsers.html import HTMLParser
from .parsers.image import ImageParser
from .parsers.json import JSONParser
from .parsers.md import MarkdownParser
from .parsers.pdf import PDFParser
from .parsers.pptx import PptxParser
from .parsers.txt import TextParser
from .search import assemble_results, bm25_search, hybrid_fuse, vector_search
from .typing import SearchResult
from .rerank import Reranker, get_reranker
from .utils import detect_mime, iter_files, loads_json, now_ts, normalize_text, sha256_file, sha256_text

PARSER_REGISTRY = {
Expand All @@ -24,6 +28,11 @@
"application/pdf": PDFParser(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": DocxParser(),
"text/csv": CSVParser(),
"application/json": JSONParser(),
"application/vnd.openxmlformats-officedocument.presentationml.presentation": PptxParser(),
"image/png": ImageParser(),
"image/jpeg": ImageParser(),
"image/tiff": ImageParser(),
}


Expand Down Expand Up @@ -160,6 +169,8 @@ def search(
embedding_backend: EmbeddingBackend | None = None,
max_per_doc: int = 3,
with_snippets: bool = True,
reranker: Reranker | str | None = None,
reranker_options: dict[str, object] | None = None,
) -> List[SearchResult]:
norm_query = normalize_text(query)
lexical = bm25_search(self.db, norm_query, k, filters)
Expand All @@ -168,7 +179,20 @@ def search(
if backend is not None:
semantic = vector_search(self.db, backend, norm_query, model_name, k)
fused = hybrid_fuse(lexical, semantic, hybrid_weight, k)
return assemble_results(self.db, fused, k, with_snippets=with_snippets, max_per_doc=max_per_doc)
results = assemble_results(
self.db, fused, k, with_snippets=with_snippets, max_per_doc=max_per_doc
)
reranker_instance: Reranker | None
if isinstance(reranker, str):
try:
reranker_instance = get_reranker(reranker, **(reranker_options or {}))
except KeyError as exc:
raise ValueError(f"Unknown reranker '{reranker}'") from exc
else:
reranker_instance = reranker
if reranker_instance is not None:
results = list(reranker_instance.rerank(query, results))
return results

def delete(self, doc_id: str) -> None:
self.db.delete_document(doc_id)
Expand Down
51 changes: 50 additions & 1 deletion raglite_sqlite/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ def query(
hybrid: float = typer.Option(0.6, min=0.0, max=1.0, help="Hybrid weight"),
max_per_doc: int = typer.Option(3, help="Max results per document"),
filters: Optional[list[str]] = typer.Option(None, "--filter", help="Filter key=value"),
reranker: Optional[str] = typer.Option(None, help="Name of reranker to apply"),
reranker_option: Optional[list[str]] = typer.Option(
None, "--reranker-option", help="Reranker option key=value"
),
) -> None:
rag = get_rag(db)
filter_dict: dict[str, str] | None = None
Expand All @@ -78,7 +82,26 @@ def query(
raise typer.BadParameter("Filters must be in key=value format")
key, value = item.split("=", 1)
filter_dict[key] = value
results = rag.search(text, k=k, hybrid_weight=hybrid, max_per_doc=max_per_doc, filters=filter_dict)
reranker_opts: dict[str, object] | None = None
if reranker_option:
reranker_opts = {}
for item in reranker_option:
if "=" not in item:
raise typer.BadParameter("Reranker options must be in key=value format")
key, value = item.split("=", 1)
reranker_opts[key] = value
try:
results = rag.search(
text,
k=k,
hybrid_weight=hybrid,
max_per_doc=max_per_doc,
filters=filter_dict,
reranker=reranker,
reranker_options=reranker_opts,
)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from exc
table = Table(show_header=True, header_style="bold magenta")
table.add_column("Score", justify="right")
table.add_column("Doc ID")
Expand Down Expand Up @@ -119,3 +142,29 @@ def vacuum(db: Path = typer.Option(..., help="Database path")) -> None:
rag = get_rag(db)
rag.vacuum()
console.print("VACUUM completed")


@app.command()
def serve(
db: Path = typer.Option(..., help="Database path"),
host: str = typer.Option("127.0.0.1", help="Host to bind"),
port: int = typer.Option(8000, help="Port to bind"),
reload: bool = typer.Option(False, help="Enable auto-reload"),
) -> None:
"""Run the optional REST server."""

try:
from .server import create_app
except ImportError as exc: # pragma: no cover - optional dependency missing
raise typer.BadParameter(
"The REST server requires FastAPI. Install raglite-sqlite[server]."
) from exc
try:
import uvicorn
except ImportError as exc: # pragma: no cover - optional dependency missing
raise typer.BadParameter(
"Running the REST server requires uvicorn. Install raglite-sqlite[server]."
) from exc

app_instance = create_app(str(db))
uvicorn.run(app_instance, host=host, port=port, reload=reload)
6 changes: 6 additions & 0 deletions raglite_sqlite/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
from .pdf import PDFParser
from .docx import DocxParser
from .csv import CSVParser
from .json import JSONParser
from .pptx import PptxParser
from .image import ImageParser

__all__ = [
"TextParser",
Expand All @@ -14,4 +17,7 @@
"PDFParser",
"DocxParser",
"CSVParser",
"JSONParser",
"PptxParser",
"ImageParser",
]
36 changes: 36 additions & 0 deletions raglite_sqlite/parsers/image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from pathlib import Path
from typing import Iterable

from .base import BaseParser
from ..typing import ParsedBlock
from ..utils import normalize_text


class ImageParser(BaseParser):
"""Parse image files using pytesseract-based OCR."""

def __init__(self, default_lang: str = "eng") -> None:
self.default_lang = default_lang

def parse(self, path: str, **options: object) -> Iterable[ParsedBlock]:
lang = str(options.get("lang", self.default_lang))
config = options.get("tesseract_config")
try:
from PIL import Image
except ImportError as exc: # pragma: no cover - dependency missing
raise RuntimeError("Pillow is required for OCR parsing") from exc
try:
import pytesseract
except ImportError as exc: # pragma: no cover - dependency missing
raise RuntimeError("pytesseract is required for OCR parsing") from exc
image_path = Path(path)
with Image.open(image_path) as image:
try:
text = pytesseract.image_to_string(image, lang=lang, config=config)
except pytesseract.pytesseract.TesseractNotFoundError as exc: # pragma: no cover - environment specific
raise RuntimeError(
"Tesseract OCR binary not found. Install it or adjust TESSDATA_PREFIX."
) from exc
yield ParsedBlock(text=normalize_text(text), section=None)
20 changes: 20 additions & 0 deletions raglite_sqlite/parsers/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import json
from pathlib import Path
from typing import Iterable

from .base import BaseParser
from ..typing import ParsedBlock
from ..utils import normalize_text


class JSONParser(BaseParser):
"""Parse JSON documents by rendering them as a stable, human-readable string."""

def parse(self, path: str, **options: object) -> Iterable[ParsedBlock]:
indent = int(options.get("indent", 2) or 0)
sort_keys = bool(options.get("sort_keys", True))
data = json.loads(Path(path).read_text(encoding="utf-8"))
text = json.dumps(data, indent=indent or None, sort_keys=sort_keys, ensure_ascii=False)
yield ParsedBlock(text=normalize_text(text), section=None)
45 changes: 45 additions & 0 deletions raglite_sqlite/parsers/pptx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from __future__ import annotations

from pathlib import Path
from typing import Iterable
from xml.etree import ElementTree
from zipfile import ZipFile

from .base import BaseParser
from ..typing import ParsedBlock
from ..utils import normalize_text


class PptxParser(BaseParser):
"""Lightweight PPTX parser that extracts text from slide XML payloads."""

SLIDE_PREFIX = "ppt/slides/"
SLIDE_SUFFIX = ".xml"

def parse(self, path: str, **options: object) -> Iterable[ParsedBlock]:
pptx_path = Path(path)
texts: list[str] = []
with ZipFile(pptx_path) as archive:
slide_names = sorted(
name
for name in archive.namelist()
if name.startswith(self.SLIDE_PREFIX) and name.endswith(self.SLIDE_SUFFIX)
)
for slide_name in slide_names:
with archive.open(slide_name) as handle:
xml_bytes = handle.read()
try:
root = ElementTree.fromstring(xml_bytes)
except ElementTree.ParseError:
continue
namespaces = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
}
slide_text: list[str] = []
for node in root.findall('.//a:t', namespaces):
if node.text:
slide_text.append(node.text)
if slide_text:
texts.append(" ".join(slide_text))
combined = normalize_text("\n\n".join(texts))
yield ParsedBlock(text=combined, section=None)
52 changes: 51 additions & 1 deletion raglite_sqlite/rerank.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Iterable, Protocol
from typing import Callable, Iterable, Protocol

from .typing import SearchResult

Expand All @@ -13,3 +13,53 @@ def rerank(self, query: str, results: Iterable[SearchResult]) -> Iterable[Search
class NoopReranker:
def rerank(self, query: str, results: Iterable[SearchResult]) -> Iterable[SearchResult]:
return results


RerankerFactory = Callable[..., Reranker]

_RERANKER_REGISTRY: dict[str, RerankerFactory] = {
"none": lambda **_: NoopReranker(),
}


def register_reranker(name: str, factory: RerankerFactory) -> None:
"""Register a reranker factory."""

_RERANKER_REGISTRY[name] = factory


def get_reranker(name: str, **options: object) -> Reranker:
"""Instantiate a reranker by name."""

factory = _RERANKER_REGISTRY.get(name)
if factory is None:
raise KeyError(name)
return factory(**options)


class CrossEncoderReranker:
"""Rerank using a sentence-transformers CrossEncoder."""

def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2", batch_size: int = 16) -> None:
try:
from sentence_transformers import CrossEncoder
except ImportError as exc: # pragma: no cover - dependency missing
raise RuntimeError(
"sentence-transformers is required for the cross-encoder reranker"
) from exc

self.model = CrossEncoder(model_name)
self.batch_size = batch_size

def rerank(self, query: str, results: Iterable[SearchResult]) -> Iterable[SearchResult]:
result_list = list(results)
if not result_list:
return result_list
pairs = [(query, item["text"]) for item in result_list]
scores = self.model.predict(pairs, batch_size=self.batch_size)
for item, score in zip(result_list, scores):
item["rerank_score"] = float(score)
return sorted(result_list, key=lambda item: item.get("rerank_score", 0.0), reverse=True)


register_reranker("cross-encoder", lambda **opts: CrossEncoderReranker(**opts))
Loading
Loading