Skip to content

feat(rag): add fleischwolf-rag — pluggable RAG subsystem - #23

Merged
artiz merged 20 commits into
masterfrom
claude/fleischwolf-rag-system-3knaso
Jul 2, 2026
Merged

feat(rag): add fleischwolf-rag — pluggable RAG subsystem#23
artiz merged 20 commits into
masterfrom
claude/fleischwolf-rag-system-3knaso

Conversation

@artiz

@artiz artiz commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Add a new workspace crate that builds a Retrieval-Augmented-Generation layer
on top of the document converter: source → convert to Markdown → chunk → embed
→ vector store → retrieve → (optional) LLM answer synthesis.

Every external dependency is a swappable trait:

  • Chunking: Markdown-aware, configurable size (300) + overlap (5%), heading
    context, section-bounded sliding window.
  • Embedders: Ollama/bge-m3 (default, 1024-d), Gemini, local ONNX (feature
    onnx-embed), and a deterministic hashing embedder for offline tests/eval.
  • Vector stores: SQLite (default, bundled), PostgreSQL + pgvector (feature
    postgres), in-memory. Documents and chunks live in separate tables.
  • Retrieval: dense vector, sparse BM25, Hybrid (RRF), Multi-Query fusion, HyDE.
  • LLM: OpenRouter (default model DeepSeek-V3).
  • Sources: local folder (default), FTP/SFTP (feature remote-sources).
  • Queues: in-process (default), RabbitMQ (feature rabbitmq), Redis pub/sub
    (feature redis).

Includes an evaluation harness that sweeps a matrix of chunk sizes / overlaps /
retrieval modes over a labelled dataset and ranks configs by recall@k / MRR /
nDCG@k / latency, a clap-based CLI (init-db, ingest, query, eval, stats), and
config loaded from .env (see .env.example). The default feature set is fully
offline and unit-tested (33 tests); the heavier backends are real client code,
compile-checked but exercised only against live services.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun

claude and others added 20 commits July 1, 2026 23:07
Add a new workspace crate that builds a Retrieval-Augmented-Generation layer
on top of the document converter: source → convert to Markdown → chunk → embed
→ vector store → retrieve → (optional) LLM answer synthesis.

Every external dependency is a swappable trait:
- Chunking: Markdown-aware, configurable size (300) + overlap (5%), heading
  context, section-bounded sliding window.
- Embedders: Ollama/bge-m3 (default, 1024-d), Gemini, local ONNX (feature
  onnx-embed), and a deterministic hashing embedder for offline tests/eval.
- Vector stores: SQLite (default, bundled), PostgreSQL + pgvector (feature
  postgres), in-memory. Documents and chunks live in separate tables.
- Retrieval: dense vector, sparse BM25, Hybrid (RRF), Multi-Query fusion, HyDE.
- LLM: OpenRouter (default model DeepSeek-V3).
- Sources: local folder (default), FTP/SFTP (feature remote-sources).
- Queues: in-process (default), RabbitMQ (feature rabbitmq), Redis pub/sub
  (feature redis).

Includes an evaluation harness that sweeps a matrix of chunk sizes / overlaps /
retrieval modes over a labelled dataset and ranks configs by recall@k / MRR /
nDCG@k / latency, a clap-based CLI (init-db, ingest, query, eval, stats), and
config loaded from .env (see .env.example). The default feature set is fully
offline and unit-tested (33 tests); the heavier backends are real client code,
compile-checked but exercised only against live services.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
Replace the brute-force cosine scan in the SQLite store with the sqlite-vec
extension, statically compiled into the bundled SQLite and registered via
sqlite3_auto_extension so every pooled connection sees it. Embeddings move out
of a BLOB column on chunks into a vec0 virtual table (chunks_vec, cosine
metric, dimension fixed at migrate time) sharing the chunks rowid, and
vector_search becomes a KNN `embedding MATCH ? AND k = ?` query joined back to
chunks.

The store now takes the embedding dimension at connect time and validates it on
insert and search. Adds vec0-backed unit tests (in-memory SQLite KNN, dimension
mismatch); CLI smoke test returns identical cosine scores to the previous
brute-force implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
Time the three ingestion phases (parsing via fleischwolf, chunking, embedding)
per document and store the results in the documents table's JSON metadata
column under "metrics", so new metrics can be added later without a schema
migration. Recorded: source file size, page count where the format has pages
(PDF via pdfium page_count, PPTX slides / XLSX sheets via zip entry names),
Markdown word count, chunk count, embedded word count, and per-phase seconds +
words/sec — plus pages/sec for parsing when the page count is known.

Adds a metrics module (ProcessingMetrics/PhaseMetrics/Timings + count_pages),
a list_documents method on VectorStore (memory/sqlite/postgres), a per-document
metrics table in the `stats` CLI subcommand, an info-level tracing line per
ingested document, and unit + integration tests over the stored JSON shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
Add `fleischwolf-rag serve` (axum): GET /health (public), and under
API-key auth GET /api/stats, /api/documents, /api/documents/{id}, and
GET/POST /api/search with mode (vector|bm25|hybrid|multi-query|hyde),
top_k, and an optional answer=true flag for LLM-grounded answers. Keys
come from RAG_API_KEYS (comma-separated, in .env) and are accepted as
X-Api-Key or Authorization: Bearer; auth is fail-closed — the server
refuses to start with an empty key list. `serve --ingest` indexes the
configured source before serving; RAG_HTTP_ADDR / --addr control the bind.

Docs: add a from-zero quickstart to the crate README (index any documents
folder offline with the hash embedder, then switch to Ollama/bge-m3), a
REST API endpoint table with curl examples, and .env.example entries.

Tests: offline HTTP integration test over an ephemeral port (auth 401s,
Bearer + X-Api-Key, documents with metrics, search in all offline modes,
POST body, 400s for bad mode / LLM mode without key, empty-key refusal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
Fixes the `fmt · clippy (pinned)` CI job on #23 — the new crate had never
been run through cargo fmt. No functional changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
Master released v0.11.0 (workspace-wide version bump); fleischwolf-rag's
path dependencies still required ^0.10.0, so the PR merge ref failed to
resolve. Merged origin/master and aligned the pins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
…naso' into claude/fleischwolf-rag-system-3knaso
…m warnings

RAG_DOCUMENTS_OUTPUT (added to .env.example in 0dca270) is now implemented:
every ingested document's parsed Markdown is mirrored into that folder with
the source's directory structure preserved and `.md` appended to the original
file name (report.pdf -> report.pdf.md; original .md inputs also get the
suffix since conversion may reformat them). SourceRef carries a rel_path for
this (folder/FTP/SFTP). Writes are best-effort and never fail ingestion;
path components are sanitized so a hostile rel_path cannot escape the root.

Fix the "corrupt deflate stream" WARN flood when ingesting real-world PDFs:
the warnings come from lopdf's per-stream zlib decode (it skips such streams
and continues; fleischwolf's PDF parse is unaffected) and surfaced only
because the RAG CLI's tracing subscriber bridges `log` records. The default
env filter is now "info,lopdf=error"; RUST_LOG still overrides. Verified on
rag/data: one report emitted 486 warnings unfiltered, 0 filtered, and still
ingested 131 pages / 400 chunks with a correct markdown dump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
…rors

Ingestion now runs as three overlapped stages joined by bounded channels:
fleischwolf's convert_streaming emits Markdown as produced (per page for PDF)
on a parser thread, a StreamingChunker cuts completed sections incrementally
(at ATX headings outside code fences — chunks never cross headings, so batch
and streaming chunking are byte-identical; property-tested at every split
granularity), and an embed/insert worker embeds 64-chunk batches concurrently
with parsing. Phase metrics record busy time per stage, so words/sec stays
meaningful under overlap. A failed ingest now rolls back its document row and
partial chunks (new VectorStore::delete_document on memory/sqlite/postgres)
instead of leaving a hash that future runs would skip.

Adds an info log when a document starts processing (uri, name, bytes) in
addition to the completion log with metrics.

`query` now synthesizes an LLM answer by default when OPENROUTER_API_KEY is
set (sources listed below the answer); --chunks restores the plain list, and a
hint is printed when no key is configured. LLM HTTP errors now include the
provider's response body plus a hint for the common 401 (a native DeepSeek
sk-... key needs OPENROUTER_BASE_URL=https://api.deepseek.com and
RAG_LLM_MODEL=deepseek-chat; OpenRouter keys start with sk-or-). Documented in
.env.example and the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
The per-document stats table now uses a fixed 65-char title column
(ellipsized with "..." when longer, char-safe) and right-aligned numeric
columns, so rows line up in the terminal regardless of title length.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
…h-safe ingest

Evaluation additions:
- `eval --from-md-dir DIR --questions FILE` assembles the dataset from the
  RAG_DOCUMENTS_OUTPUT markdown mirror plus a questions file, so the sweep
  runs on the real ingested corpus. Questions accept both the crate's
  {query, relevant} shape and the QA-benchmark {text, kind} shape; entries
  without `relevant` labels are skipped with a note.
- `eval --sizes 200,300 --overlaps 0,0.05` exposes the chunk-config matrix
  (cross product); the default remains the three built-in pairs.
- New `answers` subcommand: runs an unlabelled {text, kind} benchmark through
  the full RAG + LLM loop against the configured store and prints each answer
  with source count and latency (Markdown or --json). Automates the
  rag/questions.json performance check.

Interrupted-ingest hygiene (explains "duplicate" doc rows with no metrics):
a document row used to be inserted with its real content hash before
processing, so a killed run left a row that dedup then skipped forever.
Now the initial upsert carries a "pending:" sentinel hash — the real hash is
written only on success — and re-ingesting a source first deletes any stale
rows for the same URI (new VectorStore::delete_documents_by_source), which
also replaces old versions of changed files instead of accumulating them.
New `prune` subcommand removes incomplete rows left by older builds.

Documented eval/answers/prune usage, dataset format, and metric meanings in
the crate README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
Master's release automation bumps every sibling crate's exact version pin on
each release (v0.11.0, v0.12.1 while this PR has been open), which repeatedly
broke the PR merge-ref build: fleischwolf-rag required the previous exact
version. Merge latest master and switch the fleischwolf / fleischwolf-pdf
requirements to ">=0.12", which tolerates future release bumps; after merge,
release.sh rewrites them to exact pins automatically (its sed matches any
quoted version on `path = "../fleischwolf…"` lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
…json

The questions loader now accepts `question` as an alias (alongside `query` /
`text`) and ignores extra fields, so `answers --json` output is directly
reusable as a questions file. The "no relevant labels" error now explains how
to annotate entries, and the README documents the intended round-trip:
answers -> review + add `relevant` snippets -> eval.

Annotate rag/questions.json with verified `relevant` labels (each snippet
grep-confirmed against the parsed markdown of the corresponding PDF):
Tradition "operating margin of 9.9%", Yellow Pages "Normal Course Issuer Bid"/
"871,135", Holley "Baer Brakes"/"Empower Ltd", Mercia "Enterprise Ventures
Group" — the latter shows the recorded LLM answer ("no mergers mentioned") was
a retrieval miss, exactly what eval is meant to catch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
The 10-K incorporates executive compensation by reference to the 2023 Proxy
Statement — the dollar figures are not in the document, so the recorded LLM
answer ('cannot answer') was correct. The Item 11 passage citing the proxy
statement is the right retrieval evidence; '2023 Proxy Statement' is unique to
the CrossFirst document in this corpus. All 5 questions now score in eval.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxznqztpBUiRN4FH6pKGun
@artiz
artiz merged commit 86f045b into master Jul 2, 2026
3 checks passed
@artiz
artiz deleted the claude/fleischwolf-rag-system-3knaso branch July 2, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants