diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfc79a5..46d8f372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,27 @@ - `evaluations run --filter/-f CLAUSE`: SQL `WHERE` clause over document columns, applied to the retrieval benchmark's searches and to every capability search during QA. Recorded as `document_filter` in experiment metadata. - `mtrag_clapnq` / `mtrag_clapnq_rewrite` / `mtrag_clapnq_live` / `mtrag_clapnq_live_uncompacted` evaluation datasets: multi-turn QA with gold-prefix and live-session conversation replay, live arms with and without `EvidenceCompactionCapability`, Recall@k/nDCG@k retrieval metrics, eligibility-aware citation scoring, refusal precision/recall, per-turn tool-traffic attributes, and `citation_status` / `turn_citation_status` eval attributes. - Raw chunk metadata is now exposed to search and citation results, through `SearchResult.chunk_meta` and `Citation.chunk_meta`. For context-expanded results, the metadata is that of the anchor chunk. - +- BTree indexes on `chunks.id`, `chunks.document_id` and `documents.id`, and a Bitmap index on `document_items.label`. Existing databases need `haiku-rag migrate`. +- `lancedb.read_consistency_interval_seconds` (default 30), `lancedb.index_cache_size_bytes` and `lancedb.metadata_cache_size_bytes`. The LanceDB session is shared across connections in a process, so its index and metadata caches survive a connection being closed. ### Changed +- Eval judge pinned to `qwen3.8`: `DEFAULT_JUDGE_MODEL` is `ollama:qwen3.8`, and the reference configs use `Inferact/Qwen3.8-27B-NVFP4` with `extra_body.chat_template_kwargs.reasoning_effort: low`. Results in `docs/benchmarks.md` were judged by `Qwen3.6-35B-A3B-NVFP4` and are not re-judged. +- `create_capability(rag=...)` lends a capability an open client rather than having it open its own; `client.ask`/`client.analyze` now pass theirs. +- The MCP server opens one database client for its lifetime instead of one per tool call, and fails at startup if the database cannot be opened. `delete_document` no longer opens its own connection with `skip_validation=True`, so the server no longer opts out of embedding-config validation: drift that validation rejects (any `vector_dim` mismatch, or identity drift on a writable server) now fails MCP startup instead of serving a delete-only server. Use the CLI to delete under drift. - `import_documents` embeds chunks across the whole batch in one pass instead of per document. +- `haiku.rag` and `haiku.rag-slim` summaries and keywords; both packages now publish `[project.urls]`. +- `server.json` declares `title` and `websiteUrl`, and drops the `keywords` and `license` keys, which are not in the server schema. ### Removed - `wix` evaluation dataset and its reference config `evaluations/configs/wix.yaml`. +### Fixed + +- `DocumentRepository.delete_all` recreated `document_items` from `DocumentItemRecord` instead of `get_document_items_arrow_schema()`, returning `picture_data` as `binary` rather than `large_binary`. +- `server.json` runtime arguments are `mcp --stdio`, was `serve --mcp`. + ## [0.74.0] - 2026-08-13 ### Added diff --git a/README.md b/README.md index 869b3bfd..1cc489b0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ -# Haiku RAG +# haiku.rag +[](https://pypi.org/project/haiku.rag/) +[](https://pypi.org/project/haiku.rag/) +[](https://pepy.tech/projects/haiku-rag-slim) +[](https://ggozad.github.io/haiku.rag/) [](https://github.com/ggozad/haiku.rag/actions/workflows/test.yml) [](https://codecov.io/gh/ggozad/haiku.rag) -Agentic RAG built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.pydantic.dev/), and [Docling](https://docling-project.github.io/docling/). +Agentic RAG that answers questions about your own documents with citations to page numbers and section headings. Runs locally on an embedded database, no server required. + +Built on [LanceDB](https://lancedb.com/), [Pydantic AI](https://ai.pydantic.dev/), and [Docling](https://docling-project.github.io/docling/). Full documentation at [ggozad.github.io/haiku.rag](https://ggozad.github.io/haiku.rag/). > **New: vision and multimodal search.** Picture-aware ingestion captures embedded figure bytes; vision-capable QA models receive them alongside text. Multimodal embedders put picture vectors in the same space as text, enabling text-as-query → figure hits and image-as-query retrieval. diff --git a/app/backend/pyproject.toml b/app/backend/pyproject.toml index bb0290b8..5e2b669d 100644 --- a/app/backend/pyproject.toml +++ b/app/backend/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "uvicorn[standard]>=0.40.0", "pydantic-ai-slim[ag-ui,anthropic,openai]>=2.18.0,<3.0.0", "python-dotenv>=1.2.1", - "haiku.rag-slim>=0.74.0", + "haiku.rag-slim>=0.75.0", "logfire[pydantic-ai]>=3.17.0", ] diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 68340bb0..c75d1040 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -2,140 +2,9 @@ We evaluate `haiku.rag` on a small set of datasets that exercise different parts of the pipeline. OpenRAG Bench (ORB), T²-RAGBench, HotpotQA, and MTRAG are the datasets we currently track. Retrieval, QA accuracy, and citation retrieval are scored end-to-end through the RAG and analysis capabilities. -## Running Evaluations - -You can run evaluations with the `evaluations` CLI: - -```bash -evaluations run hotpotqa -evaluations run orb_text -``` - -The evaluation flow is orchestrated with [`pydantic-evals`](https://github.com/pydantic/pydantic-ai/tree/main/libs/pydantic-evals), which we leverage for dataset management, scoring, and report generation. - -### Pre-built Databases - -Building evaluation databases from scratch can take a long time, especially for large datasets like OpenRAG Bench. Pre-built databases are available on HuggingFace: - -```bash -# Download a specific dataset -evaluations download hotpotqa - -# Download all datasets -evaluations download all - -# Force re-download (overwrite existing) -evaluations download hotpotqa --force -``` - -Active datasets: - -| Dataset | Size | -|---------|------| -| `orb_text` — OpenRAG Bench, text embedder (`qwen3-embedding:4b`) with VLM picture descriptions baked into chunk content | ~18 GB | -| `orb_multimodal` — OpenRAG Bench, multimodal embedder (`qwen3-vl-embedding-8b`); picture vectors live in the same space as text for cross-modal retrieval | ~16 GB | -| `orb_multimodal_nemotron` — OpenRAG Bench, multimodal embedder (`nvidia/llama-nemotron-embed-vl-1b-v2`), the embedder behind the published headline results | ~16 GB | -| `t2_finqa` — T²-RAGBench (FinQA) financial QA, text embedder (`qwen3-embedding:4b`); scored by exact numeric match, run with `--target analysis-capability` | ~2 GB | -| `hotpotqa` — HotpotQA multi-hop QA over Wikipedia paragraphs, text embedder (`qwen3-embedding:4b`) | ~1.5 GB | -| `mtrag_clapnq` — MTRAG multi-turn RAG, ClapNQ (Wikipedia) passages, text embedder (`qwen3-embedding:4b`); also serves the `mtrag_clapnq_rewrite`, `mtrag_clapnq_live` and `mtrag_clapnq_live_uncompacted` keys | ~2.8 GB | - -After downloading, run benchmarks with `--skip-db`. Each database is built with a specific embedder, so pass its reference config from `evaluations/configs/` (a database only opens against a config whose embedder matches): - -```bash -evaluations run orb_multimodal_nemotron --skip-db --config configs/orb_multimodal_nemotron.yaml -``` - -The configs use `vllm` as the model host. Point `base_url` at your own OpenAI-compatible endpoints to reproduce the numbers. - -### Configuration - -The benchmark script accepts several options: - -```bash -evaluations run hotpotqa --config /path/to/haiku.rag.yaml --db /path/to/custom.lancedb -``` - -**Options:** - -- `--config PATH` - Specify a custom `haiku.rag.yaml` configuration file -- `--db PATH` - Override the database path (default: platform-specific user data directory) -- `--skip-db` - Skip updating the evaluation database -- `--skip-retrieval` - Skip retrieval benchmark -- `--skip-qa` - Skip QA benchmark -- `--limit N` - Limit number of test cases -- `--name NAME` - Override the evaluation name -- `--target {rag-capability,analysis-capability}` - Choose which [capability](capabilities/index.md) to benchmark end-to-end (default: `rag-capability`). The target names remain stable dataset identifiers. -- `--capability-model PROVIDER:NAME` - Override the capability model independently from the judge (default: `config.qa.model`, or `config.analysis.model` when set for `--target analysis-capability`). -- `--filter CLAUSE` / `-f CLAUSE` - Restrict every benchmark search to a subset of the database (see [Restricting the corpus](#restricting-the-corpus)). - -If no config file is specified, the script searches standard locations: `./haiku.rag.yaml`, user config directory, then falls back to defaults. - -To pin the LLM judge in YAML (rather than the default `ollama:qwen3.6`). These are the recommended settings: - -```yaml -evaluations: - judge: - provider: openai - name: RedHatAI/Qwen3.6-35B-A3B-NVFP4 - base_url: http://localhost:8000/v1 # optional, for OpenAI-compatible servers (vLLM, LM Studio, etc.) - temperature: 0.6 - max_tokens: 16384 - extra_body: - top_p: 0.95 - top_k: 20 - min_p: 0 - chat_template_kwargs: - enable_thinking: true -``` - -### Restricting the corpus - -When a database holds documents from several corpora — only some of which a dataset's questions are drawn from — `--filter` restricts every benchmark search to a subset. It takes the same SQL `WHERE` clause as `haiku-rag search --filter`, over document columns (`id`, `uri`, `title`, `created_at`, `updated_at`, `metadata`). Each dataset writes its own URIs: `orb_text` uses bare arXiv ids such as `2407.01528v3`, `hotpotqa` uses page titles. - -```bash -evaluations run orb_text --skip-db --config haiku.rag.s3.yaml \ - --filter "uri LIKE '2407%'" -``` - -If the corpora are distinguished by a tag rather than by URI, attach it at ingest time as document metadata and match it with `LIKE`. `metadata` is stored as a `json.dumps` string, so there is no JSON subfield access — match the serialized key/value, including the space after the colon: - -```bash -evaluations run orb_text --skip-db --filter "metadata LIKE '%\"corpus\": \"orb_text\"%'" -``` - -The clause applies to both benchmark phases — the retrieval benchmark's searches and every search the capability runs during QA — so the two score the same subset. It is recorded as `document_filter` in the run's experiment metadata, so a filtered run is never mistaken for an unfiltered one when comparing results. - -Filtering affects searches only — a run without `--skip-db` still populates the database with the dataset's full corpus. - -## Methodology - -### Retrieval Metrics - -**Mean Average Precision (MAP)** scores ranked retrieval results against the gold `expected_uris`. - -- For each relevant document at position k, calculate precision@k = (relevant docs in top k) / k -- Average Precision (AP) = sum of these precision values / total relevant documents -- MAP is the mean of AP scores across all queries -- Range: 0 to 1. Rewards ranking relevant documents higher -- For single-doc queries this collapses to `1/rank` (i.e. reciprocal rank) - -### QA Accuracy - -`pydantic-evals` coordinates an LLM judge to determine whether the capability's answer is correct. The default judge is `ollama:qwen3.6`, pinned so changes to the capability model don't change the judge underneath. Set `evaluations.judge` in `haiku.rag.yaml` to override (including a custom `base_url` for any OpenAI-compatible endpoint). Accuracy is the fraction of correctly answered questions. - -A dataset that brings its own deterministic evaluator is scored by that evaluator instead, and no judge runs. T²-RAGBench is the only such dataset today, scored by `NumberMatchEvaluator`. - -We picked `qwen3.6` over the previously-pinned `gpt-oss` after a 4-cell calibration (gpt-oss / qwen3.6 as both answerer and judge, with Claude Opus 4.7 as a reference). `qwen3.6` had κ ≥ 0.66 vs the reference on both same-family and cross-family answerers (vs ~0.39–0.55 for `gpt-oss`) and showed no measurable self-preference bias, while `gpt-oss` was ~10 pp more lenient on its own outputs. - -### Citation Retrieval - -Alongside QA accuracy, a second metric scores the URIs the capability registered via the `cite` tool against each dataset's gold `expected_uris`, using the same MAP math as raw retrieval. The score key is `cited_map`. Console output also includes the cite rate (% of cases with at least one citation) and the mean number of citations per case. - -This is computed alongside QA accuracy from the same capability run, no extra invocations. The signal complements raw retrieval: where raw retrieval measures whether the retriever surfaced the gold document at any rank, citation retrieval measures whether the capability grounded its answer on it. - ## Current results -Numbers measured under the current pinned judge (`ollama:qwen3.6`) on a recent `haiku.rag` version. +Numbers below were measured under `Qwen3.6-35B-A3B-NVFP4` as judge, on a recent `haiku.rag` version. The pinned judge is now `qwen3.8`; rows are not re-judged, so compare rows to each other rather than to runs judged by `qwen3.8`. ### OpenRAG Bench (ORB) @@ -260,3 +129,136 @@ The two live arms replay the same 29 conversations (224 turns) and differ only i - Answer pass rate: 185/224 vs 175/224 turns. Of the 18 turns where the arms disagree, 14 pass only compacted and 4 only uncompacted. McNemar exact two-sided p = 0.031. The paired difference is +4.5pp with a Wald 95% CI of +0.8 to +8.1pp, so the honest claim is an improvement of roughly 1 to 8 points, not the point estimate. - Citation MAP, macro-averaged over conversations with 208 of 224 turns eligible (turns with gold passages) in each arm: 0.4174 compacted vs 0.4230 uncompacted. The gold-prefix 0.35 is over 208 of 224 eligible cases. - Refusal precision and recall against the answerability labels (16 UNANSWERABLE turns per arm): compacted 0.33 precision and 0.44 recall (21 refusals), uncompacted 0.23 and 0.31 (22 refusals). Gold-prefix: 0.24 and 0.44 (29 refusals). + +## Methodology + +### Retrieval Metrics + +**Mean Average Precision (MAP)** scores ranked retrieval results against the gold `expected_uris`. + +- For each relevant document at position k, calculate precision@k = (relevant docs in top k) / k +- Average Precision (AP) = sum of these precision values / total relevant documents +- MAP is the mean of AP scores across all queries +- Range: 0 to 1. Rewards ranking relevant documents higher +- For single-doc queries this collapses to `1/rank` (i.e. reciprocal rank) + +### QA Accuracy + +`pydantic-evals` coordinates an LLM judge to determine whether the capability's answer is correct. The default judge is `ollama:qwen3.8`, pinned so changes to the capability model don't change the judge underneath. Set `evaluations.judge` in `haiku.rag.yaml` to override (including a custom `base_url` for any OpenAI-compatible endpoint). Accuracy is the fraction of correctly answered questions. + +A dataset that brings its own deterministic evaluator is scored by that evaluator instead, and no judge runs. T²-RAGBench is the only such dataset today, scored by `NumberMatchEvaluator`. + +`qwen3.8` replaced `qwen3.6` after a 120-case calibration on ORB, stratified 60 pass / 60 fail: agreement 0.950, Cohen's κ 0.900, and in all 6 disagreements it matched or beat `qwen3.6` (4 were `qwen3.6` failing answers that were equivalent in different notation). It emits no reasoning content, so it avoids the thinking spirals that made `qwen3.6` exceed its output budget and drop verdicts. `reasoning_effort` changes its verdicts in 1 case per 120, so the cheaper `low` is pinned. + +Before that, we picked `qwen3.6` over the previously-pinned `gpt-oss` after a 4-cell calibration (gpt-oss / qwen3.6 as both answerer and judge, with Claude Opus 4.7 as a reference). `qwen3.6` had κ ≥ 0.66 vs the reference on both same-family and cross-family answerers (vs ~0.39–0.55 for `gpt-oss`) and showed no measurable self-preference bias, while `gpt-oss` was ~10 pp more lenient on its own outputs. + +### Citation Retrieval + +Alongside QA accuracy, a second metric scores the URIs the capability registered via the `cite` tool against each dataset's gold `expected_uris`, using the same MAP math as raw retrieval. The score key is `cited_map`. Console output also includes the cite rate (% of cases with at least one citation) and the mean number of citations per case. + +This is computed alongside QA accuracy from the same capability run, no extra invocations. The signal complements raw retrieval: where raw retrieval measures whether the retriever surfaced the gold document at any rank, citation retrieval measures whether the capability grounded its answer on it. + +## Running Evaluations + +You can run evaluations with the `evaluations` CLI: + +```bash +evaluations run hotpotqa +evaluations run orb_text +``` + +The evaluation flow is orchestrated with [`pydantic-evals`](https://github.com/pydantic/pydantic-ai/tree/main/libs/pydantic-evals), which we leverage for dataset management, scoring, and report generation. + +### Pre-built Databases + +Building evaluation databases from scratch can take a long time, especially for large datasets like OpenRAG Bench. Pre-built databases are available on HuggingFace: + +```bash +# Download a specific dataset +evaluations download hotpotqa + +# Download all datasets +evaluations download all + +# Force re-download (overwrite existing) +evaluations download hotpotqa --force +``` + +Active datasets: + +| Dataset | Size | +|---------|------| +| `orb_text` — OpenRAG Bench, text embedder (`qwen3-embedding:4b`) with VLM picture descriptions baked into chunk content | ~18 GB | +| `orb_multimodal` — OpenRAG Bench, multimodal embedder (`qwen3-vl-embedding-8b`); picture vectors live in the same space as text for cross-modal retrieval | ~16 GB | +| `orb_multimodal_nemotron` — OpenRAG Bench, multimodal embedder (`nvidia/llama-nemotron-embed-vl-1b-v2`), the embedder behind the published headline results | ~16 GB | +| `t2_finqa` — T²-RAGBench (FinQA) financial QA, text embedder (`qwen3-embedding:4b`); scored by exact numeric match, run with `--target analysis-capability` | ~2 GB | +| `hotpotqa` — HotpotQA multi-hop QA over Wikipedia paragraphs, text embedder (`qwen3-embedding:4b`) | ~1.5 GB | +| `mtrag_clapnq` — MTRAG multi-turn RAG, ClapNQ (Wikipedia) passages, text embedder (`qwen3-embedding:4b`); also serves the `mtrag_clapnq_rewrite`, `mtrag_clapnq_live` and `mtrag_clapnq_live_uncompacted` keys | ~2.8 GB | + +After downloading, run benchmarks with `--skip-db`. Each database is built with a specific embedder, so pass its reference config from `evaluations/configs/` (a database only opens against a config whose embedder matches): + +```bash +evaluations run orb_multimodal_nemotron --skip-db --config configs/orb_multimodal_nemotron.yaml +``` + +The configs use `vllm` as the model host. Point `base_url` at your own OpenAI-compatible endpoints to reproduce the numbers. + +### Configuration + +The benchmark script accepts several options: + +```bash +evaluations run hotpotqa --config /path/to/haiku.rag.yaml --db /path/to/custom.lancedb +``` + +**Options:** + +- `--config PATH` - Specify a custom `haiku.rag.yaml` configuration file +- `--db PATH` - Override the database path (default: platform-specific user data directory) +- `--skip-db` - Skip updating the evaluation database +- `--skip-retrieval` - Skip retrieval benchmark +- `--skip-qa` - Skip QA benchmark +- `--limit N` - Limit number of test cases +- `--name NAME` - Override the evaluation name +- `--target {rag-capability,analysis-capability}` - Choose which [capability](capabilities/index.md) to benchmark end-to-end (default: `rag-capability`). The target names remain stable dataset identifiers. +- `--capability-model PROVIDER:NAME` - Override the capability model independently from the judge (default: `config.qa.model`, or `config.analysis.model` when set for `--target analysis-capability`). +- `--filter CLAUSE` / `-f CLAUSE` - Restrict every benchmark search to a subset of the database (see [Restricting the corpus](#restricting-the-corpus)). + +If no config file is specified, the script searches standard locations: `./haiku.rag.yaml`, user config directory, then falls back to defaults. + +To pin the LLM judge in YAML (rather than the default `ollama:qwen3.8`). These are the recommended settings: + +```yaml +evaluations: + judge: + provider: openai + name: Inferact/Qwen3.8-27B-NVFP4 + base_url: http://localhost:8000/v1 # optional, for OpenAI-compatible servers (vLLM, LM Studio, etc.) + temperature: 0.6 + max_tokens: 16384 + extra_body: + top_p: 0.95 + top_k: 20 + min_p: 0 + chat_template_kwargs: + reasoning_effort: low # qwen3.8: low | medium | xhigh (default) +``` + +### Restricting the corpus + +When a database holds documents from several corpora — only some of which a dataset's questions are drawn from — `--filter` restricts every benchmark search to a subset. It takes the same SQL `WHERE` clause as `haiku-rag search --filter`, over document columns (`id`, `uri`, `title`, `created_at`, `updated_at`, `metadata`). Each dataset writes its own URIs: `orb_text` uses bare arXiv ids such as `2407.01528v3`, `hotpotqa` uses page titles. + +```bash +evaluations run orb_text --skip-db --config haiku.rag.s3.yaml \ + --filter "uri LIKE '2407%'" +``` + +If the corpora are distinguished by a tag rather than by URI, attach it at ingest time as document metadata and match it with `LIKE`. `metadata` is stored as a `json.dumps` string, so there is no JSON subfield access — match the serialized key/value, including the space after the colon: + +```bash +evaluations run orb_text --skip-db --filter "metadata LIKE '%\"corpus\": \"orb_text\"%'" +``` + +The clause applies to both benchmark phases — the retrieval benchmark's searches and every search the capability runs during QA — so the two score the same subset. It is recorded as `document_filter` in the run's experiment metadata, so a filtered run is never mistaken for an unfiltered one when comparing results. + +Filtering affects searches only — a run without `--skip-db` still populates the database with the dataset's full corpus. diff --git a/docs/configuration/storage.md b/docs/configuration/storage.md index 4d86b215..57ab3aa4 100644 --- a/docs/configuration/storage.md +++ b/docs/configuration/storage.md @@ -122,6 +122,18 @@ The `storage_options` keys are case-insensitive and passed directly to the under **Note:** Table optimization is automatically handled by LanceDB Cloud (`db://` URIs) and is disabled for better performance. For object storage backends (S3, Azure, GCS), optimization and vector indexing are still performed normally. +### Caching and Read Consistency + +```yaml +lancedb: + read_consistency_interval_seconds: 30 # null to never re-check + index_cache_size_bytes: 536870912 # null for the LanceDB default + metadata_cache_size_bytes: 268435456 +``` + +- **read_consistency_interval_seconds**: how often a connection checks for writes from another process. `null` never checks, so a long-lived reader never sees the ingester's writes. `0` checks on every read. +- **index_cache_size_bytes** / **metadata_cache_size_bytes**: sizes for the caches held by the LanceDB session, which is shared across every connection in the process. The first vector query loads the index into it, so on object storage the cache is what stops the next connection refetching it. Size it for the total set of indexes a process keeps warm, against the memory available to it. + ### Deployment Pattern: One Writer, Many Readers LanceDB on S3 supports **exactly one writer + N readers** per database URI. Multiple writers against the same URI can race on the manifest commit and corrupt state. This is a LanceDB property, not something `haiku.rag` enforces. diff --git a/docs/index.md b/docs/index.md index d3e9886b..421df9c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,37 @@ --- title: haiku.rag +description: Local-first agentic RAG. Index PDFs, web pages, and whole directories, then ask questions and get answers cited to page numbers and section headings. Hybrid search, reranking, and multimodal retrieval on embedded LanceDB. --- + +haiku.rag indexes PDFs, web pages, and whole directories, retrieves with hybrid search, and answers with citations down to the page number and section heading. It runs on an embedded database with open models, so your documents stay on your machine and there is no server to operate. + +```bash +uv pip install haiku.rag + +haiku-rag init +haiku-rag add-src ~/Documents/some-paper.pdf +haiku-rag ask "what does it conclude?" +``` + +[Quickstart](tutorial.md) covers provider setup and the first ingestion. + +## Why haiku.rag + +**Answers you can check.** Every answer carries citations with page numbers and section headings. Visual grounding shows the cited chunk highlighted on the original page image. Optional capabilities require an answer to declare what grounds it, including declaring that nothing does. + +**Local-first, no server.** Embedded [LanceDB](https://lancedb.com/) and open models through [Ollama](https://ollama.com/) by default. No database to run and no API keys required. The same code runs against S3, GCS, Azure, LanceDB Cloud, or any provider Pydantic AI supports. + +**Built for agents.** Native [Pydantic AI](https://ai.pydantic.dev/) capabilities compose into your own agents. An [MCP server](mcp.md) exposes the same database to Claude Desktop and other assistants. The analysis capability runs sandboxed Python across documents for questions that need computation rather than retrieval. + +**Measured, not asserted.** Retrieval and answer quality are tracked against public benchmarks with runnable configs. See [Benchmarks](benchmarks.md). + +## Start here + +- [Quickstart](tutorial.md): install, index, chat. +- [Overview](overview.md): what haiku.rag does, end to end. +- [Capabilities](capabilities/index.md): native RAG and analysis capabilities for Pydantic AI agents. +- [Python API](python.md): use haiku.rag from code. +- [MCP server](mcp.md): expose haiku.rag to Claude Desktop or other AI assistants. +- [Configuration](configuration/index.md): every setting. + +MIT licensed. Source on [GitHub](https://github.com/ggozad/haiku.rag). diff --git a/evaluations/README.md b/evaluations/README.md index ff1c6f7d..341fb3a6 100644 --- a/evaluations/README.md +++ b/evaluations/README.md @@ -1,4 +1,4 @@ -# Haiku RAG - Evaluations +# haiku.rag - Evaluations Internal benchmarking and evaluation scripts for haiku.rag. diff --git a/evaluations/configs/hotpotqa.yaml b/evaluations/configs/hotpotqa.yaml index 606ae86d..84aca56d 100644 --- a/evaluations/configs/hotpotqa.yaml +++ b/evaluations/configs/hotpotqa.yaml @@ -25,8 +25,8 @@ qa: evaluations: judge: provider: openai - name: RedHatAI/Qwen3.6-35B-A3B-NVFP4 - base_url: http://vllm:11430/v1 + name: Inferact/Qwen3.8-27B-NVFP4 + base_url: http://vllm:11439/v1 temperature: 0.6 max_tokens: 16384 extra_body: @@ -34,4 +34,4 @@ evaluations: top_k: 20 min_p: 0 chat_template_kwargs: - enable_thinking: true + reasoning_effort: low diff --git a/evaluations/configs/mtrag_clapnq.yaml b/evaluations/configs/mtrag_clapnq.yaml index 7fa34d84..5dbac876 100644 --- a/evaluations/configs/mtrag_clapnq.yaml +++ b/evaluations/configs/mtrag_clapnq.yaml @@ -44,8 +44,8 @@ qa: evaluations: judge: provider: openai - name: RedHatAI/Qwen3.6-35B-A3B-NVFP4 - base_url: http://vllm:11430/v1 + name: Inferact/Qwen3.8-27B-NVFP4 + base_url: http://vllm:11439/v1 temperature: 0.6 max_tokens: 16384 extra_body: @@ -53,4 +53,4 @@ evaluations: top_k: 20 min_p: 0 chat_template_kwargs: - enable_thinking: true + reasoning_effort: low diff --git a/evaluations/configs/orb_multimodal.yaml b/evaluations/configs/orb_multimodal.yaml index 2dad063e..b868fb3c 100644 --- a/evaluations/configs/orb_multimodal.yaml +++ b/evaluations/configs/orb_multimodal.yaml @@ -29,8 +29,8 @@ qa: evaluations: judge: provider: openai - name: RedHatAI/Qwen3.6-35B-A3B-NVFP4 - base_url: http://vllm:11430/v1 + name: Inferact/Qwen3.8-27B-NVFP4 + base_url: http://vllm:11439/v1 temperature: 0.6 max_tokens: 16384 extra_body: @@ -38,4 +38,4 @@ evaluations: top_k: 20 min_p: 0 chat_template_kwargs: - enable_thinking: true + reasoning_effort: low diff --git a/evaluations/configs/orb_multimodal_nemotron.yaml b/evaluations/configs/orb_multimodal_nemotron.yaml index eca56502..99e3899b 100644 --- a/evaluations/configs/orb_multimodal_nemotron.yaml +++ b/evaluations/configs/orb_multimodal_nemotron.yaml @@ -30,8 +30,8 @@ qa: evaluations: judge: provider: openai - name: RedHatAI/Qwen3.6-35B-A3B-NVFP4 - base_url: http://vllm:11430/v1 + name: Inferact/Qwen3.8-27B-NVFP4 + base_url: http://vllm:11439/v1 temperature: 0.6 max_tokens: 16384 extra_body: @@ -39,4 +39,4 @@ evaluations: top_k: 20 min_p: 0 chat_template_kwargs: - enable_thinking: true + reasoning_effort: low diff --git a/evaluations/configs/orb_text.yaml b/evaluations/configs/orb_text.yaml index 2475bac8..18f87cb9 100644 --- a/evaluations/configs/orb_text.yaml +++ b/evaluations/configs/orb_text.yaml @@ -31,8 +31,8 @@ qa: evaluations: judge: provider: openai - name: RedHatAI/Qwen3.6-35B-A3B-NVFP4 - base_url: http://vllm:11430/v1 + name: Inferact/Qwen3.8-27B-NVFP4 + base_url: http://vllm:11439/v1 temperature: 0.6 max_tokens: 16384 extra_body: @@ -40,4 +40,4 @@ evaluations: top_k: 20 min_p: 0 chat_template_kwargs: - enable_thinking: true + reasoning_effort: low diff --git a/evaluations/evaluations/benchmark.py b/evaluations/evaluations/benchmark.py index cb9f534c..0eb34507 100644 --- a/evaluations/evaluations/benchmark.py +++ b/evaluations/evaluations/benchmark.py @@ -47,10 +47,11 @@ TARGETS: tuple[Target, ...] = ("rag-capability", "analysis-capability") # Sampling follows Qwen's recommendation for thinking mode; its model cards # forbid greedy decoding. Only the keys ollama honours are set: it silently # ignores `top_k`, `min_p` and `chat_template_kwargs`. The vLLM reference -# configs under `evaluations/configs/` carry those too. +# configs under `evaluations/configs/` carry those too, plus +# `reasoning_effort`, which qwen3.8 reads from `chat_template_kwargs`. DEFAULT_JUDGE_MODEL = ModelConfig( provider="ollama", - name="qwen3.6", + name="qwen3.8", temperature=0.6, max_tokens=16384, extra_body={"top_p": 0.95}, diff --git a/evaluations/pyproject.toml b/evaluations/pyproject.toml index 28deceb0..158a5362 100644 --- a/evaluations/pyproject.toml +++ b/evaluations/pyproject.toml @@ -2,7 +2,7 @@ name = "haiku.rag-evals" description = "Benchmarking and evaluation scripts for haiku.rag" -version = "0.74.0" +version = "0.75.0" authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }] license = { text = "MIT" } requires-python = ">=3.12" diff --git a/evaluations/tests/test_benchmark.py b/evaluations/tests/test_benchmark.py index 16b43013..13a4e20a 100644 --- a/evaluations/tests/test_benchmark.py +++ b/evaluations/tests/test_benchmark.py @@ -677,6 +677,7 @@ class TestRunQaBenchmarkJudgeModel: from evaluations.benchmark import DEFAULT_JUDGE_MODEL assert DEFAULT_JUDGE_MODEL.temperature == 0.6 + assert DEFAULT_JUDGE_MODEL.name == "qwen3.8" assert DEFAULT_JUDGE_MODEL.max_tokens == 16384 assert DEFAULT_JUDGE_MODEL.extra_body == {"top_p": 0.95} diff --git a/evaluations/tests/test_reference_configs.py b/evaluations/tests/test_reference_configs.py index b25f762e..142ccb34 100644 --- a/evaluations/tests/test_reference_configs.py +++ b/evaluations/tests/test_reference_configs.py @@ -15,7 +15,7 @@ PINNED_JUDGE_SAMPLING = { "top_p": 0.95, "top_k": 20, "min_p": 0, - "chat_template_kwargs": {"enable_thinking": True}, + "chat_template_kwargs": {"reasoning_effort": "low"}, }, } diff --git a/haiku_rag_slim/haiku/rag/capabilities/_base.py b/haiku_rag_slim/haiku/rag/capabilities/_base.py index b9f0ff60..2f0bea64 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/_base.py +++ b/haiku_rag_slim/haiku/rag/capabilities/_base.py @@ -129,6 +129,9 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]): state: StateT | None = field(default=None, repr=False) outer_state: dict[str, Any] | None = field(default=None, repr=False) rag: HaikuRAG | None = field(default=None, repr=False) + """A connection this capability opened, and must close.""" + borrowed_rag: HaikuRAG | None = field(default=None, repr=False) + """A caller's connection, reused and never closed here.""" rag_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) resource_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) search_count: int = field(default=0, repr=False) @@ -347,6 +350,8 @@ class RAGCapabilityBase[StateT: BaseModel](AbstractCapability[Any]): raise error async def _ensure_rag(self) -> HaikuRAG: + if self.borrowed_rag is not None: + return self.borrowed_rag if self.rag is None: async with self.resource_lock: if self.rag is None: diff --git a/haiku_rag_slim/haiku/rag/capabilities/analysis.py b/haiku_rag_slim/haiku/rag/capabilities/analysis.py index e2de8868..b75fa06c 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/analysis.py +++ b/haiku_rag_slim/haiku/rag/capabilities/analysis.py @@ -1,13 +1,16 @@ from dataclasses import dataclass, field from functools import cache from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field from pydantic_ai import RunContext, ToolFailed from pydantic_ai.messages import ToolReturn from pydantic_ai.toolsets import FunctionToolset +if TYPE_CHECKING: + from haiku.rag.client import HaikuRAG + from haiku.rag.capabilities._base import ( CodeExecutionEntry, RAGCapabilityBase, @@ -162,6 +165,7 @@ def create_capability( config: AppConfig | None = None, *, defer_loading: bool = True, + rag: "HaikuRAG | None" = None, request_limit: int | None = 30, vision: bool | None = None, ) -> AnalysisCapability: @@ -180,6 +184,7 @@ def create_capability( return AnalysisCapability( db_path=resolve_db_path(db_path, config), config=config, + borrowed_rag=rag, state_type=AnalysisState, state_namespace=STATE_NAMESPACE, instruction_text=instructions(), diff --git a/haiku_rag_slim/haiku/rag/capabilities/rag.py b/haiku_rag_slim/haiku/rag/capabilities/rag.py index 79216a87..f8ea44e9 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/rag.py +++ b/haiku_rag_slim/haiku/rag/capabilities/rag.py @@ -1,13 +1,16 @@ from dataclasses import dataclass from functools import cache from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field from pydantic_ai import RunContext from pydantic_ai.messages import ToolReturn from pydantic_ai.toolsets import FunctionToolset +if TYPE_CHECKING: + from haiku.rag.client import HaikuRAG + from haiku.rag.capabilities._base import ( RAGCapabilityBase, resolve_db_path, @@ -72,6 +75,7 @@ def create_capability( config: AppConfig | None = None, *, defer_loading: bool = True, + rag: "HaikuRAG | None" = None, request_limit: int | None = 20, vision: bool | None = None, ) -> RAGCapability: @@ -88,6 +92,7 @@ def create_capability( return RAGCapability( db_path=resolve_db_path(db_path, config), config=config, + borrowed_rag=rag, state_type=RAGState, state_namespace=STATE_NAMESPACE, instruction_text=instructions(), diff --git a/haiku_rag_slim/haiku/rag/client/agents.py b/haiku_rag_slim/haiku/rag/client/agents.py index 9a739f74..c85fa1ab 100644 --- a/haiku_rag_slim/haiku/rag/client/agents.py +++ b/haiku_rag_slim/haiku/rag/client/agents.py @@ -63,6 +63,7 @@ async def ask( capability = create_capability( db_path=client.store.db_path, config=client._config, + rag=client, defer_loading=False, ) deps = _AgentDeps( @@ -115,6 +116,7 @@ async def analyze( capability = create_capability( db_path=client.store.db_path, config=client._config, + rag=client, defer_loading=False, ) deps = _AgentDeps( diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 0bfbfec7..2fa58a8e 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -62,10 +62,21 @@ class StorageConfig(BaseModel): class LanceDBConfig(BaseModel): + """LanceDB connection settings. + + read_consistency_interval_seconds bounds how stale a reader may be. None + never re-checks, so a long-lived reader never sees another process's writes. + The cache sizes are per process, since the session is shared across + connections. + """ + uri: str = "" api_key: str = "" region: str = "" storage_options: dict[str, str] = Field(default_factory=dict) + read_consistency_interval_seconds: float | None = Field(default=30, ge=0) + index_cache_size_bytes: int | None = Field(default=None, ge=0) + metadata_cache_size_bytes: int | None = Field(default=None, ge=0) class EmbeddingsConfig(BaseModel): diff --git a/haiku_rag_slim/haiku/rag/mcp.py b/haiku_rag_slim/haiku/rag/mcp.py index 30c2ed11..a8a44b50 100644 --- a/haiku_rag_slim/haiku/rag/mcp.py +++ b/haiku_rag_slim/haiku/rag/mcp.py @@ -1,3 +1,6 @@ +import asyncio +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from typing import Any @@ -28,7 +31,42 @@ def create_mcp_server( config: Configuration to use. read_only: If True, write tools (add_document_*, delete_document) are not registered. """ - mcp = FastMCP("haiku-rag") + client: HaikuRAG | None = None + stack = AsyncExitStack() + client_lock = asyncio.Lock() + + async def _client() -> HaikuRAG: + """The server's client, opened once. + + Opening cost is per connection, and on object storage the first vector + query loads the index into the session cache, so a client per tool call + pays that repeatedly. + """ + nonlocal client + async with client_lock: + if client is None: + client = await stack.enter_async_context( + HaikuRAG(db_path, config=config, read_only=read_only) + ) + return client + + @asynccontextmanager + async def lifespan(_server: FastMCP) -> AsyncIterator[None]: + # Open eagerly so an unopenable database fails startup rather than + # every tool call. + nonlocal client + await _client() + try: + yield + finally: + # The lifespan can be re-entered; without the reset the next cycle + # hands out the closed client, including when aclose itself fails. + try: + await stack.aclose() + finally: + client = None + + mcp = FastMCP("haiku-rag", lifespan=lifespan) # Write tools - only registered when not in read-only mode if not read_only: @@ -41,14 +79,14 @@ def create_mcp_server( ) -> str | None: """Add a document to the RAG system from a file path.""" try: - async with HaikuRAG(db_path, config=config) as rag: - result = await rag.create_document_from_source( - Path(file_path), title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents (directories) - if isinstance(result, list): - return result[0].id if result else None - return result.id + rag = await _client() + result = await rag.create_document_from_source( + Path(file_path), title=title, metadata=metadata or {} + ) + # Handle both single document and list of documents (directories) + if isinstance(result, list): + return result[0].id if result else None + return result.id except Exception: return None @@ -58,14 +96,14 @@ def create_mcp_server( ) -> str | None: """Add a document to the RAG system from a URL.""" try: - async with HaikuRAG(db_path, config=config) as rag: - result = await rag.create_document_from_source( - url, title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents - if isinstance(result, list): - return result[0].id if result else None - return result.id + rag = await _client() + result = await rag.create_document_from_source( + url, title=title, metadata=metadata or {} + ) + # Handle both single document and list of documents + if isinstance(result, list): + return result[0].id if result else None + return result.id except Exception: return None @@ -78,11 +116,11 @@ def create_mcp_server( ) -> str | None: """Add a document to the RAG system from text content.""" try: - async with HaikuRAG(db_path, config=config) as rag: - document = await rag.create_document( - content, uri, title=title, metadata=metadata or {} - ) - return document.id + rag = await _client() + document = await rag.create_document( + content, uri, title=title, metadata=metadata or {} + ) + return document.id except Exception: return None @@ -90,10 +128,8 @@ def create_mcp_server( async def delete_document(document_id: str) -> bool: """Delete a document by its ID.""" try: - async with HaikuRAG( - db_path, config=config, skip_validation=True - ) as rag: - return await rag.delete_document(document_id) + rag = await _client() + return await rag.delete_document(document_id) except Exception: return False @@ -110,10 +146,8 @@ def create_mcp_server( response (smaller JSON payload for plain-text consumers). """ try: - async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: - return await rag.search( - query, limit=limit, include_images=include_images - ) + rag = await _client() + return await rag.search(query, limit=limit, include_images=include_images) except Exception: return [] @@ -145,10 +179,8 @@ def create_mcp_server( except Exception: return [] try: - async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: - return await rag.search( - raw, limit=limit, include_images=include_images - ) + rag = await _client() + return await rag.search(raw, limit=limit, include_images=include_images) except Exception: return [] @@ -156,8 +188,8 @@ def create_mcp_server( async def get_document(document_id: str) -> Document | None: """Get a document by its ID.""" try: - async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: - return await rag.get_document_by_id(document_id) + rag = await _client() + return await rag.get_document_by_id(document_id) except Exception: return None @@ -175,18 +207,18 @@ def create_mcp_server( filter: Optional SQL WHERE clause to filter documents. """ try: - async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: - documents = await rag.list_documents(limit, offset, filter) + rag = await _client() + documents = await rag.list_documents(limit, offset, filter) - return [ - DocumentInfo( - id=doc.id, - title=doc.title or "Untitled", - uri=doc.uri or "", - created=doc.created_at.strftime("%Y-%m-%d"), - ) - for doc in documents - ] + return [ + DocumentInfo( + id=doc.id, + title=doc.title or "Untitled", + uri=doc.uri or "", + created=doc.created_at.strftime("%Y-%m-%d"), + ) + for doc in documents + ] except Exception: return [] @@ -209,11 +241,11 @@ def create_mcp_server( """ try: images = _decode_images(images_base64) - async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: - answer, citations = await rag.ask(question, images=images) - if cite and citations: - answer += "\n\n" + format_citations(citations) - return answer + rag = await _client() + answer, citations = await rag.ask(question, images=images) + if cite and citations: + answer += "\n\n" + format_citations(citations) + return answer except Exception as e: return f"Error answering question: {e!s}" @@ -240,9 +272,9 @@ def create_mcp_server( """ try: images = _decode_images(images_base64) - async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: - result = await rag.analyze(question, filter=filter, images=images) - return result.answer + rag = await _client() + result = await rag.analyze(question, filter=filter, images=images) + return result.answer except Exception as e: return f"Error running analysis capability: {e!s}" diff --git a/haiku_rag_slim/haiku/rag/store/engine.py b/haiku_rag_slim/haiku/rag/store/engine.py index 50d4a73a..08867fa6 100644 --- a/haiku_rag_slim/haiku/rag/store/engine.py +++ b/haiku_rag_slim/haiku/rag/store/engine.py @@ -12,7 +12,7 @@ from uuid import uuid4 import lancedb import pyarrow as pa -from lancedb.index import FTS, BTree, IvfPq +from lancedb.index import FTS, Bitmap, BTree, IvfPq from lancedb.pydantic import LanceModel, Vector from packaging.version import parse from pydantic import BaseModel, Field @@ -55,25 +55,56 @@ class ConnectionMode(Enum): return ConnectionMode.OBJECT_STORAGE +_sessions: dict[tuple[int | None, int | None], lancedb.Session] = {} + + +def _session(config: AppConfig) -> lancedb.Session: + """The process's session for these cache sizes. + + Sessions hold the index and metadata caches. Sharing one across connections + is what keeps a cached index from being refetched per connection, which on + object storage is the dominant cost of the first query. + """ + key = ( + config.lancedb.index_cache_size_bytes, + config.lancedb.metadata_cache_size_bytes, + ) + if key not in _sessions: + kwargs = {} + if key[0] is not None: + kwargs["index_cache_size_bytes"] = key[0] + if key[1] is not None: + kwargs["metadata_cache_size_bytes"] = key[1] + _sessions[key] = lancedb.Session(**kwargs) + return _sessions[key] + + async def connect_lancedb( config: AppConfig, db_path: Path | None = None ) -> lancedb.AsyncConnection: + interval = config.lancedb.read_consistency_interval_seconds + kwargs: dict[str, Any] = { + "session": _session(config), + "read_consistency_interval": ( + timedelta(seconds=interval) if interval is not None else None + ), + } mode = ConnectionMode.from_config(config) if mode == ConnectionMode.CLOUD: return await lancedb.connect_async( uri=config.lancedb.uri, api_key=config.lancedb.api_key, region=config.lancedb.region, + **kwargs, ) elif mode == ConnectionMode.OBJECT_STORAGE: - kwargs: dict[str, Any] = {"uri": config.lancedb.uri} if config.lancedb.storage_options: kwargs["storage_options"] = config.lancedb.storage_options - return await lancedb.connect_async(**kwargs) + return await lancedb.connect_async(uri=config.lancedb.uri, **kwargs) else: if db_path is None: raise ValueError("No lancedb.uri configured and no db_path provided") - return await lancedb.connect_async(db_path.absolute()) + return await lancedb.connect_async(db_path.absolute(), **kwargs) class DocumentRecord(LanceModel): @@ -176,6 +207,64 @@ def get_document_items_arrow_schema() -> pa.Schema: return pa.schema(fields) +def _stored_vector_dim(settings: dict) -> int | None: + """The vector dimension a database's chunks were written at.""" + return settings.get("embeddings", {}).get("model", {}).get("vector_dim") + + +def index_specs(table_name: str) -> list[tuple[str, Bitmap | BTree | FTS]]: + """The index set each table carries.""" + match table_name: + case "documents": + return [("id", BTree())] + case "document_meta": + return [("id", BTree()), ("uri", BTree())] + case "chunks": + return [ + # Positions and stop words are required for phrase queries. + ("content_fts", FTS(with_position=True, remove_stop_words=False)), + ("id", BTree()), + ("document_id", BTree()), + ] + case "document_items": + return [ + ("document_id", BTree()), + ("position", BTree()), + ("self_ref", BTree()), + ("label", Bitmap()), + ] + case _: + return [] + + +async def ensure_indexes(table: lancedb.AsyncTable, table_name: str) -> list[str]: + """Create any declared index missing from a column. Returns the columns indexed. + + Matches on index type, not column coverage, so a BTree does not satisfy a + declared Bitmap. Never drops or converts an index it did not declare. + Re-creating is not free: `create_index(replace=True)` rebuilds. + """ + covering: dict[str, set[str]] = {} + for index in await table.list_indices(): + for column in index.columns: + covering.setdefault(column, set()).add(index.index_type) + + applied: list[str] = [] + for column, config in index_specs(table_name): + declared = type(config).__name__ + present = covering.get(column, set()) + if declared in present: + continue + if present: + logger.info( + f"Adding {declared} index on {table_name}.{column}, which carries " + f"{', '.join(sorted(present))}" + ) + await table.create_index(column, config=config, replace=True) + applied.append(column) + return applied + + class SettingsRecord(LanceModel): id: str = Field(default="settings") settings: str = Field(default="{}") @@ -449,29 +538,27 @@ class Store: self._config, self.db_path ) - # For remote stores (and as a safety net for local paths that exist but - # have no tables — e.g. a previously failed init), detect new DB by - # checking whether any tables exist. - is_new_db = self._is_new_db - if not is_new_db: - existing_tables = (await self.db.list_tables()).tables - if not existing_tables: - is_new_db = True + # Read once and thread onward: on object storage each of these is a + # round trip. A local path that exists with no tables is a failed init, + # so treat it as new. + existing_tables = (await self.db.list_tables()).tables + is_new_db = self._is_new_db or not existing_tables - # For existing databases, read stored vector dimension to create ChunkRecord - # that can read existing chunks. For new databases, use config's dimension. - stored_vector_dim = None - if not is_new_db: - stored_vector_dim = await self._get_stored_vector_dim() + stored_settings: dict = {} + if not is_new_db and "settings" in existing_tables: + self.settings_table = await self.db.open_table("settings") + stored_settings = await self._read_stored_settings() - # Create ChunkRecord with stored dimension (for reading) or config dimension (for new DB) + # An existing database's chunks can only be read with the dimension they + # were written at. + stored_vector_dim = _stored_vector_dim(stored_settings) chunk_vector_dim = stored_vector_dim or self.embedder._vector_dim self.ChunkRecord: type[ChunkRecordBase] = create_chunk_model(chunk_vector_dim) # Initialize tables (creates them if they don't exist). For an existing # DB this raises MigrationRequiredError up front when migrations are # pending, before creating any newly-introduced table. - await self._init_tables(is_new_db) + await self._init_tables(is_new_db, existing_tables, stored_settings) # Set version for new databases. if is_new_db and not self._read_only: @@ -479,7 +566,7 @@ class Store: # Validate config compatibility after connection is established if not self._skip_validation: - await self._validate_configuration() + await self._validate_configuration(stored_settings) async def __aenter__(self): # If _initialize connects to LanceDB but then fails (e.g. migration @@ -501,33 +588,26 @@ class Store: """Whether the store is in read-only mode.""" return self._read_only - async def _get_stored_vector_dim(self) -> int | None: - """Read the stored vector dimension from the settings table. + async def _read_stored_settings(self) -> dict: + """The stored settings blob, or {} if it is absent or not a JSON object. - Returns: - The stored vector dimension, or None if not found. + Only decoding failures are tolerated. A storage failure must propagate: + read as empty settings it would look like version 0.0.0, and the + migration check would declare every migration pending. """ + rows = ( + await self.settings_table.query() + .where("id = 'settings'") + .limit(1) + .to_arrow() + ).to_pylist() + if not rows or not rows[0].get("settings"): + return {} try: - existing_tables = (await self.db.list_tables()).tables - if "settings" not in existing_tables: - return None - - settings_table = await self.db.open_table("settings") - rows = ( - await settings_table.query() - .where("id = 'settings'") - .limit(1) - .to_arrow() - ).to_pylist() - if not rows or not rows[0].get("settings"): - return None - - settings = json.loads(rows[0]["settings"]) - embeddings = settings.get("embeddings", {}) - model = embeddings.get("model", {}) - return model.get("vector_dim") - except Exception: - return None + decoded = json.loads(rows[0]["settings"]) + except (json.JSONDecodeError, TypeError): + return {} + return decoded if isinstance(decoded, dict) else {} def _assert_writable(self) -> None: """Raise ReadOnlyError if the store is in read-only mode.""" @@ -658,16 +738,19 @@ class Store: except Exception as e: logger.warning(f"Could not create vector index: {e}") - async def _validate_configuration(self) -> None: + async def _validate_configuration( + self, stored_settings: dict | None = None + ) -> None: """Validate that the configuration is compatible with the database.""" from haiku.rag.store.repositories.settings import SettingsRepository settings_repo = SettingsRepository(self) - await settings_repo.validate_config_compatibility() + await settings_repo.validate_config_compatibility(stored_settings) - async def _init_tables(self, is_new_db: bool): + async def _init_tables( + self, is_new_db: bool, existing_tables: list[str], stored_settings: dict + ): """Initialize database tables (create if they don't exist).""" - existing_tables = (await self.db.list_tables()).tables # Surface pending migrations BEFORE creating any newly-introduced table. # Otherwise opening a legacy DB would either mutate it (creating an empty @@ -679,8 +762,7 @@ class Store: and not self._skip_migration_check and "settings" in existing_tables ): - self.settings_table = await self.db.open_table("settings") - await self._check_migrations() + await self._check_migrations(stored_settings.get("version", "0.0.0")) missing_tables = set(REQUIRED_TABLES) - set(existing_tables) @@ -697,22 +779,17 @@ class Store: self.documents_table = await self.db.create_table( "documents", schema=get_documents_arrow_schema() ) + await ensure_indexes(self.documents_table, "documents") # Create or open document_meta table (mutable attributes kept out of the - # blob-bearing documents row). Indexed by document_id and uri — both are - # hot look-up keys (get_by_id, get_by_uri). + # blob-bearing documents row). if "document_meta" in existing_tables: self.document_meta_table = await self.db.open_table("document_meta") else: self.document_meta_table = await self.db.create_table( "document_meta", schema=DocumentMetaRecord ) - await self.document_meta_table.create_index( - "id", config=BTree(), replace=True - ) - await self.document_meta_table.create_index( - "uri", config=BTree(), replace=True - ) + await ensure_indexes(self.document_meta_table, "document_meta") # Create or open chunks table if "chunks" in existing_tables: @@ -721,12 +798,7 @@ class Store: self.chunks_table = await self.db.create_table( "chunks", schema=self.ChunkRecord ) - # Create FTS index on content_fts (contextualized content) for better search - await self.chunks_table.create_index( - "content_fts", - config=FTS(with_position=True, remove_stop_words=False), - replace=True, - ) + await ensure_indexes(self.chunks_table, "chunks") # Create or open document_items table if "document_items" in existing_tables: @@ -735,20 +807,10 @@ class Store: self.document_items_table = await self.db.create_table( "document_items", schema=get_document_items_arrow_schema() ) - await self.document_items_table.create_index( - "document_id", config=BTree(), replace=True - ) - await self.document_items_table.create_index( - "position", config=BTree(), replace=True - ) - await self.document_items_table.create_index( - "self_ref", config=BTree(), replace=True - ) + await ensure_indexes(self.document_items_table, "document_items") - # Create or open settings table - if "settings" in existing_tables: - self.settings_table = await self.db.open_table("settings") - else: + # _initialize opened the settings table when the database had one. + if "settings" not in existing_tables: self.settings_table = await self.db.create_table( "settings", schema=SettingsRecord ) @@ -762,7 +824,7 @@ class Store: """Set the initial version for a new database.""" await self.set_haiku_version(metadata.version("haiku.rag-slim")) - async def _check_migrations(self) -> None: + async def _check_migrations(self, db_version: str) -> None: """Raise if migrations are pending. Opening never writes the version. Raises: @@ -771,7 +833,6 @@ class Store: from haiku.rag.store.upgrades import get_pending_upgrades current_version = metadata.version("haiku.rag-slim") - db_version = await self.get_haiku_version() pending = get_pending_upgrades(db_version) @@ -872,13 +933,7 @@ class Store: self.chunks_table = await self.db.create_table( "chunks", schema=self.ChunkRecord ) - - # Create FTS index on content_fts (contextualized content) for better search - await self.chunks_table.create_index( - "content_fts", - config=FTS(with_position=True, remove_stop_words=False), - replace=True, - ) + await ensure_indexes(self.chunks_table, "chunks") def close(self): """Close the database connection.""" diff --git a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py index 09cc3498..ecfe9f49 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from lancedb.index import FTS from lancedb.rerankers import RRFReranker -from haiku.rag.store.engine import Store, query_to_pydantic +from haiku.rag.store.engine import Store, ensure_indexes, query_to_pydantic from haiku.rag.store.models.chunk import Chunk, SearchType from haiku.rag.utils import escape_sql_string @@ -187,12 +187,7 @@ class ChunkRepository: self.store.chunks_table = await self.store.db.create_table( "chunks", schema=self.store.ChunkRecord ) - # Create FTS index on content_fts (contextualized content) for better search - await self.store.chunks_table.create_index( - "content_fts", - config=FTS(with_position=True, remove_stop_words=False), - replace=True, - ) + await ensure_indexes(self.store.chunks_table, "chunks") async def delete_by_document_id(self, document_id: str) -> bool: """Delete all chunks for a document.""" diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document.py b/haiku_rag_slim/haiku/rag/store/repositories/document.py index 9bfd9b57..be242be3 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/document.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/document.py @@ -3,12 +3,12 @@ from datetime import datetime from typing import overload from uuid import uuid4 -from lancedb.index import BTree - from haiku.rag.store.engine import ( DocumentMetaRecord, DocumentRecord, Store, + ensure_indexes, + get_document_items_arrow_schema, get_documents_arrow_schema, query_to_pydantic, ) @@ -407,23 +407,14 @@ class DocumentRepository: async def delete_all(self) -> None: """Delete all documents from the database.""" self.store._assert_writable() - from haiku.rag.store.engine import DocumentItemRecord # Delete all chunks and items first await self.chunk_repository.delete_all() await self.store.db.drop_table("document_items") self.store.document_items_table = await self.store.db.create_table( - "document_items", schema=DocumentItemRecord - ) - await self.store.document_items_table.create_index( - "document_id", config=BTree(), replace=True - ) - await self.store.document_items_table.create_index( - "position", config=BTree(), replace=True - ) - await self.store.document_items_table.create_index( - "self_ref", config=BTree(), replace=True + "document_items", schema=get_document_items_arrow_schema() ) + await ensure_indexes(self.store.document_items_table, "document_items") # Get count before deletion count = len( @@ -437,13 +428,9 @@ class DocumentRepository: self.store.documents_table = await self.store.db.create_table( "documents", schema=get_documents_arrow_schema() ) + await ensure_indexes(self.store.documents_table, "documents") await self.store.db.drop_table("document_meta") self.store.document_meta_table = await self.store.db.create_table( "document_meta", schema=DocumentMetaRecord ) - await self.store.document_meta_table.create_index( - "id", config=BTree(), replace=True - ) - await self.store.document_meta_table.create_index( - "uri", config=BTree(), replace=True - ) + await ensure_indexes(self.store.document_meta_table, "document_meta") diff --git a/haiku_rag_slim/haiku/rag/store/repositories/settings.py b/haiku_rag_slim/haiku/rag/store/repositories/settings.py index d7b2986c..e38c17cf 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/settings.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/settings.py @@ -60,7 +60,9 @@ class SettingsRepository: ) await self.store.settings_table.add([settings_record]) - async def validate_config_compatibility(self) -> None: + async def validate_config_compatibility( + self, stored_settings: dict | None = None + ) -> None: """Validate the current configuration against stored settings without writing. Opening a database never modifies it. ``vector_dim`` mismatches raise — @@ -72,7 +74,8 @@ class SettingsRepository: while a read-only open continues. Stored settings are reconciled explicitly via ``haiku-rag rebuild --set-embedder``, never on open. """ - stored_settings = await self.get_current_settings() + if stored_settings is None: + stored_settings = await self.get_current_settings() # Nothing stored to validate against — never write on open. if not stored_settings: diff --git a/haiku_rag_slim/haiku/rag/store/upgrades/__init__.py b/haiku_rag_slim/haiku/rag/store/upgrades/__init__.py index 4280ff35..200f0f15 100644 --- a/haiku_rag_slim/haiku/rag/store/upgrades/__init__.py +++ b/haiku_rag_slim/haiku/rag/store/upgrades/__init__.py @@ -99,6 +99,9 @@ from haiku.rag.store.upgrades.v0_58_0 import ( from haiku.rag.store.upgrades.v0_64_0 import ( upgrade_rename_document_meta_id as upgrade_0_64_0_rename_document_meta_id, ) +from haiku.rag.store.upgrades.v0_75_0 import ( + upgrade_index_hot_lookup_keys as upgrade_0_75_0_index_hot_lookup_keys, +) upgrades.append(upgrade_0_20_0_docling) upgrades.append(upgrade_0_23_1_contextualize) @@ -110,3 +113,4 @@ upgrades.append(upgrade_0_48_0_heading_hierarchy) upgrades.append(upgrade_0_50_0_canonical_metadata_keys) upgrades.append(upgrade_0_58_0_split_document_meta) upgrades.append(upgrade_0_64_0_rename_document_meta_id) +upgrades.append(upgrade_0_75_0_index_hot_lookup_keys) diff --git a/haiku_rag_slim/haiku/rag/store/upgrades/v0_75_0.py b/haiku_rag_slim/haiku/rag/store/upgrades/v0_75_0.py new file mode 100644 index 00000000..f71b7001 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/store/upgrades/v0_75_0.py @@ -0,0 +1,24 @@ +import logging + +from haiku.rag.store.engine import Store, ensure_indexes +from haiku.rag.store.upgrades import Upgrade + +logger = logging.getLogger(__name__) + + +async def _apply_index_hot_lookup_keys(store: Store) -> None: + """Add the declared indexes to a database created before 0.75.0. + + Rewrites no rows. Each index build reads the column it indexes. + """ + for table_name, table in store._tables().items(): + applied = await ensure_indexes(table, table_name) + if applied: + logger.info(f"Indexed {table_name}: {', '.join(sorted(applied))}") + + +upgrade_index_hot_lookup_keys = Upgrade( + version="0.75.0", + apply=_apply_index_hot_lookup_keys, + description="Index documents.id, chunks.id, chunks.document_id and document_items.label", +) diff --git a/haiku_rag_slim/pyproject.toml b/haiku_rag_slim/pyproject.toml index 5977bf40..23757cd4 100644 --- a/haiku_rag_slim/pyproject.toml +++ b/haiku_rag_slim/pyproject.toml @@ -1,13 +1,28 @@ [project] name = "haiku.rag-slim" -description = "Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling - Minimal dependencies" -version = "0.74.0" +description = "Local-first agentic RAG with citations - hybrid search, reranking and multimodal retrieval over your own documents, no database server required - Minimal dependencies" +version = "0.75.0" authors = [{ name = "Yiorgis Gozadinos", email = "ggozadinos@gmail.com" }] license = { text = "MIT" } readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.12" -keywords = ["RAG", "lancedb", "vector-database", "ml", "mcp"] +keywords = [ + "RAG", + "agentic-rag", + "lancedb", + "vector-database", + "hybrid-search", + "reranking", + "multimodal-rag", + "embeddings", + "citations", + "document-ingestion", + "mcp", + "mcp-server", + "pydantic-ai", + "docling", +] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", @@ -41,6 +56,13 @@ dependencies = [ "zstandard>=0.23.0; python_version<'3.14'", ] +[project.urls] +Homepage = "https://ggozad.github.io/haiku.rag/" +Documentation = "https://ggozad.github.io/haiku.rag/" +Repository = "https://github.com/ggozad/haiku.rag" +Issues = "https://github.com/ggozad/haiku.rag/issues" +Changelog = "https://ggozad.github.io/haiku.rag/changelog/" + [project.optional-dependencies] # Document processing docling = ["docling>=2.102.2,<3.0.0", "opencv-python-headless>=4.6.0.66,<5.0.0.0"] diff --git a/overrides/main.html b/overrides/main.html index 5bbf25f9..fc67d494 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -3,7 +3,7 @@ {% block extrahead %} - + @@ -17,7 +17,7 @@
Opinionated agentic RAG powered by LanceDB, Pydantic AI, and Docling. Runs locally, scales to production.
+Ask questions about your own documents and get answers that cite their sources. Agentic RAG on LanceDB, Pydantic AI, and Docling. Runs locally, scales to production.