A retrieval-augmented question-answering tool for compliance questions that arise when a US-based SaaS vendor enters the German-speaking market (Germany, Austria, Switzerland). It answers questions about data protection (GDPR), employment law, and related regulatory topics by retrieving relevant passages from a document corpus and synthesizing an answer with source attribution.
The problem it addresses: compliance and legal teams evaluating DACH market entry need answers grounded in actual regulatory text and DPAs, not general knowledge that may not reflect country-specific requirements or may hallucinate specifics. This tool restricts answers to what is actually present in an indexed corpus of source documents.
The system has two stages: a one-time indexing step and a query step.
Indexing (modules/indexer.py, run once or whenever the corpus changes):
- Reads
.txtand.pdfsource documents - Splits each document into overlapping word chunks
- Generates an embedding for each chunk locally via Ollama (
nomic-embed-text) - Stores chunks, embeddings, and metadata in a Chroma collection
Query (modules/compliance_engine.py, run per question):
- Query expansion — when
RAG_QUERY_EXPANSION=true, the user's question (often in German) is sent to Claude Haiku, which produces 2-3 concise English legal search phrases likely to appear verbatim in a DPA, GDPR text, or employment law document. This step exists because the corpus is predominantly English-language legal text, and direct embedding of a German question performs worse against it than an expanded English query. WhenRAG_QUERY_EXPANSION=false, the original question is used directly. - Retrieval — exactly one query is embedded locally via Ollama and matched against the Chroma collection.
- Distance check —
RAG_MAX_DISTANCEfilters out missing, malformed, or weak matches before synthesis. - Synthesis — strong retrieved chunks are passed to Claude Sonnet, which produces the final answer, instructed to point out country-specific differences and to say explicitly when the corpus does not contain enough information to answer. The answer includes the top three retrieved raw chunks for verification.
Frage: Muss der Betriebsrat bei der Einführung eines KI-gestützten
Bewerber-Scorings nach §87 Abs. 1 Nr. 6 BetrVG zustimmen?
Antwort (gekürzt): Ja, sehr wahrscheinlich besteht ein
Mitbestimmungsrecht. §87 Abs. 1 Nr. 6 BetrVG spricht von
"Arbeitnehmern", nicht von Bewerbern - das ist der Ausgangspunkt der
Prüfung. Die herrschende Praxis-Auffassung: Sobald das System auch für
interne Bewerber oder Versetzungen genutzt wird, ist ein
Mitbestimmungsrecht eindeutig gegeben. Zusätzlich relevant: §94 BetrVG
(Personalfragebögen), §95 BetrVG (Auswahlrichtlinien), Art. 22 DSGVO
(automatisierte Entscheidungen) und die Hochrisiko-Klassifizierung
nach EU AI Act Annex III. Empfehlung: Betriebsrat frühzeitig einbinden,
Betriebsvereinbarung abschließen.
Cited sources:
1. BetrVG_87_Mitbestimmung.txt, chunk 0
2. Rechtsgrundlagen-KI-v2.0.pdf, chunk 11
3. KI-Fragenkatalog.pdf, chunk 4
Note: the answer explicitly flags where it goes beyond the indexed corpus and supplements with general legal knowledge (the Bewerber vs. Arbeitnehmer distinction) — included here as an example of the system's transparency behavior described in "Known limitations".
Embedding generation (both for indexing and for queries) runs entirely locally through Ollama — no document content or query text is sent to a third-party embedding API. The only data sent to an external API (Anthropic) is: the user's question and Haiku-generated search phrases (query expansion), and the retrieved text chunks together with the question (synthesis).
This matters for GDPR/data residency because the vector store and the embedding model both run on infrastructure under the operator's control. Anthropic's API remains a data processor for the question text and retrieved chunks that are sent to it during query expansion and synthesis — running embeddings locally does not remove that flow, it only keeps it out of the indexing step and out of raw corpus storage.
- Chunking: documents are split into chunks of
CHUNK_SIZEwords (default 500) withCHUNK_OVERLAPwords of overlap between consecutive chunks (default 50). Overlap avoids cutting a relevant passage exactly at a chunk boundary. - Retrieval count:
RAG_TOP_Kchunks (default 10) are passed to Sonnet for synthesis. - Retrieval quality threshold:
RAG_MAX_DISTANCE(default0.40) rejects weak matches before Sonnet synthesis. With Cosine Distance, a smaller value means a more similar match. The0.40default was calibrated against the current example corpus and should be re-tested for a different corpus. - Source diversity limit: no more than
RAG_MAX_PER_SOURCEchunks (default 3) from the same source document are allowed into the final set, even if a single document dominates the raw similarity ranking. This is meant to keep the context from being monopolized by one long document at the expense of others that may be equally relevant. - To fill the diversity-limited set, the engine over-fetches (
top_k * 2candidates) from Chroma before applying the per-source cap.
All of these values are configurable via environment variables; see
.env.example.
- Install dependencies:
pip install -r requirements.txt - Copy
.env.exampleto.envand fill in:ANTHROPIC_API_KEYCHROMA_HOST/CHROMA_PORT(a running Chroma instance, e.g. via Docker)OLLAMA_HOST/OLLAMA_PORT(a running Ollama instance)DOCS_DIR(directory of.txtsource documents) and, if used,PDF_DIR(directory of.pdfsource documents)
- Make sure Ollama has the embedding model available:
ollama pull nomic-embed-text - Build the index (one-time step, re-run when the corpus changes):
python modules/indexer.py - Ask a question:
python main.py "your compliance question here"
For the list of documents making up the corpus, see SOURCES.md.
- Retrieval is bounded by the corpus. The system can only surface what
is actually indexed. A question about a topic not covered by any source
document will produce a poor or misleading answer rather than a correct
one drawn from general knowledge — the synthesis prompt asks Sonnet to
say so explicitly, but this depends on the model following that
instruction correctly. As a safeguard, retrieval applies
RAG_MAX_DISTANCE(default0.40) after query expansion and skips Claude Sonnet synthesis when the best match is too weak. - PDF extraction required a library switch. An earlier version used
pypdf, which on a subset of the corpus's PDFs silently extracted whole pages as blank/whitespace text (no error, just empty content), leading to those pages being invisible to retrieval. The indexer now usespdfplumber, which extracted text correctly on the same files. It also warns automatically when individual PDF pages look suspiciously empty compared with neighbouring pages. - Repeated indexing can leave stale chunks. Re-running the indexer used
to append a fresh set of UUID-backed chunks for the same source document.
The indexer now replaces chunks per exact
sourcevalue after all new embeddings for that source have been computed, preventing stale or duplicate document versions. - Synthesis can misattribute content. Claude Sonnet occasionally cites the wrong source or paragraph within the provided context when composing an answer, even when the underlying retrieved content is correct. Answers should be checked against the cited source before being relied upon. To make answers easier to verify, successful responses now include the three retrieved raw chunks that were passed into synthesis.
flowchart LR
A[Query] --> B{"RAG_QUERY_EXPANSION=true?"}
B -->|yes| C["Query Expansion<br/>Claude Haiku (API)"]
B -->|no| D["Original query"]
C --> E["Retrieval<br/>Ollama Embedding + Chroma (local)"]
D --> E
E --> F{"Distance <= RAG_MAX_DISTANCE?"}
F -->|no| G["Not in corpus response"]
F -->|yes| H["Synthesis<br/>Claude Sonnet (API)"]
H --> I["Answer + visible raw chunks"]
classDef api fill:#f5e6ff,stroke:#7d3c98,color:#000
classDef local fill:#e0f5e9,stroke:#27ae60,color:#000
classDef guard fill:#fff4cc,stroke:#b58900,color:#000
class C,H api
class E local
class F,G guard