Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DACH Ready

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.

Architecture

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 .txt and .pdf source 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):

  1. 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. When RAG_QUERY_EXPANSION=false, the original question is used directly.
  2. Retrieval — exactly one query is embedded locally via Ollama and matched against the Chroma collection.
  3. Distance checkRAG_MAX_DISTANCE filters out missing, malformed, or weak matches before synthesis.
  4. 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.

Example Query

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".

Local embeddings and data residency

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.

Retrieval details

  • Chunking: documents are split into chunks of CHUNK_SIZE words (default 500) with CHUNK_OVERLAP words of overlap between consecutive chunks (default 50). Overlap avoids cutting a relevant passage exactly at a chunk boundary.
  • Retrieval count: RAG_TOP_K chunks (default 10) are passed to Sonnet for synthesis.
  • Retrieval quality threshold: RAG_MAX_DISTANCE (default 0.40) rejects weak matches before Sonnet synthesis. With Cosine Distance, a smaller value means a more similar match. The 0.40 default 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_SOURCE chunks (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 * 2 candidates) from Chroma before applying the per-source cap.

All of these values are configurable via environment variables; see .env.example.

Setup

  1. Install dependencies:
    pip install -r requirements.txt
    
  2. Copy .env.example to .env and fill in:
    • ANTHROPIC_API_KEY
    • CHROMA_HOST / CHROMA_PORT (a running Chroma instance, e.g. via Docker)
    • OLLAMA_HOST / OLLAMA_PORT (a running Ollama instance)
    • DOCS_DIR (directory of .txt source documents) and, if used, PDF_DIR (directory of .pdf source documents)
  3. Make sure Ollama has the embedding model available:
    ollama pull nomic-embed-text
    
  4. Build the index (one-time step, re-run when the corpus changes):
    python modules/indexer.py
    
  5. Ask a question:
    python main.py "your compliance question here"
    

For the list of documents making up the corpus, see SOURCES.md.

Known limitations

  • 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 (default 0.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 uses pdfplumber, 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 source value 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.

Architecture diagram

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
Loading

About

Local-first RAG tool for DACH compliance questions in US SaaS market entry

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages