RAG without magic: Auditable knowledge integration for local LLMs

RAG without magic: Auditable knowledge integration for local LLMs
By Matthias Petermann / on 21.11.2025

Introduction

In previous articles I have shown how a locally operated LLM can provide support in practice - initially using the example of a task robot (Local LLM in practice - Redmine ticket analysis). I then delved deeper into the subject in Local LLM with depth to better understand the entire supply chain and free my own LLM pipelines from unnecessary or uncontrollable ballast.

The next practical part follows today. This time it’s about a particularly useful aspect of modern LLM architectures: Retrieval Augmented Generation (RAG). A language model not only gains access to its trained knowledge, but also to exactly the information that is really relevant for the respective use case. RAG combines the strengths of an LLM with targeted information searches in its own databases.

The result is answers that are context-rich, up-to-date, accurate and generated entirely within your own security perimeter - a central building block for robust, sovereign and traceable AI systems.

Below I will show how RAG is implemented in practice, which components are involved and how this approach can be seamlessly integrated into local LLM workflows.


Basics: What is Retrieval-Augmented Generation (RAG)?

ℹ️ Definition: Retrieval Augmented Generation (RAG)
RAG is an architectural approach in which a language model receives additional external information during the request. The model does not generate answers purely from parameters, but based on dynamically retrieved context data.

Briefly summarized:

  • Retrieval = Retrieve relevant information from a knowledge base
  • Augmented = This information is given to the model in addition to the prompt
  • Generation = The LLM then generates its answer

RAG is particularly suitable for:

  • technical wikis
  • internal documentation
  • Policies & Playbooks
  • Knowledge bases
  • Product catalogs

Task

We run a local LLM (e.g. llama.cpp with a Qwen model) and have stored our internal wiki documents as Markdown files in a directory. This content should be prepared in such a way that the model can search it specifically and use it as a knowledge base.


Solution approach

In order to make our wiki accessible for the LLM, we need four consecutive steps:

  1. Read the wiki files, break them down into chunks and convert them into embeddings
  2. A suitable database to store these embeddings transparently and locally
  3. Select the embed user question and the most relevant wiki chunks
  4. Build a context prompt and send it to the llama-server

Below we’ll go through these steps sequentially and in detail.


1. A pipeline for vectorizing the wiki documents (embeddings)

The pipeline handles:

  • Read Markdown files
  • break the content down into meaningful chunks
  • create an embedding for each chunk
  • save everything together in one database
ℹ️ What are embeddings?
Embeddings are numerical vectors that express the meaning of a text in semantic terms represent space. Similar texts are close to each other in this room - what is measured this e.g. B. with the Cosine Similarity.
ℹ️ Scientific background: Cosine Similarity

The Cosine Similarity measures the angle between two vectors. It’s not the amount that counts, but the direction. This is ideal for language models because two texts have similar meaning Although they can be of different lengths, they are still the same Show “direction” in semantic space.

Formula: cos(θ) = (A B) / (||A|| * ||B||)

  • 1.0 → identical
  • 0.0 → orthogonal (no similarity)
  • −1.0 → opposite

2. A database for the embeddings

Once all wiki chunks have been converted into embeddings, they need to be stored in a database so that they can be accessed efficiently later. We deliberately use SQLite for our project because our expected data volume remains manageable:

  • realistic 2,000-20,000 chunks,
  • fully searchable in RAM,
  • no high latency requirements,
  • no scaling or clustering requirements.

A classic vector database would be overengineering for this size, as its strengths only come into play with several hundred thousand or million embeddings.

ℹ️ Why SQLite?

SQLite is ideal for our use case:

  • simple, portable, transparent
  • a single file, perfectly debuggable (SELECT * FROM docs)
  • no infrastructure necessary
  • easily versionable
  • absolutely performant for 2k–20k embeddings

We simply store the embeddings as JSON in an SQLite table and perform the cosine similarity in the retrieval step in Python.

🤔 When does it make sense to switch to real vector databases?

For comparison: For very large knowledge bases (> 100k embeddings) you often use real vector databases such as:

  • Chroma
  • Faiss (Meta) -Milvus -Weaviate -Qdrant
  • Pinecone (SaaS)
  • Elastic Vector Search
  • pgvector for PostgreSQL

These systems offer highly optimized ANN indexes (e.g. HNSW, IVF, PQ) and distribute data across clusters - we don’t need any of that at the moment.


3. Embed the user question and retrieve appropriate wiki chunks

Once a user asks a question:

  • the question itself is vectorized (embedding),
  • compared against all wiki embeddings,
  • the top-k most suitable chunks selected.
ℹ️ What does top k chunks mean?

“Top-k” means: The k most relevant text sections from the knowledge base - sorted by highest cosine similarity.

Example:

  • k = 5 → the five best matching chunks
  • These chunks form the context that the LLM understands.

4. Build context prompt and send to llama-server

After the relevant chunks are found, they are merged into one Context prompt embedded:

  • The prompt contains the user’s question
  • plus the Top-k-Chunks from the Wiki
  • plus an instruction that the model only is based on this Context may respond.

This prompt is then sent to the local llama-server.

ℹ️ What is included in the context prompt?

A good RAG prompt contains:

  1. System Instructions – How the model should answer (factual, brief, context-based).

  2. Context Block – The top k chunks from the knowledge base.

  3. User Question – The actual query.

  4. Note about limits
    – If something is out of context: “I don’t have any information about that information."


Architecture overview (high-level)

┌──────────────┐   ┌──────────┐   ┌──────────┐   ┌──────────────┐
│ Markdown-Wiki│ → │ Chunking │ → │Embedding │ → │   SQLite     │
└──────────────┘   └──────────┘   └──────────┘   │  (Vectors)   │
                                                 └──────┬───────┘
                                                        ▼
      ┌──────────┐   ┌───────────────────────┐   ┌──────────────┐
      │  Query   │ → │ Query-Embedding       │ → │    Top-k     │
      └──────────┘   │ Cosine Similarity     │   └──────┬───────┘
                     └───────────────────────┘          ▼
                                                 ┌──────────────┐
                                                 │   Prompt     │
                                                 └──────┬───────┘
                                                        ▼
                                                 ┌──────────────┐
                                                 │ llama-server │
                                                 └──────────────┘

The pipeline in detail – step by step (PoC)

In the following Proof-of-Concept, we orchestrate the entire knowledge integration with just two small Python scripts. The files are deliberately kept minimal and are primarily intended to demonstrate the process, not represent a productive architecture:

  1. build_index.py — Read Wiki, create chunks, calculate embeddings, fill SQLite
  2. ask_rag.py — Embed user question, get top-k chunks, build context prompt and send to llama-server

Step 1 – Read Markdown

ℹ️ What's happening here?
We read in Markdown files, remove optional front matter and generate clean source text. No embeddings yet — just text.
from pathlib import Path

def strip_frontmatter(text: str) -> str:
    if text.startswith("---"):
        parts = text.split("---", 2)
        if len(parts) == 3:
            return parts[2].lstrip()
    return text

text = Path("wiki/intro.md").read_text(encoding="utf-8")
cleaned = strip_frontmatter(text)

Step 2 – Chunking

ℹ️ Why this step?
LLMs can work better with small, semantically stable sections. We break the text into blocks of ~200-350 tokens.
MAX_CHARS = 1200

def chunk_text(text: str):
    paragraphs = text.split("\n\n")   # richtiger Absatztrenner
    current = ""

    for para in paragraphs:
        if len(current) + len(para) + 2 <= MAX_CHARS:
            # Absatz anhängen
            current += ("\n\n" if current else "") + para
        else:
            # aktuelles Chunk ausgeben
            if current:
                yield current.strip()

            # zu großer Absatz -> hart splitten
            while len(para) > MAX_CHARS:
                yield para[:MAX_CHARS].strip()
                para = para[MAX_CHARS:]

            current = para

    # Rest ausgeben
    if current:
        yield current.strip()
💡 Optional: Use 50-150 token overlap
Many RAG systems use a small overlap when chunking (e.g. 50-150 tokens), Related content is not accidentally split into two chunks. Overlap is usually not necessary for clearly structured Markdown wikis, but can increase the quality of answers for technical documents.

Step 3 – Create embeddings

ℹ️ What's happening here?
Each chunk is translated into a float vector using llama-embedding.
import subprocess
import re

def run_embedding(text: str):
    CMD = "/home/mpeterma/Projekte/ggml-org/llama.cpp/build/bin/llama-embedding"
    MODEL = "/home/mpeterma/Library/models/Qwen/Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q5_k_m.gguf"

    proc = subprocess.run(
        [CMD, "-m", MODEL, "-p", text],
        capture_output=True, text=True
    )

    if proc.returncode != 0:
        raise RuntimeError(proc.stderr)

    for line in proc.stdout.splitlines():
        line = line.strip()
        if line.startswith("embedding 0:"):
            # nur die Zahlen extrahieren
            numbers = re.findall(r"-?\d+\.\d+", line)
            return [float(n) for n in numbers]

    raise RuntimeError("No embedding vector found in output")

Step 4 – SQLite as a knowledge base

ℹ️ What's happening here?
We store each chunk + embedding as JSON in SQLite. A completely transparent knowledge base is created.

Scheme

CREATE TABLE docs (
    id INTEGER PRIMARY KEY,
    path TEXT NOT NULL,
    chunk_index INTEGER NOT NULL,
    content TEXT NOT NULL,
    embedding TEXT NOT NULL
);

CREATE INDEX idx_docs_path ON docs(path);

Indexer

import sqlite3, json
from pathlib import Path

def index_wiki():
    conn = sqlite3.connect("knowledge.db")
    cur = conn.cursor()

    cur.execute("""
    CREATE TABLE IF NOT EXISTS docs (
        id INTEGER PRIMARY KEY,
        path TEXT NOT NULL,
        chunk_index INTEGER NOT NULL,
        content TEXT NOT NULL,
        embedding TEXT NOT NULL
    )
    """)

    for path in Path("./wiki").rglob("*.md"):
        text = strip_frontmatter(path.read_text(encoding="utf-8"))
        for idx, chunk in enumerate(chunk_text(text)):
            emb = run_embedding(chunk)
            cur.execute(
                "INSERT INTO docs (path, chunk_index, content, embedding) VALUES (?, ?, ?, ?)",
                (str(path), idx, chunk, json.dumps(emb))
            )
        conn.commit()

    conn.close()

if __name__ == "__main__":
    index_wiki()

Step 5 – Retrieval (embedding comparison)

ℹ️ What's happening here?
The user question is embedded itself and via cosine similarity compared against all chunk vectors in SQLite. Result: the Top-k most relevant knowledge modules.

Cosine Similarity

import math

def cosine(a, b):
    dot = sum(x*y for x, y in zip(a, b))
    na = math.sqrt(sum(x*x for x in a))
    nb = math.sqrt(sum(y*y for y in b))
    return dot / (na * nb) if na > 0 and nb > 0 else 0

Retrieval function

import json, sqlite3

def retrieve(query: str, top_k=5):
    # 1) Anfrage als Embedding
    q_emb = run_embedding(query)

    # 2) Alle Chunks aus DB ziehen
    conn = sqlite3.connect("knowledge.db")
    rows = conn.execute(
        "SELECT path, chunk_index, content, embedding FROM docs"
    ).fetchall()
    conn.close()

    # 3) Similarity berechnen
    scored = []
    for path, idx, content, emb_json in rows:
        emb = json.loads(emb_json)
        score = cosine(q_emb, emb)
        scored.append((score, content))

    # 4) Top-k zurückgeben
    scored.sort(reverse=True)
    return [content for score, content in scored[:top_k]]

Step 6 – Prompt construction + request to the llama server

ℹ️ What's happening here?
The top k chunks are combined into a context block. This creates a controlled, fact-based system prompt, which is then sent to the llama server. Answers are based solely on your own wiki.

Create promptly

def build_prompt(query: str, chunks: list[str]) -> str:
    context = "\n\n".join(chunks)
    return f"""
Du bist ein Assistent, der ausschließlich auf Basis des folgenden Wikis antwortet.
Wenn etwas nicht im Kontext steht, sag: "Dazu habe ich keine Information."

Kontext:
{context}

Frage: {query}
Antwort:
""".strip()

Request to the llama server

import requests

def ask_llama(query: str):
    chunks = retrieve(query, top_k=4)
    prompt = build_prompt(query, chunks)

    resp = requests.post(
        "http://localhost:8080/v1/completions",
        json={
            "model": "qwen2.5-3b",
            "prompt": prompt,
            "max_tokens": 600
        }
    )
    resp.raise_for_status()
    return resp.json()["choices"][0]["text"]

Step 7 - Main function with CLI arguments

ℹ️ What's happening here?
We’re adding a minimal CLI that takes a user question as an argument and provides an answer directly from the local LLM. This allows you to use your local RAG system like a real tool.
import sys

def main():
    if len(sys.argv) < 2:
        print("Usage: python3 rag.py \"Deine Frage\"")
        sys.exit(1)

    query = " ".join(sys.argv[1:])
    answer = ask_llama(query)
    print(answer)


if __name__ == "__main__":
    main()

End-to-end flow in use

ℹ️ Notice

The local llama-server must already be running for the request. It provides the model that generates the final response text.

Start example:

llama-server -m ./models/Qwen/Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q5_k_m.gguf --host 0.0.0.0 --port 8080

1. Build index (Markdown → Chunks → Embeddings → SQLite)

python3 build_index.py

2. Make a sample request (Query → Retrieval → Context → Response)

python3 ask_rag.py "Wie lege ich in Cyrus IMAP eine neue Mailbox an?"

3. Reply received

Mit cyradm kannst Du für Cyrus IMAP neue Mailboxen anlegen.
...
🤔 Rating the example answer

The example query “How do I create a new mailbox in Cyrus IMAP?” delivers one result – because I used to work intensively with Cyrus IMAP and my internal documentation contains precise and well-structured descriptions.

The result generated is content-correct, although – considering the very simple current pipeline – still clearly expandable. Despite the minimal architecture, however, the prototype shows: The approach works, and a first functional end-to-end breakthrough has been achieved.

⚠️ Reality comparison

The satisfactory result is also due to the fact that I already know the chunks contained and have consciously “provoked” the query as an initial test.

Other, less targeted queries currently deliver significantly weaker results. Robust usability requires not only fine-tuning, but also deeper technical re-sharpening:

  • Verification of all algorithms used – ideally with unit tests
  • Improvement of the database, as many chunks are currently cut unfavorably or uncleanly
  • Embedding quality, retrieval strategy and prompting

In short: The prototype shows potential, but it is still far from productive quality.


Conclusion

We have built an end-to-end, transparent knowledge pipeline stack:

  • Imported Markdown wiki
  • Chunks generated
  • Embeddings generated
  • Used SQLite as a transparent knowledge base
  • Query → Embedding → Cosine Similarity → Top-k
  • RAG prompting
  • Reply via llama-server
✅ Result

This creates a lean, fast and completely local knowledge integration — independent of cloud services, free of black box components and (for now) completely without a vector DB.

A stack that remains manageable, is auditable and can be expanded or replaced at any time.