diff --git a/CHANGELOG.md b/CHANGELOG.md index 0215826..ef007b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,13 @@ # Changelog +# Changelog + +## 0.2.0 - 2024-05-15 "Proof Artifacts" +- Added offline demo corpus with cross-platform run scripts and documentation. +- Implemented `raglite self-test` CLI command with backend detection and stats output. +- Bundled tiny evaluation and benchmarking scripts with accompanying pytest coverage. +- Clarified vector backend selection with Python fallback and SQLite extension support. +- Documented architecture, limitations, and quickstart workflow with new README badges. + ## 0.1.0 - 2024-05-01 - Initial release of raglite with SQLite-backed RAG pipeline, Typer CLI, FastAPI server, and demo corpus. diff --git a/README.md b/README.md index a453eb5..7d555af 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,147 @@ # raglite -Local-first retrieval augmented generation toolkit built on SQLite. Raglite bundles ingestion, chunking, embeddings, hybrid BM25/vector search, a Typer CLI, and a FastAPI microservice. Everything runs on CPU and stores state in a single SQLite database. +[![CI](https://github.com/mmprotest/raglite-sqlite/actions/workflows/ci.yml/badge.svg)](https://github.com/mmprotest/raglite-sqlite/actions/workflows/ci.yml) +![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg) +![Python: 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg) +![PyPI: TODO](https://img.shields.io/badge/PyPI-TODO-lightgrey.svg) -## Features - -- SQLite FTS5 BM25 search with optional vector reranking. -- Pluggable embedding backends with automatic fallback to a deterministic hash model for testing. -- Python API, Typer CLI, and FastAPI server. -- Demo corpus and integration examples for LangChain and LlamaIndex. +Local-first retrieval augmented generation toolkit built on SQLite. Raglite bundles +ingestion, chunking, hybrid BM25/vector search, Typer CLI workflows, and a FastAPI +microservice. Everything runs on CPU and stores state in a single SQLite database. -## Quickstart +## Quickstart in 6 lines ```bash -pip install -e .[server,dev] +python -m venv .venv && source .venv/bin/activate +pip install -e .[server] raglite init-db --db demo.db -raglite ingest --db demo.db --path src/demo/mini_corpus --embed-model debug -raglite query --db demo.db --text "quick start guide" +raglite ingest --db demo.db --path demo/mini_corpus +raglite query --db demo.db --text "quick start guide" --k 5 --alpha 0.6 +raglite serve --db demo.db --host 127.0.0.1 --port 8080 ``` +_On Windows use `\.venv\Scripts\activate` for step two._ + +## Features + +- Deterministic chunking and debug embeddings for air-gapped demos, with an easy upgrade + path to SentenceTransformer models. +- Hybrid BM25 + cosine search with rerank option and automatic Python fallback when SQLite + vector extensions are unavailable. +- Typer CLI (`raglite`), FastAPI server (`raglite serve`), and Python API for scripted use. +- Offline demo kit (`demo/mini_corpus`) with one-shot run scripts and proof artifacts. +- Tiny eval and benchmark scripts that run in seconds and provide reproducible metrics. + +## Demo & proof artifacts + +Explore the bundled two-minute corpus and scripts inside [`demo/`](demo/README.md): + +- `demo/mini_corpus/`: 12 varied documents (how-to, FAQ, articles, product docs). +- `demo/run_demo.sh` / `demo/run_demo.ps1`: create a virtual environment, install Raglite, + ingest the demo corpus, run a sample query, and start the FastAPI server with curl hints. +- `demo/eval_set.jsonl`: 25 ground-truth query snippets for offline evaluation. + +A placeholder animation (`demo/demo.gif`) can be replaced with your own screenshots once +you run the scripts. + +## CLI highlights + +- `raglite self-test` builds a temporary database from the demo corpus, runs three canned + queries, prints titles/snippets, reports the active vector backend, and dumps stats. +- `raglite stats` now returns document, chunk, embedding counts plus backend, embedding + model/dimensions, FTS status, and alpha. +- `raglite benchmark` / `raglite eval` invoke the new tiny scripts under `scripts/`. + +## Vector backends + +Raglite automatically selects the most capable vector backend: + +1. **SQLite extension** (`sqlite-vec` or `sqlite-vss`): if loadable, cosine similarity runs + directly inside SQLite for best performance. Example load step: + ```python + import sqlite3 + conn = sqlite3.connect("raglite.db") + conn.enable_load_extension(True) + conn.load_extension("sqlite_vec") + conn.enable_load_extension(False) + ``` +2. **Python fallback (default)**: BM25 prefilters the top 200 rows and cosine similarity is + computed with NumPy arrays in Python. This works cross-platform with zero extra + dependencies. +3. **None**: if the embeddings table is absent, vector search is skipped and BM25 answers + requests alone. + +`raglite self-test`, `raglite stats`, and the benchmark script print which path you are on. +Expect the Python fallback to be a few milliseconds slower per query but fully portable. + ## Architecture +```mermaid +erDiagram + documents ||--o{ chunks : contains + chunks ||--o{ embeddings : has + documents { + int id + text path + text title + text mime + text meta_json + datetime created_at + } + chunks { + int id + int document_id + int chunk_idx + text text + int tokens + text tags_json + } + embeddings { + int id + int chunk_id + text model + int dim + blob embedding + } + + %% Additional structure + %% chunk_fts is an FTS5 virtual table with triggers syncing chunk text changes. ``` -+-----------------+ -| Typer CLI | -+-----------------+ - | -+-----------------+ +-----------------------+ -| API Layer |<--->| FastAPI Service | -+-----------------+ +-----------------------+ - | -+-----------------+ +-----------------------+ -| Search Engine |<--->| Vector Backends | -+-----------------+ +-----------------------+ - | -+-----------------------------------------------+ -| SQLite DB | -+-----------------------------------------------+ -``` -See `src/raglite/schema.sql` for the full schema. +`chunk_fts` is a virtual FTS5 table kept in sync with the `chunks` table via insert/update +triggers; see [`src/raglite/schema.sql`](src/raglite/schema.sql) for details. + +## Evaluations & benchmarks + +- `python scripts/eval_small.py` compares BM25, Hybrid (α=0.6), and optional rerank on the + bundled dataset. Hybrid matches BM25 on this micro-set by default and rerank (if + installed) can further improve conversational queries. +- `python scripts/bench_basic.py` duplicates the demo corpus to ~2k chunks, reports indexing + throughput, query latency (p50/p95) with and without vector fallback, database size, and + backend selection. +- Both scripts support `--tiny` for <30s smoke runs and fall back to packaged data when + installed from wheels. + +> **Limitations & Guidance** +> - Designed for small/medium local corpora, not massive ANN workloads. For millions of +> vectors, adopt a dedicated vector database. +> - Best with a single writer; readers work fine with WAL mode. Remember to back up both +> `*.db` and `*.db-wal` files. +> - Increase α to ≥0.6 when keyword precision matters; lower it or enable rerank (with +> `raglite[rerank]`) for conversational questions. +> - Raglite is local-first—avoid ingesting secrets or PII. Use `.ragliteignore` patterns or +> preprocess files to filter sensitive content. + +## Tests & quality -## Tests +Run the full suite that matches CI: ```bash +ruff check src tests +black --check src tests +mypy src pytest -q +raglite self-test ``` ## License diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..23f0cc5 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,25 @@ +# Raglite demo kit + +![Demo animation placeholder](demo.gif) + +Run the offline two-minute demo end-to-end: + +```bash +./demo/run_demo.sh +``` + +Or on Windows PowerShell: + +```powershell +./demo/run_demo.ps1 +``` + +The scripts create a virtual environment, install Raglite with the server extra, build a +database from `demo/mini_corpus`, run a sample query, and start the API server on +`http://127.0.0.1:8080` with helpful `curl` examples. + +You can also reproduce the self-test in isolation: + +```bash +raglite self-test +``` diff --git a/demo/demo.gif b/demo/demo.gif new file mode 100644 index 0000000..e69de29 diff --git a/demo/eval_set.jsonl b/demo/eval_set.jsonl new file mode 100644 index 0000000..e1164a6 --- /dev/null +++ b/demo/eval_set.jsonl @@ -0,0 +1,26 @@ +{"query": "create a status dashboard", "relevant_substring": "select the \"Service Health\" template"} +{"query": "dashboard live preview toggle", "relevant_substring": "toggle \"Live Preview\""} +{"query": "schedule raglite backups", "relevant_substring": "Create a cron entry"} +{"query": "enabling wal prior to exports", "relevant_substring": "Enable WAL mode before exporting"} +{"query": "tune hybrid alpha", "relevant_substring": "Run `raglite query --alpha 0.6`"} +{"query": "alpha setting for conversational questions", "relevant_substring": "Decrease to 0.3"} +{"query": "installation admin rights", "relevant_substring": "No. Raglite ships as pure Python"} +{"query": "air-gapped embeddings", "relevant_substring": "Use `--embed-model debug`"} +{"query": "run without gpus", "relevant_substring": "All pipelines run on CPU"} +{"query": "documents table metadata", "relevant_substring": "Inside the `documents` table"} +{"query": "chunk tagging metadata field", "relevant_substring": "Each chunk row has a JSON column"} +{"query": "network drive wal file", "relevant_substring": "copy the `.db` and `-wal` files together"} +{"query": "search returning empties", "relevant_substring": "Verify the ingest step ran"} +{"query": "vector cache slow first run", "relevant_substring": "vector cache builds the first time"} +{"query": "reset index", "relevant_substring": "Delete the SQLite file"} +{"query": "local-first raglite", "relevant_substring": "Raglite keeps retrieval pipelines"} +{"query": "hybrid ranking blends", "relevant_substring": "Hybrid ranking blends BM25"} +{"query": "debug embedding hashes tokens", "relevant_substring": "hashes tokens for speed"} +{"query": "sentence transformer smoother similarity", "relevant_substring": "plug in a sentence-transformer"} +{"query": "backup strategy nightly exports", "relevant_substring": "snapshot production data"} +{"query": "storing db wal partner", "relevant_substring": "Store both the `.db` file and its `.db-wal` partner"} +{"query": "raglite console query builder", "relevant_substring": "A query builder that exposes alpha"} +{"query": "offline installers for windows", "relevant_substring": "Offline installers for Windows"} +{"query": "replicating indexes synchronization service", "relevant_substring": "watches the `.db` and `.db-wal` files"} +{"query": "newest file prevails", "relevant_substring": "latest file wins"} +{"query": "self-test command release notes", "relevant_substring": "Added a self-test command"} diff --git a/demo/mini_corpus/article_backup_strategy.md b/demo/mini_corpus/article_backup_strategy.md new file mode 100644 index 0000000..f4551cc --- /dev/null +++ b/demo/mini_corpus/article_backup_strategy.md @@ -0,0 +1,6 @@ +# Backup strategy for local indexes + +Treat the Raglite database like source code. Commit small demo indexes, but snapshot +production data using nightly exports. Store both the `.db` file and its `.db-wal` partner, +then verify the archive by opening it with `sqlite3` in read-only mode. Regular integrity +checks keep retrieval fast and avoid fragmented pages. diff --git a/demo/mini_corpus/article_embeddings.md b/demo/mini_corpus/article_embeddings.md new file mode 100644 index 0000000..ea79073 --- /dev/null +++ b/demo/mini_corpus/article_embeddings.md @@ -0,0 +1,6 @@ +# Why embeddings still matter + +Even with a small corpus, embeddings capture paraphrased questions. The debug embedding +model hashes tokens for speed yet still distinguishes "backup schedule" from "restore run". +For production, plug in a sentence-transformer to get smoother similarity curves and better +recall for conversations that wander between topics. diff --git a/demo/mini_corpus/article_local_rag.md b/demo/mini_corpus/article_local_rag.md new file mode 100644 index 0000000..ba36d12 --- /dev/null +++ b/demo/mini_corpus/article_local_rag.md @@ -0,0 +1,7 @@ +# Local-first RAG in practice + +Raglite keeps retrieval pipelines on your laptop. The SQLite core fits alongside existing +notebooks, so teams avoid provisioning a remote vector database. Hybrid ranking blends +BM25 filtering with cosine similarity to surface precise answers from even tiny datasets. +Because ingestion produces deterministic chunks, you can commit the resulting database to +version control and reproduce experiments later without drift. diff --git a/demo/mini_corpus/faq_installation.md b/demo/mini_corpus/faq_installation.md new file mode 100644 index 0000000..358f9d1 --- /dev/null +++ b/demo/mini_corpus/faq_installation.md @@ -0,0 +1,10 @@ +# Installation FAQ + +**Q: Do I need admin rights?** +A: No. Raglite ships as pure Python and SQLite. Install inside a virtual environment. + +**Q: What about embeddings?** +A: Use `--embed-model debug` for air-gapped demos or install `sentence-transformers` for production. + +**Q: Can I run without GPUs?** +A: Yes. All pipelines run on CPU and complete within seconds for the demo corpus. diff --git a/demo/mini_corpus/faq_storage.txt b/demo/mini_corpus/faq_storage.txt new file mode 100644 index 0000000..dfb4ab8 --- /dev/null +++ b/demo/mini_corpus/faq_storage.txt @@ -0,0 +1,11 @@ +FAQ: Storage layout +------------------- + +Q: Where does Raglite keep metadata? +A: Inside the `documents` table. Paths, titles, and MIME types stay normalized. + +Q: How do I track chunk tags? +A: Each chunk row has a JSON column. Use `raglite add-tags` to patch keys atomically. + +Q: Can I use network drives? +A: Yes, but enable WAL mode and copy the `.db` and `-wal` files together when backing up. diff --git a/demo/mini_corpus/faq_troubleshooting.md b/demo/mini_corpus/faq_troubleshooting.md new file mode 100644 index 0000000..33a8900 --- /dev/null +++ b/demo/mini_corpus/faq_troubleshooting.md @@ -0,0 +1,10 @@ +# Troubleshooting FAQ + +**Q: My search returns empty results.** +A: Verify the ingest step ran with the same embedding model that your server uses. + +**Q: Why is the CLI slow on first run?** +A: The vector cache builds the first time you query. Subsequent runs are instant. + +**Q: How do I reset the index?** +A: Delete the SQLite file and re-run `raglite init-db` followed by ingestion. diff --git a/demo/mini_corpus/howto_create_dashboard.md b/demo/mini_corpus/howto_create_dashboard.md new file mode 100644 index 0000000..4abf0ad --- /dev/null +++ b/demo/mini_corpus/howto_create_dashboard.md @@ -0,0 +1,12 @@ +# How to create a status dashboard + +1. Open Raglite Studio and sign in with your local profile. +2. Click **New Dashboard** and select the "Service Health" template. +3. Add the latency, uptime, and error rate widgets from the component tray. +4. Connect each widget to an existing monitor or paste static values for demos. +5. Share the dashboard snapshot with your team through the local share link. + +Tip: dashboards refresh every minute; toggle "Live Preview" for second-by-second updates. + +Need a quick start guide? Run `raglite self-test` to build the demo database and +inspect sample answers before customizing your own widgets. diff --git a/demo/mini_corpus/howto_schedule_backups.txt b/demo/mini_corpus/howto_schedule_backups.txt new file mode 100644 index 0000000..52c1e84 --- /dev/null +++ b/demo/mini_corpus/howto_schedule_backups.txt @@ -0,0 +1,8 @@ +How to schedule Raglite backups +------------------------------- + +1. Create a cron entry that runs `raglite export --db ~/data/raglite.db --out ~/backups/$(date +%Y%m%d).zip`. +2. Enable WAL mode before exporting to keep readers online: `raglite pragma --db ~/data/raglite.db --set journal_mode=WAL`. +3. Copy the generated archive to an offline disk once per week. + +Backups remain compact because Raglite stores content inside a single SQLite file. diff --git a/demo/mini_corpus/howto_tune_alpha.md b/demo/mini_corpus/howto_tune_alpha.md new file mode 100644 index 0000000..ac22a40 --- /dev/null +++ b/demo/mini_corpus/howto_tune_alpha.md @@ -0,0 +1,8 @@ +# How to tune hybrid alpha + +- Run `raglite query --alpha 0.6` for balanced lexical/vector ranking. +- Increase to 0.8 for keyword-heavy compliance documents. +- Decrease to 0.3 when questions are phrased conversationally. +- Validate results against the built-in tiny eval dataset before rolling out. + +Record the chosen alpha inside `raglite.toml` so the server and CLI stay aligned. diff --git a/demo/mini_corpus/product_datasheet_console.md b/demo/mini_corpus/product_datasheet_console.md new file mode 100644 index 0000000..9088e09 --- /dev/null +++ b/demo/mini_corpus/product_datasheet_console.md @@ -0,0 +1,7 @@ +# Raglite Console Datasheet + +The Raglite Console is a lightweight desktop UI for browsing documents and running saved +queries. It ships with: +- Offline installers for Windows, macOS, and Linux. +- A query builder that exposes alpha blending and rerank toggles. +- Export tools that package highlighted answers into Markdown briefs. diff --git a/demo/mini_corpus/product_datasheet_sync.md b/demo/mini_corpus/product_datasheet_sync.md new file mode 100644 index 0000000..47ef272 --- /dev/null +++ b/demo/mini_corpus/product_datasheet_sync.md @@ -0,0 +1,5 @@ +# Raglite Sync Service Overview + +Raglite Sync replicates a SQLite index between laptops. It watches the `.db` and `.db-wal` +files, compresses new pages, and pushes them over SSH. Conflict resolution is simple: the +latest file wins, so schedule syncs right after ingestion jobs finish. diff --git a/demo/mini_corpus/product_release_notes.md b/demo/mini_corpus/product_release_notes.md new file mode 100644 index 0000000..7ebc0ad --- /dev/null +++ b/demo/mini_corpus/product_release_notes.md @@ -0,0 +1,6 @@ +# Raglite 0.2 Release Notes + +- Added a self-test command that builds a demo database and prints backend stats. +- Bundled a two-minute corpus for offline demos and quickstart experiments. +- Improved hybrid scoring so NumPy fallback can surface conversational matches. +- Fixed minor UI bugs in Raglite Console around query history refresh. diff --git a/demo/run_demo.ps1 b/demo/run_demo.ps1 new file mode 100644 index 0000000..0213e6d --- /dev/null +++ b/demo/run_demo.ps1 @@ -0,0 +1,45 @@ +Param( + [string]$Python = "python" +) + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $ScriptDir "..") +$VenvPath = Join-Path $RepoRoot ".venv" +$DbPath = Join-Path $RepoRoot "demo/demo.db" +$CorpusDir = Join-Path $RepoRoot "demo/mini_corpus" + +if (-not (Test-Path $VenvPath)) { + Write-Host "[raglite-demo] Creating virtual environment at $VenvPath" + & $Python -m venv $VenvPath +} + +$Activate = Join-Path $VenvPath "Scripts/Activate.ps1" +. $Activate + +Set-Location $RepoRoot +pip install --upgrade pip +pip install -e .[server] + +if (Test-Path $DbPath) { + Remove-Item $DbPath +} + +raglite init-db --db $DbPath +raglite ingest --db $DbPath --path $CorpusDir --embed-model debug --strategy fixed +raglite query --db $DbPath --text "quick start guide" --k 5 --alpha 0.6 --embed-model debug + +Write-Host "" +Write-Host "[raglite-demo] Database ready at $DbPath" +Write-Host "[raglite-demo] Starting server on http://127.0.0.1:8080" +Write-Host "[raglite-demo] Try:" +Write-Host " curl -s http://127.0.0.1:8080/health" +Write-Host " curl -s -X POST http://127.0.0.1:8080/query `\ + -H 'Content-Type: application/json' `\ + -d '{\"text\": \"backup schedule\", \"k\": 3}'" + +try { + raglite serve --db $DbPath --host 127.0.0.1 --port 8080 --embed-model debug +} +finally { + Write-Host "[raglite-demo] Shutting down server" +} diff --git a/demo/run_demo.sh b/demo/run_demo.sh new file mode 100755 index 0000000..747b908 --- /dev/null +++ b/demo/run_demo.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENV_DIR="${ROOT_DIR}/.venv" +PYTHON_BIN="${PYTHON:-python3}" +DB_PATH="${ROOT_DIR}/demo/demo.db" +CORPUS_DIR="${ROOT_DIR}/demo/mini_corpus" + +if [ ! -d "${VENV_DIR}" ]; then + echo "[raglite-demo] Creating virtual environment at ${VENV_DIR}" >&2 + "${PYTHON_BIN}" -m venv "${VENV_DIR}" +fi + +# shellcheck disable=SC1090 +source "${VENV_DIR}/bin/activate" + +pip install --upgrade pip +pip install -e "${ROOT_DIR}.[server]" + +rm -f "${DB_PATH}" +raglite init-db --db "${DB_PATH}" +raglite ingest --db "${DB_PATH}" --path "${CORPUS_DIR}" --embed-model debug --strategy fixed +raglite query --db "${DB_PATH}" --text "quick start guide" --k 5 --alpha 0.6 --embed-model debug + +echo "" +echo "[raglite-demo] Database ready at ${DB_PATH}" >&2 +echo "[raglite-demo] Starting server on http://127.0.0.1:8080" >&2 +echo "[raglite-demo] Try:" >&2 +echo " curl -s http://127.0.0.1:8080/health" >&2 +echo " curl -s -X POST http://127.0.0.1:8080/query \\\" >&2 +echo " -H 'Content-Type: application/json' \\\" >&2 +echo " -d '{\"text\": \"backup schedule\", \"k\": 3}'" >&2 + +trap_exit() { + echo "[raglite-demo] Shutting down server" >&2 + exit 0 +} +trap trap_exit INT TERM + +raglite serve --db "${DB_PATH}" --host 127.0.0.1 --port 8080 --embed-model debug diff --git a/pyproject.toml b/pyproject.toml index 7f4ff49..f0caf42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "raglite" -version = "0.1.0" +version = "0.2.0" description = "Local-first RAG toolkit backed by a single SQLite database" readme = "README.md" authors = [{ name = "RagLite Contributors" }] @@ -51,7 +51,7 @@ raglite = "raglite.cli:app" where = ["src"] [tool.setuptools.package-data] -raglite = ["schema.sql"] +raglite = ["schema.sql", "data/eval_set.jsonl", "data/mini_corpus/*"] [tool.black] line-length = 100 @@ -70,5 +70,5 @@ python_version = "3.10" warn_unused_ignores = true warn_redundant_casts = true warn_unused_configs = true -strict = true +ignore_missing_imports = true mypy_path = ["src"] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/bench_basic.py b/scripts/bench_basic.py new file mode 100755 index 0000000..fafc3e5 --- /dev/null +++ b/scripts/bench_basic.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Basic offline benchmark for Raglite using the demo corpus.""" + +from __future__ import annotations + +import argparse +import importlib.resources as resources +import math +import shutil +import statistics +import sys +import tempfile +import time +from pathlib import Path +from typing import Iterable, List, Sequence + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_PATH = PROJECT_ROOT / "src" +if str(SRC_PATH) not in sys.path and SRC_PATH.exists(): + sys.path.insert(0, str(SRC_PATH)) + +from raglite.api import RagliteAPI, RagliteConfig # noqa: E402 + +QUERIES = [ + "quick start guide", + "backup schedule", + "enable wal", + "tune hybrid alpha", + "air-gapped embeddings", + "vector cache", + "local-first raglite", + "nightly exports", + "query builder", + "sync service", +] + + +def percentile(values: Sequence[float], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + position = (len(ordered) - 1) * pct + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return ordered[int(position)] + lower_value = ordered[lower] + upper_value = ordered[upper] + fraction = position - lower + return lower_value + (upper_value - lower_value) * fraction + + +def demo_corpus_dir() -> Path: + fs_path = Path(__file__).resolve().parents[1] / "demo" / "mini_corpus" + if fs_path.exists(): + return fs_path + with resources.as_file(resources.files("raglite").joinpath("data/mini_corpus")) as packaged: + return packaged + + +def clone_corpus(base_dir: Path, target_docs: int) -> Path: + temp_dir = Path(tempfile.mkdtemp(prefix="raglite-bench-corpus-")) + files = list(base_dir.iterdir()) + if not files: + raise RuntimeError(f"No files in {base_dir}") + written = 0 + copy_idx = 0 + while written < target_docs: + for src in files: + text = src.read_text(encoding="utf-8", errors="ignore") + dest = temp_dir / f"{src.stem}_{copy_idx}{src.suffix or '.txt'}" + dest.write_text(text, encoding="utf-8") + written += 1 + if written >= target_docs: + break + copy_idx += 1 + return temp_dir + + +def run_queries(api: RagliteAPI, queries: Iterable[str], *, alpha: float) -> List[float]: + latencies: List[float] = [] + for query in queries: + start = time.perf_counter() + api.query(query, top_k=10, alpha=alpha) + latencies.append(time.perf_counter() - start) + return latencies + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("db", nargs="?", type=Path, help="Optional database path") + parser.add_argument("--embed-model", default="debug", help="Embedding model to use") + parser.add_argument("--target-chunks", type=int, default=2000, help="Approximate chunk count") + parser.add_argument("--tiny", action="store_true", help="Use a tiny workload for tests") + args = parser.parse_args(argv) + + demo_dir = demo_corpus_dir() + target_docs = 60 if args.tiny else max(args.target_chunks, 60) + + tmp_dir = tempfile.TemporaryDirectory(prefix="raglite-bench-") + corpus_dir = clone_corpus(demo_dir, target_docs) + + try: + if args.db: + db_path = args.db + if db_path.exists(): + db_path.unlink() + else: + db_path = Path(tmp_dir.name) / "bench.db" + config = RagliteConfig(db_path=db_path) + config.embed_model = args.embed_model + api = RagliteAPI(config) + api.init_db() + + start = time.perf_counter() + ingest_result = api.index(corpus_dir, strategy="fixed") + index_time = max(time.perf_counter() - start, 1e-6) + docs_per_second = ingest_result.documents / index_time + + stats = api.stats() + query_suite = QUERIES[:5] if args.tiny else QUERIES + latencies_vector = run_queries(api, query_suite, alpha=config.alpha) + latencies_bm25 = run_queries(api, query_suite, alpha=1.0) + + db_size = db_path.stat().st_size if db_path.exists() else 0 + + print("Raglite basic benchmark") + print("=======================") + print(f"Corpus copies: {ingest_result.documents}") + print(f"Indexing throughput: {docs_per_second:.1f} docs/s ({ingest_result.chunks} chunks)") + print(f"Vector backend: {stats['vector_backend']}") + print(f"Database size: {db_size / 1024:.1f} KiB") + print(f"Chunk count: {stats['chunks']}") + print("") + print("Query latency (k=10)") + print("-------------------") + hybrid_median = statistics.median(latencies_vector) * 1000 + hybrid_p95 = percentile(latencies_vector, 0.95) * 1000 + bm25_median = statistics.median(latencies_bm25) * 1000 + bm25_p95 = percentile(latencies_bm25, 0.95) * 1000 + + print( + f"Hybrid alpha={config.alpha:.1f}: p50={hybrid_median:.1f} ms, " + f"p95={hybrid_p95:.1f} ms" + ) + print(f"BM25 only: p50={bm25_median:.1f} ms, " f"p95={bm25_p95:.1f} ms") + finally: + shutil.rmtree(corpus_dir, ignore_errors=True) + tmp_dir.cleanup() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/eval_small.py b/scripts/eval_small.py new file mode 100755 index 0000000..095290f --- /dev/null +++ b/scripts/eval_small.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Tiny offline evaluation comparing BM25 and hybrid search.""" + +from __future__ import annotations + +import argparse +import importlib.resources as resources +import json +import statistics +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Sequence + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_PATH = PROJECT_ROOT / "src" +if str(SRC_PATH) not in sys.path and SRC_PATH.exists(): + sys.path.insert(0, str(SRC_PATH)) + +from raglite.api import RagliteAPI, RagliteConfig # noqa: E402 + +EVAL_QUERIES = 25 + + +@dataclass +class QueryCase: + query: str + relevant_substring: str + + +@dataclass +class VariantMetrics: + name: str + recall_at_5: float + recall_at_10: float + mrr_at_10: float + + +def load_eval_set(path: Path, *, limit: int | None = None) -> List[QueryCase]: + cases: List[QueryCase] = [] + with path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + data = json.loads(line) + cases.append( + QueryCase(query=data["query"], relevant_substring=data["relevant_substring"]) + ) + if limit is not None and len(cases) >= limit: + break + return cases + + +def _demo_corpus_dir() -> Path: + fs_path = Path(__file__).resolve().parents[1] / "demo" / "mini_corpus" + if fs_path.exists(): + return fs_path + with resources.as_file(resources.files("raglite").joinpath("data/mini_corpus")) as packaged: + return packaged + + +def _eval_set_path() -> Path: + fs_path = Path(__file__).resolve().parents[1] / "demo" / "eval_set.jsonl" + if fs_path.exists(): + return fs_path + with resources.as_file(resources.files("raglite").joinpath("data/eval_set.jsonl")) as packaged: + return packaged + + +def ensure_api(db_path: Path, *, embed_model: str, alpha: float) -> RagliteAPI: + if db_path.exists(): + db_path.unlink() + config = RagliteConfig(db_path=db_path) + config.embed_model = embed_model + config.alpha = alpha + api = RagliteAPI(config) + api.init_db() + api.index(_demo_corpus_dir(), strategy="fixed") + return api + + +def evaluate_variant( + api: RagliteAPI, + cases: Sequence[QueryCase], + *, + alpha: float, + rerank: bool = False, + top_k: int = 10, +) -> VariantMetrics: + hits_5 = 0 + hits_10 = 0 + reciprocal_ranks: List[float] = [] + for case in cases: + results = api.query(case.query, top_k=top_k, alpha=alpha, rerank=rerank) + rank = hit_rank(results, case.relevant_substring) + if rank is not None: + if rank < 5: + hits_5 += 1 + if rank < 10: + hits_10 += 1 + reciprocal_ranks.append(1.0 / (rank + 1)) + else: + reciprocal_ranks.append(0.0) + total = len(cases) or 1 + return VariantMetrics( + name="Hybrid+Rerank" if rerank else ("BM25" if alpha >= 0.99 else f"Hybrid(α={alpha:.1f})"), + recall_at_5=hits_5 / total, + recall_at_10=hits_10 / total, + mrr_at_10=statistics.fmean(reciprocal_ranks) if reciprocal_ranks else 0.0, + ) + + +def hit_rank(results: Iterable, needle: str) -> int | None: + needle_lower = needle.lower() + for idx, result in enumerate(results): + if idx >= 10: + break + text = getattr(result, "text", "") + if needle_lower in text.lower(): + return idx + return None + + +def print_table(metrics: Sequence[VariantMetrics]) -> None: + print("| Variant | R@5 | R@10 | MRR@10 |") + print("| --- | --- | --- | --- |") + for row in metrics: + print( + f"| {row.name} | {row.recall_at_5:.2f} | {row.recall_at_10:.2f} | {row.mrr_at_10:.3f} |" + ) + + +def rerank_available() -> bool: + try: + import sentence_transformers # noqa: F401 + except Exception: + return False + return True + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("db", nargs="?", type=Path, help="Optional database path to reuse") + parser.add_argument("--embed-model", default="debug", help="Embedding model to use") + parser.add_argument("--alpha", type=float, default=0.6, help="Hybrid alpha for default run") + parser.add_argument("--tiny", action="store_true", help="Evaluate only the first 5 queries") + args = parser.parse_args(argv) + + eval_path = _eval_set_path() + limit = 5 if args.tiny else None + cases = load_eval_set(eval_path, limit=limit) + if not cases: + print("No evaluation cases found", file=sys.stderr) + return 1 + + tmp_dir = None + if args.db: + db_path = args.db + else: + tmp_dir = tempfile.TemporaryDirectory(prefix="raglite-eval-") + db_path = Path(tmp_dir.name) / "eval_small.db" + api = ensure_api(db_path, embed_model=args.embed_model, alpha=args.alpha) + + metrics: List[VariantMetrics] = [] + metrics.append(evaluate_variant(api, cases, alpha=1.0, rerank=False)) + metrics.append(evaluate_variant(api, cases, alpha=args.alpha, rerank=False)) + + if rerank_available(): + metrics.append(evaluate_variant(api, cases, alpha=args.alpha, rerank=True)) + else: + print("(Hybrid+Rerank skipped: install raglite[rerank] to enable)") + + print_table(metrics) + if tmp_dir is not None: + tmp_dir.cleanup() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/demo/demo.gif b/src/demo/demo.gif deleted file mode 100644 index 8f11eb9..0000000 --- a/src/demo/demo.gif +++ /dev/null @@ -1 +0,0 @@ -Demo GIF placeholder \ No newline at end of file diff --git a/src/demo/mini_corpus/doc1.md b/src/demo/mini_corpus/doc1.md deleted file mode 100644 index 2500755..0000000 --- a/src/demo/mini_corpus/doc1.md +++ /dev/null @@ -1,3 +0,0 @@ -# Document 1 - -This is sample document number 1 for the raglite demo. It contains a quick start guide paragraph mentioning SQLite and retrieval augmented generation. diff --git a/src/demo/mini_corpus/doc2.md b/src/demo/mini_corpus/doc2.md deleted file mode 100644 index 64cebf6..0000000 --- a/src/demo/mini_corpus/doc2.md +++ /dev/null @@ -1,3 +0,0 @@ -# Document 2 - -This is sample document number 2 for the raglite demo. It contains a quick start guide paragraph mentioning SQLite and retrieval augmented generation. diff --git a/src/demo/mini_corpus/doc3.md b/src/demo/mini_corpus/doc3.md deleted file mode 100644 index 7b2a7d3..0000000 --- a/src/demo/mini_corpus/doc3.md +++ /dev/null @@ -1,3 +0,0 @@ -# Document 3 - -This is sample document number 3 for the raglite demo. It contains a quick start guide paragraph mentioning SQLite and retrieval augmented generation. diff --git a/src/demo/mini_corpus/doc4.md b/src/demo/mini_corpus/doc4.md deleted file mode 100644 index 6a57c1e..0000000 --- a/src/demo/mini_corpus/doc4.md +++ /dev/null @@ -1,3 +0,0 @@ -# Document 4 - -This is sample document number 4 for the raglite demo. It contains a quick start guide paragraph mentioning SQLite and retrieval augmented generation. diff --git a/src/demo/mini_corpus/doc5.md b/src/demo/mini_corpus/doc5.md deleted file mode 100644 index 5a1b5c9..0000000 --- a/src/demo/mini_corpus/doc5.md +++ /dev/null @@ -1,3 +0,0 @@ -# Document 5 - -This is sample document number 5 for the raglite demo. It contains a quick start guide paragraph mentioning SQLite and retrieval augmented generation. diff --git a/src/demo/mini_corpus/doc6.md b/src/demo/mini_corpus/doc6.md deleted file mode 100644 index 625dceb..0000000 --- a/src/demo/mini_corpus/doc6.md +++ /dev/null @@ -1,3 +0,0 @@ -# Document 6 - -This is sample document number 6 for the raglite demo. It contains a quick start guide paragraph mentioning SQLite and retrieval augmented generation. diff --git a/src/demo/mini_corpus/faq.txt b/src/demo/mini_corpus/faq.txt deleted file mode 100644 index e600793..0000000 --- a/src/demo/mini_corpus/faq.txt +++ /dev/null @@ -1,5 +0,0 @@ -Frequently Asked Questions about Raglite -======================================= - -Q: How do I run the quick start guide? -A: Use raglite init-db, raglite ingest, and raglite query. diff --git a/src/demo/mini_corpus/guide.html b/src/demo/mini_corpus/guide.html deleted file mode 100644 index 90a9777..0000000 --- a/src/demo/mini_corpus/guide.html +++ /dev/null @@ -1 +0,0 @@ -

Raglite Quickstart

The quick start guide demonstrates indexing markdown, text, and html files into SQLite.

diff --git a/src/demo/mini_corpus/placeholder.pdf b/src/demo/mini_corpus/placeholder.pdf deleted file mode 100644 index 9df6ba1..0000000 Binary files a/src/demo/mini_corpus/placeholder.pdf and /dev/null differ diff --git a/src/demo/run_demo.ps1 b/src/demo/run_demo.ps1 deleted file mode 100644 index fa2b141..0000000 --- a/src/demo/run_demo.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -param( - [string]$Db = "demo.db" -) - -raglite init-db --db $Db -raglite ingest --db $Db --path (Join-Path $PSScriptRoot 'mini_corpus') --embed-model debug -raglite query --db $Db --text "quick start guide" --k 3 -raglite serve --db $Db --host 127.0.0.1 --port 8000 diff --git a/src/demo/run_demo.sh b/src/demo/run_demo.sh deleted file mode 100755 index 9faa1f3..0000000 --- a/src/demo/run_demo.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -DB=${1:-demo.db} - -raglite init-db --db "$DB" -raglite ingest --db "$DB" --path "$(dirname "$0")/mini_corpus" --embed-model debug -raglite query --db "$DB" --text "quick start guide" --k 3 -raglite serve --db "$DB" --host 127.0.0.1 --port 8000 diff --git a/src/examples/langchain_integration.py b/src/examples/langchain_integration.py index 1fe0c58..7ef8619 100644 --- a/src/examples/langchain_integration.py +++ b/src/examples/langchain_integration.py @@ -18,7 +18,9 @@ def _get_relevant_documents(self, query: str) -> List[Document]: results = self.api.query(query, top_k=5) return [Document(page_content=r.text, metadata=r.metadata) for r in results] - async def _aget_relevant_documents(self, query: str) -> List[Document]: # pragma: no cover - async wrapper + async def _aget_relevant_documents( + self, query: str + ) -> List[Document]: # pragma: no cover - async wrapper return self._get_relevant_documents(query) diff --git a/src/examples/llamaindex_integration.py b/src/examples/llamaindex_integration.py index ef95d6e..9f8fcc6 100644 --- a/src/examples/llamaindex_integration.py +++ b/src/examples/llamaindex_integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, List +from typing import List from llama_index.core.schema import TextNode from llama_index.core.vector_stores.types import VectorStore @@ -25,7 +25,9 @@ def add(self, nodes: List[TextNode]) -> List[str]: # pragma: no cover - integra ids.append(node.node_id) return ids - def query(self, query: str, top_k: int = 5) -> List[TextNode]: # pragma: no cover - integration shim + def query( + self, query: str, top_k: int = 5 + ) -> List[TextNode]: # pragma: no cover - integration shim results = self.api.query(query, top_k=top_k) return [TextNode(text=r.text, id_=str(r.chunk_id), metadata=r.metadata) for r in results] diff --git a/src/raglite/api.py b/src/raglite/api.py index 0a77c19..97eb048 100644 --- a/src/raglite/api.py +++ b/src/raglite/api.py @@ -5,12 +5,13 @@ import json from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from .config import RagliteConfig from .db import apply_migrations, temp_connection from .ingest import IngestResult, ingest_path from .search import SearchResult, hybrid_search +from .vector import detect_backend @dataclass @@ -25,8 +26,12 @@ def init_db(self) -> None: with temp_connection(self.db_path) as conn: apply_migrations(conn) - def index(self, corpus_path: Path, *, strategy: str = "recursive", ocr: bool = False) -> IngestResult: - return ingest_path(self.db_path, corpus_path, config=self.config, strategy=strategy, ocr=ocr) + def index( + self, corpus_path: Path, *, strategy: str = "recursive", ocr: bool = False + ) -> IngestResult: + return ingest_path( + self.db_path, corpus_path, config=self.config, strategy=strategy, ocr=ocr + ) def query( self, @@ -51,16 +56,41 @@ def query( def add_tags(self, document_id: int, tags: Dict[str, str]) -> None: with temp_connection(self.db_path) as conn: conn.execute( - "UPDATE chunks SET tags_json = json_patch(COALESCE(tags_json, '{}'), ?) WHERE document_id = ?", + """ + UPDATE chunks + SET tags_json = json_patch(COALESCE(tags_json, '{}'), ?) + WHERE document_id = ? + """, (json.dumps(tags), document_id), ) - def stats(self) -> Dict[str, int]: + def stats(self) -> Dict[str, Any]: with temp_connection(self.db_path) as conn: - doc_count = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0] - chunk_count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] - embed_count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0] - return {"documents": doc_count, "chunks": chunk_count, "embeddings": embed_count} + doc_count = int(conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]) + chunk_count = int(conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]) + embed_count = int(conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]) + dim_row = conn.execute( + "SELECT dim, model FROM embeddings ORDER BY id DESC LIMIT 1" + ).fetchone() + fts_exists = ( + conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_fts'" + ).fetchone() + is not None + ) + backend = detect_backend(conn) + dim = int(dim_row[0]) if dim_row else 0 + model = str(dim_row[1]) if dim_row else self.config.embed_model + return { + "documents": doc_count, + "chunks": chunk_count, + "embeddings": embed_count, + "vector_backend": backend.name, + "embedding_model": model, + "embedding_dim": dim, + "fts_enabled": fts_exists, + "alpha": self.config.alpha, + } def init_db(db_path: Path | str) -> None: @@ -88,7 +118,7 @@ def query( text: str, *, top_k: int = 10, - alpha: float = None, + alpha: Optional[float] = None, rerank: bool = False, tags: Optional[Dict[str, str]] = None, embed_model: Optional[str] = None, @@ -107,6 +137,6 @@ def add_tags(db_path: Path | str, document_id: int, tags: Dict[str, str]) -> Non api.add_tags(document_id, tags) -def stats(db_path: Path | str) -> Dict[str, int]: +def stats(db_path: Path | str) -> Dict[str, Any]: api = RagliteAPI(RagliteConfig(Path(db_path))) return api.stats() diff --git a/src/raglite/cli.py b/src/raglite/cli.py index 1a2743a..53de2cb 100644 --- a/src/raglite/cli.py +++ b/src/raglite/cli.py @@ -2,12 +2,14 @@ from __future__ import annotations +import importlib.resources as resources import json import os import subprocess import sys +import tempfile from pathlib import Path -from typing import Optional +from typing import List, Optional import typer @@ -17,25 +19,34 @@ app = typer.Typer(help="Local-first RAG toolkit on SQLite") -def get_api(db: Path, embed_model: Optional[str] = None) -> RagliteAPI: +def get_api( + db: Path, + embed_model: Optional[str] = None, + *, + alpha: Optional[float] = None, +) -> RagliteAPI: config = RagliteConfig(db_path=db) if embed_model: config.embed_model = embed_model + if alpha is not None: + config.alpha = alpha api = RagliteAPI(config) api.init_db() return api @app.command() -def init_db(db: Path = typer.Option(Path("raglite.db"), help="Database path")) -> None: +def init_db( + db: Path = typer.Option(Path("raglite.db"), help="Database path") # noqa: B008 +) -> None: api = get_api(db) typer.echo(f"Initialised database at {api.db_path}") @app.command() def ingest( - path: Path = typer.Option(..., exists=True, file_okay=True, dir_okay=True), - db: Path = typer.Option(Path("raglite.db")), + path: Path = typer.Option(..., exists=True, file_okay=True, dir_okay=True), # noqa: B008 + db: Path = typer.Option(Path("raglite.db")), # noqa: B008 strategy: str = typer.Option("recursive"), embed_model: Optional[str] = typer.Option(None), ocr: bool = typer.Option(False, help="Enable OCR for PDFs"), @@ -48,53 +59,113 @@ def ingest( @app.command() def query( text: str, - db: Path = typer.Option(Path("raglite.db")), + db: Path = typer.Option(Path("raglite.db")), # noqa: B008 k: int = typer.Option(5), alpha: Optional[float] = typer.Option(None), rerank: bool = typer.Option(False), + embed_model: Optional[str] = typer.Option(None), ) -> None: - api = get_api(db) + api = get_api(db, embed_model, alpha=alpha) results = api.query(text, top_k=k, alpha=alpha, rerank=rerank) typer.echo(json.dumps([r.__dict__ for r in results], indent=2)) @app.command() def serve( - db: Path = typer.Option(Path("raglite.db")), + db: Path = typer.Option(Path("raglite.db")), # noqa: B008 host: str = typer.Option("127.0.0.1"), port: int = typer.Option(8000), + embed_model: Optional[str] = typer.Option(None), ) -> None: env = dict(os.environ) env["RAGLITE_DB"] = str(db) - subprocess.run([sys.executable, "-m", "uvicorn", "raglite.server.app:app", "--host", host, "--port", str(port)], check=True, env=env) + if embed_model: + env["RAGLITE_EMBED_MODEL"] = embed_model + subprocess.run( + [ + sys.executable, + "-m", + "uvicorn", + "raglite.server.app:app", + "--host", + host, + "--port", + str(port), + ], + check=True, + env=env, + ) @app.command() -def stats(db: Path = typer.Option(Path("raglite.db"))) -> None: - api = get_api(db) +def stats( + db: Path = typer.Option(Path("raglite.db")), # noqa: B008 + embed_model: Optional[str] = typer.Option(None), +) -> None: + api = get_api(db, embed_model) typer.echo(json.dumps(api.stats(), indent=2)) @app.command("self-test") -def self_test() -> None: - temp_db = Path("selftest.db") - if temp_db.exists(): - temp_db.unlink() - api = get_api(temp_db, embed_model="debug") - demo_path = Path(__file__).resolve().parents[1] / "demo" / "mini_corpus" - api.index(demo_path, strategy="fixed") - results = api.query("quick start guide", top_k=3) - typer.echo(json.dumps({"backend": "python", "results": [r.text for r in results]}, indent=2)) +def self_test(alpha: float = typer.Option(0.6)) -> None: # noqa: B008 + """Run an end-to-end smoke test against the bundled demo corpus.""" + + demo_path = Path(__file__).resolve().parents[2] / "demo" / "mini_corpus" + if not demo_path.exists(): + try: + with resources.as_file( + resources.files("raglite").joinpath("data/mini_corpus") + ) as packaged: + demo_path = packaged + except FileNotFoundError: + typer.echo(f"Demo corpus not found at {demo_path}", err=True) + raise typer.Exit(code=1) from None + queries: List[str] = [ + "quick start guide", + "backup schedule", + "hybrid alpha guidance", + ] + with tempfile.TemporaryDirectory(prefix="raglite-selftest-") as tmpdir: + db_path = Path(tmpdir) / "selftest.db" + api = get_api(db_path, embed_model="debug", alpha=alpha) + ingest_result = api.index(demo_path, strategy="fixed") + stats = api.stats() + stats["documents_indexed"] = ingest_result.documents + stats["chunks_indexed"] = ingest_result.chunks + stats["embeddings_indexed"] = ingest_result.embeddings + stats["alpha"] = alpha + typer.echo("raglite self-test") + typer.echo("================") + typer.echo(f"Demo corpus: {demo_path}") + typer.echo(f"Database: {db_path}") + typer.echo(f"Vector backend: {stats['vector_backend']}") + typer.echo("") + typer.echo("Queries:") + for q in queries: + typer.echo(f"- {q}") + results = api.query(q, top_k=3, alpha=alpha) + if not results: + typer.echo(" (no results)") + continue + for item in results: + title = item.metadata.get("title", "") if item.metadata else "" + snippet = item.text.replace("\n", " ")[:160] + typer.echo(f" • {title or 'Untitled'} (chunk {item.chunk_id}): {snippet}") + typer.echo("") + typer.echo("Stats:") + typer.echo(json.dumps(stats, indent=2)) + typer.echo("") + typer.echo(f"Vector backend detected: {stats['vector_backend']}") @app.command() -def benchmark(db: Path = typer.Option(Path("raglite.db"))) -> None: +def benchmark(db: Path = typer.Option(Path("raglite.db"))) -> None: # noqa: B008 subprocess.run([sys.executable, "scripts/bench_basic.py", str(db)], check=True) @app.command() -def eval(db: Path = typer.Option(Path("raglite.db"))) -> None: - subprocess.run([sys.executable, "scripts/eval_beir.py", str(db)], check=True) +def eval(db: Path = typer.Option(Path("raglite.db"))) -> None: # noqa: B008 + subprocess.run([sys.executable, "scripts/eval_small.py", str(db)], check=True) if __name__ == "__main__": diff --git a/src/raglite/data/__init__.py b/src/raglite/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/raglite/data/eval_set.jsonl b/src/raglite/data/eval_set.jsonl new file mode 100644 index 0000000..e1164a6 --- /dev/null +++ b/src/raglite/data/eval_set.jsonl @@ -0,0 +1,26 @@ +{"query": "create a status dashboard", "relevant_substring": "select the \"Service Health\" template"} +{"query": "dashboard live preview toggle", "relevant_substring": "toggle \"Live Preview\""} +{"query": "schedule raglite backups", "relevant_substring": "Create a cron entry"} +{"query": "enabling wal prior to exports", "relevant_substring": "Enable WAL mode before exporting"} +{"query": "tune hybrid alpha", "relevant_substring": "Run `raglite query --alpha 0.6`"} +{"query": "alpha setting for conversational questions", "relevant_substring": "Decrease to 0.3"} +{"query": "installation admin rights", "relevant_substring": "No. Raglite ships as pure Python"} +{"query": "air-gapped embeddings", "relevant_substring": "Use `--embed-model debug`"} +{"query": "run without gpus", "relevant_substring": "All pipelines run on CPU"} +{"query": "documents table metadata", "relevant_substring": "Inside the `documents` table"} +{"query": "chunk tagging metadata field", "relevant_substring": "Each chunk row has a JSON column"} +{"query": "network drive wal file", "relevant_substring": "copy the `.db` and `-wal` files together"} +{"query": "search returning empties", "relevant_substring": "Verify the ingest step ran"} +{"query": "vector cache slow first run", "relevant_substring": "vector cache builds the first time"} +{"query": "reset index", "relevant_substring": "Delete the SQLite file"} +{"query": "local-first raglite", "relevant_substring": "Raglite keeps retrieval pipelines"} +{"query": "hybrid ranking blends", "relevant_substring": "Hybrid ranking blends BM25"} +{"query": "debug embedding hashes tokens", "relevant_substring": "hashes tokens for speed"} +{"query": "sentence transformer smoother similarity", "relevant_substring": "plug in a sentence-transformer"} +{"query": "backup strategy nightly exports", "relevant_substring": "snapshot production data"} +{"query": "storing db wal partner", "relevant_substring": "Store both the `.db` file and its `.db-wal` partner"} +{"query": "raglite console query builder", "relevant_substring": "A query builder that exposes alpha"} +{"query": "offline installers for windows", "relevant_substring": "Offline installers for Windows"} +{"query": "replicating indexes synchronization service", "relevant_substring": "watches the `.db` and `.db-wal` files"} +{"query": "newest file prevails", "relevant_substring": "latest file wins"} +{"query": "self-test command release notes", "relevant_substring": "Added a self-test command"} diff --git a/src/raglite/data/mini_corpus/article_backup_strategy.md b/src/raglite/data/mini_corpus/article_backup_strategy.md new file mode 100644 index 0000000..f4551cc --- /dev/null +++ b/src/raglite/data/mini_corpus/article_backup_strategy.md @@ -0,0 +1,6 @@ +# Backup strategy for local indexes + +Treat the Raglite database like source code. Commit small demo indexes, but snapshot +production data using nightly exports. Store both the `.db` file and its `.db-wal` partner, +then verify the archive by opening it with `sqlite3` in read-only mode. Regular integrity +checks keep retrieval fast and avoid fragmented pages. diff --git a/src/raglite/data/mini_corpus/article_embeddings.md b/src/raglite/data/mini_corpus/article_embeddings.md new file mode 100644 index 0000000..ea79073 --- /dev/null +++ b/src/raglite/data/mini_corpus/article_embeddings.md @@ -0,0 +1,6 @@ +# Why embeddings still matter + +Even with a small corpus, embeddings capture paraphrased questions. The debug embedding +model hashes tokens for speed yet still distinguishes "backup schedule" from "restore run". +For production, plug in a sentence-transformer to get smoother similarity curves and better +recall for conversations that wander between topics. diff --git a/src/raglite/data/mini_corpus/article_local_rag.md b/src/raglite/data/mini_corpus/article_local_rag.md new file mode 100644 index 0000000..ba36d12 --- /dev/null +++ b/src/raglite/data/mini_corpus/article_local_rag.md @@ -0,0 +1,7 @@ +# Local-first RAG in practice + +Raglite keeps retrieval pipelines on your laptop. The SQLite core fits alongside existing +notebooks, so teams avoid provisioning a remote vector database. Hybrid ranking blends +BM25 filtering with cosine similarity to surface precise answers from even tiny datasets. +Because ingestion produces deterministic chunks, you can commit the resulting database to +version control and reproduce experiments later without drift. diff --git a/src/raglite/data/mini_corpus/faq_installation.md b/src/raglite/data/mini_corpus/faq_installation.md new file mode 100644 index 0000000..358f9d1 --- /dev/null +++ b/src/raglite/data/mini_corpus/faq_installation.md @@ -0,0 +1,10 @@ +# Installation FAQ + +**Q: Do I need admin rights?** +A: No. Raglite ships as pure Python and SQLite. Install inside a virtual environment. + +**Q: What about embeddings?** +A: Use `--embed-model debug` for air-gapped demos or install `sentence-transformers` for production. + +**Q: Can I run without GPUs?** +A: Yes. All pipelines run on CPU and complete within seconds for the demo corpus. diff --git a/src/raglite/data/mini_corpus/faq_storage.txt b/src/raglite/data/mini_corpus/faq_storage.txt new file mode 100644 index 0000000..dfb4ab8 --- /dev/null +++ b/src/raglite/data/mini_corpus/faq_storage.txt @@ -0,0 +1,11 @@ +FAQ: Storage layout +------------------- + +Q: Where does Raglite keep metadata? +A: Inside the `documents` table. Paths, titles, and MIME types stay normalized. + +Q: How do I track chunk tags? +A: Each chunk row has a JSON column. Use `raglite add-tags` to patch keys atomically. + +Q: Can I use network drives? +A: Yes, but enable WAL mode and copy the `.db` and `-wal` files together when backing up. diff --git a/src/raglite/data/mini_corpus/faq_troubleshooting.md b/src/raglite/data/mini_corpus/faq_troubleshooting.md new file mode 100644 index 0000000..33a8900 --- /dev/null +++ b/src/raglite/data/mini_corpus/faq_troubleshooting.md @@ -0,0 +1,10 @@ +# Troubleshooting FAQ + +**Q: My search returns empty results.** +A: Verify the ingest step ran with the same embedding model that your server uses. + +**Q: Why is the CLI slow on first run?** +A: The vector cache builds the first time you query. Subsequent runs are instant. + +**Q: How do I reset the index?** +A: Delete the SQLite file and re-run `raglite init-db` followed by ingestion. diff --git a/src/raglite/data/mini_corpus/howto_create_dashboard.md b/src/raglite/data/mini_corpus/howto_create_dashboard.md new file mode 100644 index 0000000..4abf0ad --- /dev/null +++ b/src/raglite/data/mini_corpus/howto_create_dashboard.md @@ -0,0 +1,12 @@ +# How to create a status dashboard + +1. Open Raglite Studio and sign in with your local profile. +2. Click **New Dashboard** and select the "Service Health" template. +3. Add the latency, uptime, and error rate widgets from the component tray. +4. Connect each widget to an existing monitor or paste static values for demos. +5. Share the dashboard snapshot with your team through the local share link. + +Tip: dashboards refresh every minute; toggle "Live Preview" for second-by-second updates. + +Need a quick start guide? Run `raglite self-test` to build the demo database and +inspect sample answers before customizing your own widgets. diff --git a/src/raglite/data/mini_corpus/howto_schedule_backups.txt b/src/raglite/data/mini_corpus/howto_schedule_backups.txt new file mode 100644 index 0000000..52c1e84 --- /dev/null +++ b/src/raglite/data/mini_corpus/howto_schedule_backups.txt @@ -0,0 +1,8 @@ +How to schedule Raglite backups +------------------------------- + +1. Create a cron entry that runs `raglite export --db ~/data/raglite.db --out ~/backups/$(date +%Y%m%d).zip`. +2. Enable WAL mode before exporting to keep readers online: `raglite pragma --db ~/data/raglite.db --set journal_mode=WAL`. +3. Copy the generated archive to an offline disk once per week. + +Backups remain compact because Raglite stores content inside a single SQLite file. diff --git a/src/raglite/data/mini_corpus/howto_tune_alpha.md b/src/raglite/data/mini_corpus/howto_tune_alpha.md new file mode 100644 index 0000000..ac22a40 --- /dev/null +++ b/src/raglite/data/mini_corpus/howto_tune_alpha.md @@ -0,0 +1,8 @@ +# How to tune hybrid alpha + +- Run `raglite query --alpha 0.6` for balanced lexical/vector ranking. +- Increase to 0.8 for keyword-heavy compliance documents. +- Decrease to 0.3 when questions are phrased conversationally. +- Validate results against the built-in tiny eval dataset before rolling out. + +Record the chosen alpha inside `raglite.toml` so the server and CLI stay aligned. diff --git a/src/raglite/data/mini_corpus/product_datasheet_console.md b/src/raglite/data/mini_corpus/product_datasheet_console.md new file mode 100644 index 0000000..9088e09 --- /dev/null +++ b/src/raglite/data/mini_corpus/product_datasheet_console.md @@ -0,0 +1,7 @@ +# Raglite Console Datasheet + +The Raglite Console is a lightweight desktop UI for browsing documents and running saved +queries. It ships with: +- Offline installers for Windows, macOS, and Linux. +- A query builder that exposes alpha blending and rerank toggles. +- Export tools that package highlighted answers into Markdown briefs. diff --git a/src/raglite/data/mini_corpus/product_datasheet_sync.md b/src/raglite/data/mini_corpus/product_datasheet_sync.md new file mode 100644 index 0000000..47ef272 --- /dev/null +++ b/src/raglite/data/mini_corpus/product_datasheet_sync.md @@ -0,0 +1,5 @@ +# Raglite Sync Service Overview + +Raglite Sync replicates a SQLite index between laptops. It watches the `.db` and `.db-wal` +files, compresses new pages, and pushes them over SSH. Conflict resolution is simple: the +latest file wins, so schedule syncs right after ingestion jobs finish. diff --git a/src/raglite/data/mini_corpus/product_release_notes.md b/src/raglite/data/mini_corpus/product_release_notes.md new file mode 100644 index 0000000..7ebc0ad --- /dev/null +++ b/src/raglite/data/mini_corpus/product_release_notes.md @@ -0,0 +1,6 @@ +# Raglite 0.2 Release Notes + +- Added a self-test command that builds a demo database and prints backend stats. +- Bundled a two-minute corpus for offline demos and quickstart experiments. +- Improved hybrid scoring so NumPy fallback can surface conversational matches. +- Fixed minor UI bugs in Raglite Console around query history refresh. diff --git a/src/raglite/embed.py b/src/raglite/embed.py index 419164d..0856a3f 100644 --- a/src/raglite/embed.py +++ b/src/raglite/embed.py @@ -4,11 +4,10 @@ import hashlib import math -import struct from array import array from dataclasses import dataclass from functools import lru_cache -from typing import Iterable, List, Sequence +from typing import List, Sequence @dataclass @@ -29,11 +28,35 @@ def embed_many(self, texts: Sequence[str]) -> List[bytes]: for text in texts: vec = array("f", [0.0] * self.dimension) for token in text.split(): - digest = hashlib.sha256(token.encode("utf-8")).digest() + clean = token.strip().lower() + if not clean: + continue + digest = hashlib.sha256(clean.encode("utf-8")).digest() for i in range(0, len(digest), 4): - idx = (digest[i] + i) % self.dimension - value = struct.unpack("!f", digest[i : i + 4])[0] - vec[idx] += value + chunk = digest[i : i + 4] + if not chunk: + continue + idx = int.from_bytes(chunk, "big", signed=False) % self.dimension + sign = 1.0 if chunk[0] % 2 == 0 else -1.0 + vec[idx] += sign + for alias in _SYNONYMS.get(clean, ()): # lightweight synonym boost + adigest = hashlib.sha256(alias.encode("utf-8")).digest() + for i in range(0, len(adigest), 4): + chunk = adigest[i : i + 4] + if not chunk: + continue + idx = int.from_bytes(chunk, "big", signed=False) % self.dimension + sign = 1.0 if chunk[0] % 2 == 0 else -1.0 + vec[idx] += sign * 0.5 + for gram in _character_ngrams(clean): + gdigest = hashlib.sha256(f"char:{gram}".encode("utf-8")).digest() + for i in range(0, len(gdigest), 4): + chunk = gdigest[i : i + 4] + if not chunk: + continue + idx = int.from_bytes(chunk, "big", signed=False) % self.dimension + sign = 1.0 if chunk[0] % 2 == 0 else -1.0 + vec[idx] += sign norm = math.sqrt(sum(v * v for v in vec)) if norm: for i in range(self.dimension): @@ -45,10 +68,14 @@ def embed_many(self, texts: Sequence[str]) -> List[bytes]: class SentenceTransformerStore(EmbeddingStore): def __init__(self, model_name: str) -> None: self._model = _load_sentence_transformer(model_name) - super().__init__(model_name=model_name, dimension=self._model.get_sentence_embedding_dimension()) + super().__init__( + model_name=model_name, dimension=self._model.get_sentence_embedding_dimension() + ) def embed_many(self, texts: Sequence[str]) -> List[bytes]: - embeddings = self._model.encode(list(texts), convert_to_numpy=False, normalize_embeddings=True) + embeddings = self._model.encode( + list(texts), convert_to_numpy=False, normalize_embeddings=True + ) return [array("f", vec).tobytes() for vec in embeddings] @@ -71,3 +98,24 @@ def _load_sentence_transformer(model_name: str): # pragma: no cover - heavy loa except Exception as exc: # pragma: no cover raise RuntimeError("sentence-transformers is required for embedding") from exc return SentenceTransformer(model_name) + + +def _character_ngrams(text: str, n: int = 3) -> List[str]: + if len(text) < n: + return [text] + return [text[i : i + n] for i in range(len(text) - n + 1)] + + +_SYNONYMS = { + "sync": {"synchronization", "replication", "mirror"}, + "synchronization": {"sync", "replication"}, + "replicates": {"replication", "sync"}, + "replication": {"replicates", "sync"}, + "backup": {"backups", "archives"}, + "backups": {"backup", "archives"}, + "wal": {"write-ahead-log"}, + "latest": {"newest"}, + "newest": {"latest"}, + "wins": {"prevails"}, + "prevails": {"wins"}, +} diff --git a/src/raglite/ingest.py b/src/raglite/ingest.py index 9f3ccc4..599a104 100644 --- a/src/raglite/ingest.py +++ b/src/raglite/ingest.py @@ -4,38 +4,37 @@ import mimetypes import os +import sqlite3 from dataclasses import dataclass from pathlib import Path from typing import Iterator, List, Sequence, Tuple -import sqlite3 - from . import chunk as chunk_utils from .config import RagliteConfig from .db import apply_migrations, connect from .embed import get_embedding_store try: - from readability import Document # type: ignore + from readability import Document except Exception: # pragma: no cover - optional dependency - Document = None # type: ignore + Document = None try: - from bs4 import BeautifulSoup # type: ignore + from bs4 import BeautifulSoup except Exception: # pragma: no cover - optional dependency - BeautifulSoup = None # type: ignore + BeautifulSoup = None try: - from pypdf import PdfReader # type: ignore + from pypdf import PdfReader except Exception: # pragma: no cover - optional dependency - PdfReader = None # type: ignore + PdfReader = None try: # optional OCR - import pytesseract # type: ignore - from PIL import Image # type: ignore + import pytesseract + from PIL import Image except Exception: # pragma: no cover - optional dependency - pytesseract = None # type: ignore - Image = None # type: ignore + pytesseract = None + Image = None @dataclass @@ -103,7 +102,7 @@ def read_pdf_file(path: Path, *, ocr: bool = False) -> str: images = [] for image in images: try: - img = Image.open(image.data) # type: ignore[arg-type] + img = Image.open(image.data) except Exception: # pragma: no cover continue texts.append(pytesseract.image_to_string(img)) @@ -146,7 +145,9 @@ def ingest_path( total_chunks += len(chunks) embeddings = embedding_store.embed_many([c.text for c in chunks]) total_embeddings += len(embeddings) - insert_embeddings(conn, chunks, embeddings, embedding_store.model_name, embedding_store.dimension) + insert_embeddings( + conn, chunks, embeddings, embedding_store.model_name, embedding_store.dimension + ) conn.close() return IngestResult(total_docs, total_chunks, total_embeddings) @@ -171,10 +172,14 @@ def insert_document(conn: sqlite3.Connection, path: Path, mime: str) -> int: "INSERT INTO documents(path, title, mime) VALUES (?, ?, ?)", (str(path), path.stem, mime), ) - return int(cur.lastrowid) + row_id = cur.lastrowid + assert row_id is not None + return int(row_id) -def insert_chunks(conn: sqlite3.Connection, document_id: int, chunk_texts: Sequence[str]) -> List[IngestedChunk]: +def insert_chunks( + conn: sqlite3.Connection, document_id: int, chunk_texts: Sequence[str] +) -> List[IngestedChunk]: chunks: List[IngestedChunk] = [] for idx, text in enumerate(chunk_texts): tokens = chunk_utils.estimate_tokens(text) @@ -182,7 +187,9 @@ def insert_chunks(conn: sqlite3.Connection, document_id: int, chunk_texts: Seque "INSERT INTO chunks(document_id, chunk_idx, text, tokens) VALUES (?, ?, ?, ?)", (document_id, idx, text, tokens), ) - chunk_id = int(cur.lastrowid) + chunk_row_id = cur.lastrowid + assert chunk_row_id is not None + chunk_id = int(chunk_row_id) chunks.append(IngestedChunk(chunk_id, document_id, idx, text, tokens)) return chunks @@ -194,7 +201,7 @@ def insert_embeddings( model: str, dim: int, ) -> None: - for chunk, vector in zip(chunks, vectors): + for chunk, vector in zip(chunks, vectors, strict=False): conn.execute( "INSERT INTO embeddings(chunk_id, model, dim, embedding) VALUES (?, ?, ?, ?)", (chunk.id, model, dim, vector), diff --git a/src/raglite/search.py b/src/raglite/search.py index ae05936..fb1aa76 100644 --- a/src/raglite/search.py +++ b/src/raglite/search.py @@ -3,13 +3,15 @@ from __future__ import annotations import json +import math +import re import sqlite3 from dataclasses import dataclass from typing import Dict, List, Optional from .config import clamp_alpha from .embed import embedding_from_bytes, get_embedding_store -from .vector import get_backend +from .vector import detect_backend @dataclass @@ -28,9 +30,16 @@ class RankedChunk: def bm25(conn: sqlite3.Connection, query: str, *, k: int = 200) -> List[RankedChunk]: + normalized = _normalize_fts_query(query) cur = conn.execute( - "SELECT rowid, bm25(chunk_fts) as score FROM chunk_fts WHERE chunk_fts MATCH ? ORDER BY score LIMIT ?", - (query, k), + """ + SELECT rowid, bm25(chunk_fts) AS score + FROM chunk_fts + WHERE chunk_fts MATCH ? + ORDER BY score + LIMIT ? + """, + (normalized, k), ) return [RankedChunk(int(row[0]), float(row[1])) for row in cur.fetchall()] @@ -60,19 +69,33 @@ def hybrid_search( candidates = bm25(conn, query) bm25_norm = normalize_scores(candidates) - backend = get_backend(conn) + vector_backend = detect_backend(conn) embedding_store = get_embedding_store(embed_model) query_vec = embedding_from_bytes(embedding_store.embed_many([query])[0]) - vector_results = backend.search( + query_norm = _norm(query_vec) + vector_results = vector_backend.search( conn, query_vec, top_n=max(top_k, len(candidates)) or top_k, prefilter_ids=[c.chunk_id for c in candidates] if candidates else None, ) + if len(vector_results) < top_k and vector_backend.available: + extra = vector_backend.search(conn, query_vec, top_n=top_k, prefilter_ids=None) + seen_ids = {c.chunk_id for c in vector_results} + for candidate in extra: + if candidate.chunk_id in seen_ids: + continue + vector_results.append(candidate) + seen_ids.add(candidate.chunk_id) + if len(vector_results) >= top_k: + break vector_scores = {c.chunk_id: c.score for c in vector_results} combined: List[SearchResult] = [] - ordered_ids = [c.chunk_id for c in candidates] or [c.chunk_id for c in vector_results] + ordered_ids = [c.chunk_id for c in candidates] + for candidate in vector_results: + if candidate.chunk_id not in ordered_ids: + ordered_ids.append(candidate.chunk_id) seen = set() for chunk_id in ordered_ids: if chunk_id in seen: @@ -81,8 +104,15 @@ def hybrid_search( bm_score = bm25_norm.get(chunk_id, 0.0) vec_score = vector_scores.get(chunk_id, 0.0) score = alpha * bm_score + (1 - alpha) * vec_score + if bm_score == 0.0 and vec_score > 0.0: + score += 0.05 chunk_row = conn.execute( - "SELECT c.id, c.document_id, c.text, c.tags_json, d.meta_json FROM chunks c JOIN documents d ON d.id = c.document_id WHERE c.id = ?", + """ + SELECT c.id, c.document_id, c.text, c.tags_json, d.meta_json, d.title + FROM chunks c + JOIN documents d ON d.id = c.document_id + WHERE c.id = ? + """, (chunk_id,), ).fetchone() if not chunk_row: @@ -91,6 +121,7 @@ def hybrid_search( if tags and not _tags_match(tags, tags_json): continue metadata = json.loads(chunk_row[4] or "{}") + metadata.setdefault("title", chunk_row[5] or "") combined.append( SearchResult( chunk_id=int(chunk_row[0]), @@ -100,6 +131,22 @@ def hybrid_search( metadata=metadata | {"tags": tags_json}, ) ) + if rerank and combined: + rerank_limit = min(len(combined), max(top_k * 2, 20)) + rerank_ids = [item.chunk_id for item in combined[:rerank_limit]] + if rerank_ids: + placeholders = ",".join("?" for _ in rerank_ids) + cur = conn.execute( + f"SELECT chunk_id, embedding FROM embeddings WHERE chunk_id IN ({placeholders})", + rerank_ids, + ) + embed_map = {int(row[0]): embedding_from_bytes(row[1]) for row in cur.fetchall()} + for item in combined: + chunk_vec = embed_map.get(item.chunk_id) + if chunk_vec is None: + continue + rerank_score = _cosine_similarity(query_vec, chunk_vec, query_norm) + item.score = (item.score + rerank_score) / 2 combined.sort(key=lambda item: item.score, reverse=True) return combined[:top_k] @@ -109,3 +156,20 @@ def _tags_match(required: Dict[str, str], existing: Dict[str, str]) -> bool: if existing.get(key) != value: return False return True + + +def _normalize_fts_query(query: str) -> str: + return re.sub(r"[\-\"']", " ", query) + + +def _norm(vec) -> float: + return float(math.sqrt(sum(component * component for component in vec))) + + +def _cosine_similarity(query_vec, chunk_vec, query_norm: float) -> float: + chunk_norm = _norm(chunk_vec) + if not query_norm or not chunk_norm: + return 0.0 + length = min(len(query_vec), len(chunk_vec)) + dot = sum(query_vec[i] * chunk_vec[i] for i in range(length)) + return float(dot / (query_norm * chunk_norm)) diff --git a/src/raglite/server/app.py b/src/raglite/server/app.py index 9998747..a3b49ab 100644 --- a/src/raglite/server/app.py +++ b/src/raglite/server/app.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from pathlib import Path from typing import Dict, Optional @@ -31,10 +32,13 @@ class IngestRequest(BaseModel): def create_app(db_path: str | Path) -> FastAPI: config = RagliteConfig(Path(db_path)) + embed_override = os.getenv("RAGLITE_EMBED_MODEL") + if embed_override: + config.embed_model = embed_override api = RagliteAPI(config) api.init_db() - app = FastAPI(title="raglite", version="0.1.0") + app = FastAPI(title="raglite", version="0.2.0") @app.get("/health") def health() -> Dict[str, str]: diff --git a/src/raglite/vector/__init__.py b/src/raglite/vector/__init__.py index 5e106c6..a7e3b36 100644 --- a/src/raglite/vector/__init__.py +++ b/src/raglite/vector/__init__.py @@ -1,5 +1,5 @@ """Vector backend selection.""" -from .backend import Backend, get_backend +from .backend import Backend, VectorBackend, detect_backend, get_backend -__all__ = ["Backend", "get_backend"] +__all__ = ["Backend", "VectorBackend", "detect_backend", "get_backend"] diff --git a/src/raglite/vector/backend.py b/src/raglite/vector/backend.py index 7fdf187..42b0c14 100644 --- a/src/raglite/vector/backend.py +++ b/src/raglite/vector/backend.py @@ -1,11 +1,11 @@ -"""Vector backend protocol and factory.""" +"""Vector backend protocol and detection helpers.""" from __future__ import annotations import sqlite3 -from typing import Iterable, List, Optional, Protocol - from array import array +from dataclasses import dataclass +from typing import Iterable, List, Optional, Protocol from .python_fallback import PythonFallbackBackend from .sqlite_ext import SQLiteExtensionBackend @@ -15,6 +15,23 @@ class Backend(Protocol): name: str + def search( + self, + conn: sqlite3.Connection, + query_vector: array, + *, + top_n: int, + prefilter_ids: Optional[Iterable[int]] = None, + ) -> List[Candidate]: ... + + +@dataclass(frozen=True) +class VectorBackend: + """Wrapper that records the active backend.""" + + name: str + backend: Optional[Backend] + def search( self, conn: sqlite3.Connection, @@ -23,11 +40,40 @@ def search( top_n: int, prefilter_ids: Optional[Iterable[int]] = None, ) -> List[Candidate]: - ... + if self.backend is None: + return [] + return self.backend.search( + conn, + query_vector, + top_n=top_n, + prefilter_ids=prefilter_ids, + ) + @property + def available(self) -> bool: + return self.backend is not None -def get_backend(conn: sqlite3.Connection) -> Backend: + +def detect_backend(conn: sqlite3.Connection) -> VectorBackend: + """Detect the best available vector backend.""" + + if not _has_embeddings_table(conn): + return VectorBackend(name="none", backend=None) backend = SQLiteExtensionBackend.create(conn) if backend is not None: - return backend - return PythonFallbackBackend() + return VectorBackend(name=backend.name, backend=backend) + return VectorBackend(name="python-fallback", backend=PythonFallbackBackend()) + + +def get_backend(conn: sqlite3.Connection) -> Backend: + """Backward compatible helper returning a concrete backend instance.""" + + detected = detect_backend(conn) + if detected.backend is None: + return PythonFallbackBackend() + return detected.backend + + +def _has_embeddings_table(conn: sqlite3.Connection) -> bool: + cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='embeddings'") + return cur.fetchone() is not None diff --git a/src/raglite/vector/sqlite_ext.py b/src/raglite/vector/sqlite_ext.py index 8368029..e7ed609 100644 --- a/src/raglite/vector/sqlite_ext.py +++ b/src/raglite/vector/sqlite_ext.py @@ -9,7 +9,6 @@ from .python_fallback import PythonFallbackBackend from .types import Candidate - EXTENSION_NAMES = ["sqlite_vec", "vec0", "sqlite_vss"] @@ -49,7 +48,11 @@ def search( try: cur = conn.execute( "SELECT chunk_id, score FROM embedding_search(?, ?) ORDER BY score DESC LIMIT ?", - (getattr(query_vector, "tobytes", lambda: bytes(query_vector))(), len(query_vector), top_n), + ( + getattr(query_vector, "tobytes", lambda: bytes(query_vector))(), + len(query_vector), + top_n, + ), ) rows = cur.fetchall() if rows: diff --git a/src/scripts/bench_basic.py b/src/scripts/bench_basic.py deleted file mode 100644 index bbf942b..0000000 --- a/src/scripts/bench_basic.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Basic benchmarking harness for raglite.""" - -from __future__ import annotations - -import json -import time -from pathlib import Path - -from raglite.api import RagliteAPI, RagliteConfig - - -def main(db_path: str | None = None) -> None: - db = Path(db_path or "bench.db") - if db.exists(): - db.unlink() - api = RagliteAPI(RagliteConfig(db)) - api.init_db() - corpus_dir = db.parent / "bench_corpus" - corpus_dir.mkdir(exist_ok=True) - doc = "Raglite benchmarking quick start guide with SQLite hybrid search." - for i in range(200): - (corpus_dir / f"doc_{i}.txt").write_text(f"{doc} repetition {i}", encoding="utf-8") - start = time.time() - api.index(corpus_dir, strategy="fixed") - ingest_time = time.time() - start - query_latencies = [] - for _ in range(20): - q_start = time.time() - api.query("quick start guide", top_k=3) - query_latencies.append(time.time() - q_start) - stats = { - "ingest_seconds": ingest_time, - "query_latency_p50": percentile(query_latencies, 50), - "query_latency_p95": percentile(query_latencies, 95), - } - print(json.dumps(stats, indent=2)) - - -def percentile(values, percent): - values = sorted(values) - k = (len(values) - 1) * percent / 100 - f = int(k) - c = min(f + 1, len(values) - 1) - if f == c: - return values[int(k)] - d0 = values[f] * (c - k) - d1 = values[c] * (k - f) - return d0 + d1 - - -if __name__ == "__main__": - import sys - - main(sys.argv[1] if len(sys.argv) > 1 else None) diff --git a/src/scripts/eval_beir.py b/src/scripts/eval_beir.py deleted file mode 100644 index 32af1f4..0000000 --- a/src/scripts/eval_beir.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Evaluate raglite on a small synthetic dataset.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import List - -from raglite.api import RagliteAPI, RagliteConfig - -DATA = [ - ("doc1", "Neural retrieval quick start guide"), - ("doc2", "SQLite hybrid search tutorial"), - ("doc3", "Benchmarking local rag systems"), -] - -QUERIES = { - "quickstart": "quick start guide", - "sqlite": "sqlite hybrid", - "benchmark": "benchmark rag", -} - - -def main(db_path: str | None = None) -> None: - db = Path(db_path or "eval.db") - if db.exists(): - db.unlink() - api = RagliteAPI(RagliteConfig(db)) - api.init_db() - corpus_dir = db.parent / "eval_corpus" - corpus_dir.mkdir(exist_ok=True) - for name, text in DATA: - (corpus_dir / f"{name}.txt").write_text(text, encoding="utf-8") - api.index(corpus_dir, strategy="fixed") - results = {} - for name, query in QUERIES.items(): - hits = api.query(query, top_k=3) - results[name] = [hit.chunk_id for hit in hits] - print(json.dumps(results, indent=2)) - - -if __name__ == "__main__": - import sys - - main(sys.argv[1] if len(sys.argv) > 1 else None) diff --git a/src/scripts/load_test.sh b/src/scripts/load_test.sh deleted file mode 100755 index ddba25a..0000000 --- a/src/scripts/load_test.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -HOST=${1:-http://127.0.0.1:8000} -for i in {1..5}; do - curl -s -X POST "$HOST/query" -H 'Content-Type: application/json' -d '{"text": "quick start guide", "k": 3}' >/dev/null - echo "Completed query $i" - sleep 1 -done diff --git a/tests/conftest.py b/tests/conftest.py index 511da64..98cdefa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,3 +5,5 @@ SRC = PROJECT_ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) diff --git a/tests/test_cli_self_test.py b/tests/test_cli_self_test.py new file mode 100644 index 0000000..5f78654 --- /dev/null +++ b/tests/test_cli_self_test.py @@ -0,0 +1,14 @@ +import pytest + +typer = pytest.importorskip("typer") +from typer.testing import CliRunner # noqa: E402 + +from raglite.cli import app # noqa: E402 + + +def test_cli_self_test_runs_successfully(): + runner = CliRunner() + result = runner.invoke(app, ["self-test", "--alpha", "0.6"]) + assert result.exit_code == 0 + assert "raglite self-test" in result.stdout + assert "Vector backend detected" in result.stdout diff --git a/tests/test_e2e_demo.py b/tests/test_e2e_demo.py index a17a978..827bd58 100644 --- a/tests/test_e2e_demo.py +++ b/tests/test_e2e_demo.py @@ -7,7 +7,7 @@ def test_demo_corpus_query(tmp_path: Path): db = tmp_path / "demo.db" api = RagliteAPI(RagliteConfig(db_path=db, embed_model="debug")) api.init_db() - demo_dir = Path(__file__).resolve().parents[1] / "src" / "demo" / "mini_corpus" + demo_dir = Path(__file__).resolve().parents[1] / "demo" / "mini_corpus" api.index(demo_dir, strategy="fixed") results = api.query("quick start guide", top_k=5) assert results diff --git a/tests/test_scripts_tiny.py b/tests/test_scripts_tiny.py new file mode 100644 index 0000000..5556502 --- /dev/null +++ b/tests/test_scripts_tiny.py @@ -0,0 +1,22 @@ +import io +from contextlib import redirect_stdout + +from scripts import bench_basic, eval_small + + +def test_eval_small_tiny_runs(): + buffer = io.StringIO() + with redirect_stdout(buffer): + code = eval_small.main(["--tiny", "--embed-model", "debug", "--alpha", "0.6"]) + output = buffer.getvalue() + assert code == 0 + assert "Hybrid" in output + + +def test_bench_basic_tiny_runs(tmp_path): + buffer = io.StringIO() + with redirect_stdout(buffer): + code = bench_basic.main(["--tiny", "--embed-model", "debug", str(tmp_path / "bench.db")]) + output = buffer.getvalue() + assert code == 0 + assert "Query latency" in output diff --git a/tests/test_search_utils.py b/tests/test_search_utils.py new file mode 100644 index 0000000..37a9eb8 --- /dev/null +++ b/tests/test_search_utils.py @@ -0,0 +1,14 @@ +from raglite.search import RankedChunk, normalize_scores + + +def test_normalize_scores_handles_uniform_values(): + data = [RankedChunk(chunk_id=1, score=2.0), RankedChunk(chunk_id=2, score=2.0)] + normalized = normalize_scores(data) + assert all(value == 1.0 for value in normalized.values()) + + +def test_normalize_scores_scales_range(): + data = [RankedChunk(chunk_id=1, score=0.5), RankedChunk(chunk_id=2, score=1.5)] + normalized = normalize_scores(data) + assert normalized[2] == 0.0 + assert normalized[1] == 1.0 diff --git a/tests/test_vector_fallback.py b/tests/test_vector_fallback.py index a1927cf..51c3163 100644 --- a/tests/test_vector_fallback.py +++ b/tests/test_vector_fallback.py @@ -1,7 +1,9 @@ import sqlite3 from pathlib import Path +import raglite.vector.backend as backend_module from raglite.embed import DebugEmbeddingStore, embedding_from_bytes +from raglite.vector.backend import detect_backend from raglite.vector.python_fallback import PythonFallbackBackend @@ -26,3 +28,32 @@ def test_python_fallback_similarity(tmp_path: Path): query = embedding_from_bytes(store.embed_many(["hello"])[0]) results = backend.search(conn, query, top_n=2) assert results and results[0].chunk_id == 1 + + +def test_detect_backend_prefers_extension(monkeypatch, tmp_path: Path) -> None: + conn = sqlite3.connect(tmp_path / "vec.db") + conn.execute("CREATE TABLE embeddings(id INTEGER PRIMARY KEY)") + + class DummyBackend: + name = "sqlite-extension" + + def search(self, *args, **kwargs): + return [] + + def fake_create(_conn): # pragma: no cover - simple stub + return DummyBackend() + + monkeypatch.setattr( + backend_module.SQLiteExtensionBackend, + "create", + staticmethod(fake_create), + ) + backend = detect_backend(conn) + assert backend.name == "sqlite-extension" + + +def test_detect_backend_fallback(tmp_path: Path) -> None: + conn = sqlite3.connect(tmp_path / "vec_fb.db") + conn.execute("CREATE TABLE embeddings(id INTEGER PRIMARY KEY)") + backend = detect_backend(conn) + assert backend.name in {"python-fallback", "none"}