Building a Knowledge Base Chatbot with llama.cpp: Complete Guide
Large language models are powerful, but they are frozen in time at the moment of their training. When users ask about proprietary documents, internal wikis, or recent events, even the largest models hallucinate or simply refuse to answer. The solution is retrieval-augmented generation (RAG): a pattern where relevant context is fetched from a knowledge base and injected into the model's prompt before it generates a response. In this guide, we will build a fully functional knowledge base chatbot using llama.cpp, the lightweight C++ inference engine that lets you run quantized LLMs on commodity hardware.
What Is llama.cpp and Why Use It for a Knowledge Base Chatbot?
llama.cpp is an open-source C/C++ library originally created to run Meta's LLaMA models on consumer CPUs. It has since grown into a general-purpose inference engine supporting GGUF-formatted models from many families: Llama, Mistral, Phi, Qwen, Gemma, and more. It exposes a simple HTTP server with an OpenAI-compatible API, making it easy to plug into existing tooling.
For a knowledge base chatbot, llama.cpp offers several advantages:
- Low resource footprint: Quantized 7B models run comfortably on a laptop with 8GB of RAM.
- No GPU required: CPU inference works out of the box, with optional CUDA, Metal, and ROCm acceleration.
- Data privacy: Everything runs locally, so sensitive documents never leave your machine.
- OpenAI-compatible API: You can swap remote API calls for local inference with minimal code changes.
- Production-ready server: The bundled
llama-serverbinary handles concurrent requests, streaming, and chat templating.
Architecture Overview
Our chatbot will follow a classic RAG architecture with four components:
- Document store: A folder of text or markdown files representing the knowledge base.
- Embedding index: A vector database built from chunked documents using an embedding model.
- Retriever: Logic that converts the user's question into an embedding and finds the most similar chunks.
- Generator:
llama.cppreceives the retrieved chunks as context and produces the final answer.
We will use Python for orchestration because it has excellent libraries for embeddings and vector storage, while llama.cpp handles the heavy lifting of generation. The same pattern can be implemented in any language that can speak HTTP.
Prerequisites and Installation
Before writing code, install the required components. You will need Python 3.10 or newer, a C++ compiler, and cmake.
First, clone and build llama.cpp:
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
cmake -B build -DGGML_NATIVE=ON
cmake --build build --config Release -j
Next, download a chat model in GGUF format. A good starting point is a quantized Llama 3 8B model, which balances quality and speed. Place the .gguf file in a known path, for example ./models/llama-3-8b-instruct-q4_k_m.gguf.
Install the Python dependencies:
pip install sentence-transformers numpy
We will use sentence-transformers for embeddings and a simple NumPy-based vector store to keep the tutorial dependency-free. For production, you would swap this for Chroma, FAISS, or Qdrant.
Step 1: Preparing the Knowledge Base
Create a directory called knowledge_base and add some text files. For this tutorial, imagine a small company FAQ. Here is an example file at knowledge_base/leave_policy.txt:
Annual Leave Policy
====================
Employees are entitled to 25 days of paid annual leave per calendar year.
Unused leave can be carried over up to 5 days into the next year.
Leave requests must be submitted at least 14 days in advance through the HR portal.
Sick leave does not count against annual leave and requires a doctor's note after 3 consecutive days.
Add a few more files covering topics like remote work, expenses, and onboarding. The more documents you add, the more useful the chatbot becomes.
Step 2: Chunking and Embedding Documents
LLMs have limited context windows, so we cannot feed entire documents into the prompt. Instead, we split documents into smaller chunks, embed each chunk, and store the embeddings for later retrieval. A chunk size of around 300 to 500 characters works well for short FAQ-style documents.
Create a file called build_index.py:
import os
import json
import numpy as np
from sentence_transformers import SentenceTransformer
KB_DIR = "knowledge_base"
CHUNK_SIZE = 400
CHUNK_OVERLAP = 50
EMBED_MODEL = "all-MiniLM-L6-v2"
def chunk_text(text, size=CHUNK_SIZE, overlap=CHUNK_OVERLAP):
chunks = []
start = 0
while start < len(text):
end = start + size
chunks.append(text[start:end])
start += size - overlap
return chunks
def main():
model = SentenceTransformer(EMBED_MODEL)
documents = []
for filename in os.listdir(KB_DIR):
path = os.path.join(KB_DIR, filename)
if not os.path.isfile(path):
continue
with open(path, "r", encoding="utf-8") as f:
text = f.read()
for i, chunk in enumerate(chunk_text(text)):
documents.append({
"id": f"{filename}_{i}",
"source": filename,
"text": chunk
})
texts = [d["text"] for d in documents]
embeddings = model.encode(texts, normalize_embeddings=True)
embeddings = np.array(embeddings, dtype=np.float32)
np.save("embeddings.npy", embeddings)
with open("documents.json", "w", encoding="utf-8") as f:
json.dump(documents, f, ensure_ascii=False, indent=2)
print(f"Indexed {len(documents)} chunks from {len(os.listdir(KB_DIR))} files.")
if __name__ == "__main__":
main()
Run the script to build the index:
python build_index.py
This produces two files: embeddings.npy containing the vector matrix and documents.json mapping each vector back to its source text. The all-MiniLM-L6-v2 model produces 384-dimensional embeddings, which is small and fast while still performing well on semantic similarity tasks.
Step 3: Starting the llama.cpp Server
With the index built, start the llama.cpp server. The server binary is located in build/bin/ after compilation. Launch it with your chosen model:
./build/bin/llama-server \
-m ./models/llama-3-8b-instruct-q4_k_m.gguf \
--host 0.0.0.0 \
--port 8080 \
-c 8192 \
-t 4 \
-ngl 0
Key flags explained:
-m: Path to the GGUF model file.-c 8192: Context window size in tokens. Larger windows allow more retrieved context.-t 4: Number of CPU threads to use.-ngl 0: Number of layers to offload to GPU. Set to 0 for pure CPU inference, or higher if you have a GPU.
Once running, the server exposes an OpenAI-compatible endpoint at http://localhost:8080/v1/chat/completions. You can test it with curl:
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello!"}]}'
Step 4: Building the Retriever
The retriever takes a user question, embeds it using the same model, and finds the most similar document chunks via cosine similarity. Because we normalized embeddings during indexing, cosine similarity reduces to a simple dot product.
Create retriever.py:
import json
import numpy as np
from sentence_transformers import SentenceTransformer
class Retriever:
def __init__(self, embeddings_path="embeddings.npy", docs_path="documents.json"):
self.embeddings = np.load(embeddings_path)
with open(docs_path, "r", encoding="utf-8") as f:
self.documents = json.load(f)
self.model = SentenceTransformer("all-MiniLM-L6-v2")
def search(self, query, top_k=3):
query_vec = self.model.encode([query], normalize_embeddings=True)
scores = self.embeddings @ query_vec.T
scores = scores.flatten()
top_indices = np.argsort(scores)[::-1][:top_k]
results = []
for idx in top_indices:
results.append({
"text": self.documents[idx]["text"],
"source": self.documents[idx]["source"],
"score": float(scores[idx])
})
return results
if __name__ == "__main__":
r = Retriever()
for hit in r.search("How many days of annual leave do I get?"):
print(f"[{hit['score']:.3f}] {hit['source']}: {hit['text'][:80]}")
Run it to verify retrieval works:
python retriever.py
You should see the leave policy chunk ranked highest, confirming that semantic search is functioning correctly.
Step 5: Assembling the Chatbot
Now we combine the retriever with the llama.cpp server. The chatbot follows a simple loop: receive a question, retrieve relevant chunks, construct a prompt with the context, send it to the model, and stream the answer back to the user.
Create chatbot.py:
import json
import requests
from retriever import Retriever
LLAMA_URL = "http://localhost:8080/v1/chat/completions"
MODEL_NAME = "local-llama"
TOP_K = 3
SYSTEM_PROMPT = """You are a helpful assistant that answers questions based strictly on the provided context.
If the answer is not contained in the context, say you do not know. Do not make up information.
Always cite the source file at the end of your answer."""
def build_context(hits):
context_parts = []
for i, hit in enumerate(hits, 1):
context_parts.append(f"[{i}] (Source: {hit['source']})\n{hit['text']}")
return "\n\n".join(context_parts)
def ask_question(retriever, question, history=None):
hits = retriever.search(question, top_k=TOP_K)
context = build_context(hits)
user_content = f"Context:\n{context}\n\nQuestion: {question}"
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if history:
messages.extend(history)
messages.append({"role": "user", "content": user_content})
payload = {
"model": MODEL_NAME,
"messages": messages,
"temperature": 0.3,
"max_tokens": 512,
"stream": False
}
response = requests.post(LLAMA_URL, json=payload)
response.raise_for_status()
answer = response.json()["choices"][0]["message"]["content"]
sources = [hit["source"] for hit in hits]
return answer, sources, messages
def main():
retriever = Retriever()
history = []
print("Knowledge Base Chatbot (type 'quit' to exit)\n")
while True:
question = input("You: ").strip()
if question.lower() in ("quit", "exit"):
break
if not question:
continue
answer, sources, messages = ask_question(retriever, question, history)
print(f"\nBot: {answer}")
print(f"Sources: {', '.join(sources)}\n")
history = messages
history.append({"role": "assistant", "content": answer})
history = history[-10:]
if __name__ == "__main__":
main()
Run the chatbot:
python chatbot.py
Try asking questions like "How many days of annual leave do I get?" or "What is the policy on remote work?" The chatbot will retrieve the relevant chunks, pass them as context to llama.cpp, and return a grounded answer with source citations.
Step 6: Adding Streaming Responses
For a better user experience, enable streaming so the answer appears token by token. Update the ask_question function to use server-sent events:
def ask_question_stream(retriever, question, history=None):
hits = retriever.search(question, top_k=TOP_K)
context = build_context(hits)
user_content = f"Context:\n{context}\n\nQuestion: {question}"
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if history:
messages.extend(history)
messages.append({"role": "user", "content": user_content})
payload = {
"model": MODEL_NAME,
"messages": messages,
"temperature": 0.3,
"max_tokens": 512,
"stream": True
}
full_response = ""
with requests.post(LLAMA_URL, json=payload, stream=True) as response:
response.raise_for_status()
for line in response.iter_lines():
if not line:
continue
line = line.decode("utf-8")
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
break
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"].get("content", "")
full_response += delta
print(delta, end="", flush=True)
print()
return full_response, [hit["source"] for hit in hits], messages
Replace the call in main() with ask_question_stream to see the streamed output in your terminal.
Best Practices
Building a working chatbot is only the beginning. The following practices will help you move from a prototype to a reliable system.
- Tune chunk size carefully. Too small and you lose context; too large and you waste the context window. Experiment with 200 to 600 characters and measure retrieval accuracy on a small evaluation set.
- Use overlap between chunks. A 10 to 20 percent overlap prevents relevant information from being split across chunk boundaries.
- Choose the right embedding model.
all-MiniLM-L6-v2is fast, but larger models likebge-large-en-v1.5ore5-large-v2offer better retrieval quality for complex corpora. - Keep temperature low. For factual Q&A, a temperature of 0.1 to 0.3 reduces hallucination. Reserve higher temperatures for creative tasks.
- Constrain the model with a strong system prompt. Explicitly instruct it to refuse when context is insufficient. This dramatically reduces fabricated answers.
- Persist conversation history with limits. Keep only the last few turns to avoid blowing up the context window and slowing inference.
- Evaluate retrieval separately from generation. If the retriever returns irrelevant chunks, no prompt engineering will save the answer. Log retrieved chunks and inspect them regularly.
- Use GPU offload when available. Even offloading 20 to 30 layers with
-nglcan cut generation time by half on supported hardware. - Rebuild the index when documents change. Treat the embedding index as a build artifact. Add a checksum or timestamp check so stale indexes are detected automatically.
- Consider hybrid search. Combine semantic vector search with keyword search (BM25) for corpora with lots of proper nouns, codes, or IDs that embedding models handle poorly.
Production Considerations
When moving beyond a local prototype, consider these upgrades. Replace the NumPy vector store with a dedicated database like Chroma, Qdrant, or pgvector for persistence and fast approximate nearest neighbor search. Containerize the llama.cpp server using Docker so deployments are reproducible. Add authentication in front of the server endpoint to prevent unauthorized access. Implement rate limiting and request queuing, since LLM inference is expensive and a single slow request can block others. Finally, instrument the chatbot with logging that captures the question, retrieved chunks, model response, and latency so you can continuously monitor quality.
Conclusion
Building a knowledge base chatbot with llama.cpp gives you a fully local, privacy-preserving, and cost-effective alternative to hosted LLM APIs. By combining semantic retrieval with a quantized open-source model, you can ground answers in your own documents while keeping complete control over the infrastructure. The architecture presented here, chunking, embedding, retrieval, and generation, is the foundation of virtually every RAG system, and each component can be upgraded independently as your needs grow. Start with the simple NumPy store and a single GGUF model, then scale up to a vector database, GPU acceleration, and hybrid search when your corpus and user base demand it. The most important step is to build a small evaluation set early, so every change you make can be measured against real questions and real answers.