Skip to content

About

RAG for Spring AI where every statement points to a verifiable passage: document, section, page. Hybrid search over pgvector and Postgres full text, strict tenant partitions, citation checks, retrieval evals in the build.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

footnote-rag

Maven Central CI

Retrieval-augmented generation for Spring AI where every statement points to a verifiable passage: document, section, page.

Status: 0.1.0 released on Maven Central. As long as the version is 0.x, the API may change between releases.

Requirements

  • Java 25
  • Spring Boot 4.0 or 4.1, Spring AI 2.0
  • PostgreSQL with pgvector 0.8 or later (iterative index scans), and a database user allowed to run CREATE EXTENSION vector, or the extension already installed

Why

Most RAG examples return an answer and a list of "sources". footnote-rag checks that every element of the model output cites a passage that was actually in its context, and reports document, section and page for it. If the sources do not cover a question, retrieval says so instead of handing the model the nearest noise.

Quick start

dependencies {
    implementation("io.github.christianhartmann:footnote-rag-spring-boot-starter:0.1.0")
    // Only for citation validation of model output (CitationValidator); the starter does not bring it along.
    implementation("io.github.christianhartmann:footnote-rag-generation:0.1.0")
    implementation("org.springframework.ai:spring-ai-starter-model-ollama")
}
spring.ai.model.embedding=ollama
spring.ai.ollama.embedding.options.model=bge-m3
footnote-rag.embedding.model-id=bge-m3@<digest from `ollama list`>
# Vector length of the model; default 1024 (bge-m3). Fixed once the schema exists.
footnote-rag.embedding.dimensions=1024
ingestion.ingest(new IngestionRequest(sourceId, "tenant-42", pdfStream, SourceReaders.PDF, "FwDV 7", "2023"));
RetrievalResult result = retrieval.retrieve(RetrievalRequest.of("Who monitors breathing apparatus teams?", Set.of("tenant-42", "GLOBAL"), 8));
if (!result.sufficient()) { /* tell the user the sources do not cover this */ }

Modules

How the modules, the ingestion pipeline, retrieval and the store fit together is described in docs/architecture.md.

Module Purpose
footnote-rag-core Reading PDF/DOCX, structure-aware chunking, PII scan, ingestion, hybrid retrieval with coverage check, retry of transient failures, eval harness. No database, no Spring Boot auto-configuration (the Boot BOM is only used for version alignment).
footnote-rag-pgvector ChunkStore on PostgreSQL with pgvector and German full-text search. Own Flyway history table.
footnote-rag-generation Citation validation for model output. Optional: add it as its own dependency, the starter does not pull it in.
footnote-rag-spring-boot-starter Auto-configuration for core and pgvector, and a CitationValidator bean when footnote-rag-generation is on the classpath.

Design decisions

  • Hybrid retrieval: pgvector (HNSW, iterative scans with filters) plus PostgreSQL German full-text search, fused with Reciprocal Rank Fusion.
  • Retrieval mode (HYBRID, VECTOR, KEYWORD) is chosen per request with the mode argument of the RetrievalRequest constructor; HYBRID is the default (and what RetrievalRequest.of uses).
  • Coverage (RetrievalResult.sufficient()) counts only vector hits above footnote-rag.retrieval.min-similarity, because fused and keyword scores are relative. KEYWORD mode therefore always reports sufficient() == false (for any min-hits above 0).
  • The default min-similarity of 0.54 is calibrated for bge-m3 on the German law corpus of the evals. Another model or corpus needs its own value; the eval report suggests one.
  • Partitions are mandatory in every search (SearchScope); there is no API to search across partitions. A source id belongs to one partition: writing it to another partition fails instead of moving it, and deleting it names the partition.
  • Every search filters on the configured embedding model id, so a re-embedding never mixes vector spaces.
  • Own Flyway history table, so the schema never collides with the application's migrations.
  • Source files are read under limits (footnote-rag.reading.max-bytes, max-unpacked-bytes, max-pages, max-characters; defaults 50 MiB, 256 MiB unpacked DOCX, 2000 pages, 5 million characters). A source over a limit is rejected with SourceTooLargeException before anything of it is chunked or embedded, so a crafted or oversized file cannot fill the heap or keep the embedding model busy for hours. The text scan for personal data is linear in the text length.
  • The embedding column has the fixed length footnote-rag.embedding.dimensions. At startup the starter compares it with the model only if the model reports its dimensions without an embedding call (Spring AI's Ollama and OpenAI models would need a remote call); otherwise the first write or search with a vector of the wrong length fails. Changing the property after the schema exists fails at startup (when footnote-rag migrates the schema, the default); a model with other dimensions needs a new database schema.

Evals

Corpus: three German federal laws (public domain, see eval/corpus/SOURCES.md), 50 questions plus 10 out-of-corpus questions.

Mode Recall@8 MRR
HYBRID 0.880 0.651
VECTOR 0.960 0.825
KEYWORD 0.640 0.366

On this corpus, hybrid retrieval currently trails pure vector retrieval on both Recall@8 and MRR (see the Findings section in eval-reports/latest.md). Until that is understood, an application that only needs semantic search can pass RetrievalMode.VECTOR in its RetrievalRequest instead of the HYBRID default.

Run the evals with ./gradlew :footnote-rag-pgvector:evalTest (needs Docker).

Schema migration ordering

footnote-rag migrates its own tables with its own Flyway history table (footnote_rag_schema_history), separate from the application's. That migration runs after the application's database initialization (Flyway, Liquibase, spring.sql.init), via @DependsOnDatabaseInitialization.

Consequences:

  • The application's own migrations cannot reference rag_chunk; it does not exist yet when they run.
  • This ordering works only when Boot drives both migrations. A host that runs Flyway itself, outside Boot's database-initialization lifecycle, is not ordered against footnote-rag's migration.

License

Apache-2.0

About

RAG for Spring AI where every statement points to a verifiable passage: document, section, page. Hybrid search over pgvector and Postgres full text, strict tenant partitions, citation checks, retrieval evals in the build.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages