- Posted on
- • Artificial Intelligence
Build Your First RAG Pipeline
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Build Your First RAG Pipeline on Linux (with Bash-first ergonomics)
What if your terminal could answer questions about your own docs with the clarity of a seasoned teammate—and without leaking data to the internet? That’s the promise of Retrieval-Augmented Generation (RAG): pair a language model with a private, searchable index of your documents, so responses are grounded in your data instead of guesses.
In this guide, you’ll build a minimal, working RAG pipeline on Linux using simple Bash commands and a few small Python scripts. You’ll ingest docs, embed and index them locally, retrieve relevant chunks for a question, and generate answers using either a local LLM (via Ollama) or a hosted API.
You’ll end with a repeatable workflow you can aim at logs, wikis, PDFs, READMEs—whatever text you care about.
Why RAG (and why now)?
Hallucinations cost time. RAG grounds answers in your data to reduce guesswork.
Privacy by default. Keep proprietary documentation and notes on your machine.
Lightweight and flexible. You don’t need a massive stack: a vector store (Chroma), an embedding model, and a generator (local or API) is enough.
Linux-native flow. Use Bash to wire everything together; keep scripts small, auditable, and portable.
1) Install prerequisites
We’ll use:
Python 3 + venv, pip
Basic build tools
Optional PDF-to-text (pdftotext via Poppler)
Git, curl, jq, unzip
Optional: Ollama for a local model
System packages (choose your distro):
Ubuntu/Debian (apt):
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git curl jq unzip gcc build-essential pkg-config
# Optional: PDF to text
sudo apt install -y poppler-utils
Fedora/RHEL/CentOS (dnf):
sudo dnf install -y python3 python3-virtualenv python3-pip git curl jq unzip gcc gcc-c++ make pkgconfig
# Optional: PDF to text
sudo dnf install -y poppler-utils
openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y python3 python3-pip python3-venv git curl jq unzip gcc gcc-c++ make pkg-config
# Optional: PDF to text (note the package name)
sudo zypper install -y poppler-tools
Create a project and virtual environment:
mkdir -p ~/rag-lab && cd ~/rag-lab
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
Python packages:
pip install chromadb sentence-transformers requests tqdm
Optional: Install Ollama (local LLM runtime):
curl -fsSL https://ollama.com/install.sh | sh
# Pull a small, capable model (choose one):
ollama pull llama3 # good general model
# or
ollama pull mistral
Tip: You can also use a hosted API (e.g., OpenAI). If so, export your key:
export OPENAI_API_KEY="sk-..."
If you’ll use Ollama, export a model name (or keep the default in the script):
export OLLAMA_MODEL="llama3"
2) Prepare documents and index them
Create a docs directory and drop in some files (Markdown, TXT, and optional PDF). For a quick test:
mkdir -p docs
curl -L https://raw.githubusercontent.com/docker/docs/main/get-started/overview.md -o docs/docker_overview.md
curl -L https://raw.githubusercontent.com/redis/redis/unstable/README.md -o docs/redis_readme.md
Optional PDF example (if you installed pdftotext):
- Place any PDF in docs/, e.g., docs/example.pdf
Now create a script to ingest and embed documents into a local Chroma DB.
Save as ingest.py:
#!/usr/bin/env python3
import os, sys, subprocess, uuid
from pathlib import Path
from typing import List
from tqdm import tqdm
import chromadb
from sentence_transformers import SentenceTransformer
DOC_DIR = Path("docs")
DB_DIR = Path("rag_store")
COLLECTION_NAME = "docs"
def read_text_from_file(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in [".md", ".txt", ".rst"]:
try:
return path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return path.read_text(errors="ignore")
elif suffix == ".pdf":
# Requires pdftotext (poppler-utils/poppler-tools)
try:
out = subprocess.check_output(
["pdftotext", "-layout", "-nopgbrk", str(path), "-"],
stderr=subprocess.DEVNULL
)
return out.decode("utf-8", errors="ignore")
except Exception as e:
print(f"Warning: could not convert PDF {path}: {e}", file=sys.stderr)
return ""
else:
return ""
def chunk_by_words(text: str, chunk_size: int = 200, overlap: int = 40) -> List[str]:
words = text.split()
chunks = []
start = 0
while start < len(words):
end = min(start + chunk_size, len(words))
chunk = " ".join(words[start:end])
if chunk.strip():
chunks.append(chunk)
if end == len(words):
break
start = end - overlap
if start < 0:
start = 0
return chunks
def main():
if not DOC_DIR.exists():
print(f"Missing {DOC_DIR}/", file=sys.stderr)
sys.exit(1)
client = chromadb.PersistentClient(path=str(DB_DIR))
collection = client.get_or_create_collection(name=COLLECTION_NAME)
model_name = os.environ.get("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
embedder = SentenceTransformer(model_name)
files = [p for p in DOC_DIR.rglob("*") if p.is_file()]
if not files:
print(f"No files found in {DOC_DIR}/", file=sys.stderr)
sys.exit(1)
for path in tqdm(files, desc="Indexing"):
text = read_text_from_file(path)
if not text.strip():
continue
chunks = chunk_by_words(text, chunk_size=220, overlap=50)
ids = []
metas = []
docs = []
for i, chunk in enumerate(chunks):
ids.append(f"{path.relative_to(DOC_DIR)}::chunk{i}::{uuid.uuid4().hex[:8]}")
metas.append({"source": str(path.relative_to(DOC_DIR)), "chunk": i})
docs.append(chunk)
if docs:
embs = embedder.encode(docs, normalize_embeddings=True).tolist()
collection.add(ids=ids, documents=docs, metadatas=metas, embeddings=embs)
print(f"Indexed {len(files)} files into {DB_DIR}/")
if __name__ == "__main__":
main()
Make it executable and run:
chmod +x ingest.py
./ingest.py
What you just did:
Converted files to plain text (PDFs via pdftotext, others directly).
Chunked documents into ~220-word slices with slight overlap.
Generated embeddings with a small, fast model.
Stored embeddings and metadata in a persistent Chroma DB at ./rag_store.
3) Retrieve relevant chunks and generate an answer
Create query.py that:
Embeds your question
Retrieves top-N relevant chunks
Builds a prompt and sends it to either Ollama or a hosted API
Prints the final answer (and optionally the sources)
Save as query.py:
#!/usr/bin/env python3
import os, sys, json
from typing import List, Dict
import chromadb
from sentence_transformers import SentenceTransformer
import requests
DB_DIR = "rag_store"
COLLECTION_NAME = "docs"
def get_collection():
client = chromadb.PersistentClient(path=DB_DIR)
return client.get_or_create_collection(name=COLLECTION_NAME)
def retrieve(question: str, k: int = 5):
collection = get_collection()
embed_model = os.environ.get("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
embedder = SentenceTransformer(embed_model)
q_emb = embedder.encode([question], normalize_embeddings=True).tolist()
res = collection.query(query_embeddings=q_emb, n_results=k, include=["documents","metadatas","distances"])
docs = res.get("documents", [[]])[0]
metas = res.get("metadatas", [[]])[0]
dists = res.get("distances", [[]])[0]
return list(zip(docs, metas, dists))
def build_prompt(question: str, contexts: List[Dict]) -> str:
parts = []
for i, (doc, meta, dist) in enumerate(contexts, 1):
src = meta.get("source", "unknown")
parts.append(f"[Source {i}: {src}] {doc}")
context_block = "\n\n---\n".join(parts)
prompt = (
"You are a helpful assistant. Use ONLY the context below to answer. "
"If the answer is not contained in the context, say you don't know.\n\n"
f"Context:\n{context_block}\n\n"
f"Question: {question}\n"
"Answer concisely:"
)
return prompt
def call_ollama(prompt: str, model: str = "llama3") -> str:
url = "http://localhost:11434/api/generate"
payload = {"model": model, "prompt": prompt, "stream": False}
r = requests.post(url, json=payload, timeout=600)
r.raise_for_status()
data = r.json()
return data.get("response", "").strip()
def call_openai(prompt: str, model: str = "gpt-4o-mini") -> str:
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY not set")
url = "https://api.openai.com/v1/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
body = {
"model": model,
"messages": [
{"role": "system", "content": "You are a helpful assistant that answers using provided context only."},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
}
r = requests.post(url, headers=headers, data=json.dumps(body), timeout=600)
r.raise_for_status()
data = r.json()
return data["choices"][0]["message"]["content"].strip()
def main():
if len(sys.argv) < 2:
print("Usage: ./query.py \"your question\" [top_k]", file=sys.stderr)
sys.exit(1)
question = sys.argv[1]
top_k = int(sys.argv[2]) if len(sys.argv) > 2 else 5
hits = retrieve(question, k=top_k)
if not hits:
print("No results. Did you run ./ingest.py and add docs/?", file=sys.stderr)
sys.exit(1)
prompt = build_prompt(question, hits)
answer = ""
try:
if os.environ.get("OLLAMA_MODEL"):
answer = call_ollama(prompt, os.environ["OLLAMA_MODEL"])
elif os.environ.get("OPENAI_API_KEY"):
answer = call_openai(prompt)
else:
# Fallback: show the prompt so you can pipe it to an LLM manually
print("No generator configured (set OLLAMA_MODEL or OPENAI_API_KEY).")
print("\n----- PROMPT BELOW -----\n")
print(prompt)
sys.exit(0)
except Exception as e:
print(f"Generation error: {e}", file=sys.stderr)
sys.exit(2)
# Print answer and sources
print("Answer:\n")
print(answer)
print("\nSources:")
for i, (_, meta, dist) in enumerate(hits, 1):
print(f"- [{i}] {meta.get('source')} (similarity ~ {1 - dist:.3f})")
if __name__ == "__main__":
main()
Make it executable:
chmod +x query.py
Ask your first question:
export OLLAMA_MODEL="llama3" # or ensure OPENAI_API_KEY is set
./query.py "What is Docker used for?"
You should see a concise answer with a list of sources pointing to the chunks retrieved from your docs.
4) Real-world usage patterns
Here are a few concrete ways to point this at real data:
Team knowledge base
- Export Confluence/Notion pages as Markdown and drop them in docs/.
- Re-run
./ingest.pyas content changes, or put it in a cron job.
Incident timelines and logs
- Convert key timelines to .txt and store in docs/incidents/.
- Query:
./query.py "What remediations did we try for the TLS outage?"
Developer onboarding
- Include READMEs from multiple services.
- Query:
./query.py "How do I run the API service locally with a seeded DB?"
Mixed PDFs
- Add postmortems or vendor docs as PDFs. Ensure pdftotext is installed.
- Query:
./query.py "Which Redis config is recommended for persistence?"
Tip: For repeat indexing, you can start by deleting the store:
rm -rf rag_store && ./ingest.py
5) Common tweaks and troubleshooting
Speed vs accuracy
- Increase chunk size for more context per chunk; increase top_k to retrieve more chunks.
- Try a different embedding model via
export EMBED_MODEL="sentence-transformers/all-mpnet-base-v2".
GPU acceleration
- If you have a GPU, sentence-transformers can leverage it when installed with the right PyTorch build; consult PyTorch’s install matrix.
PDFs not indexing
- Make sure pdftotext is installed:
- apt:
sudo apt install -y poppler-utils - dnf:
sudo dnf install -y poppler-utils - zypper:
sudo zypper install -y poppler-tools
Local model not responding
- Ensure Ollama is running:
sudo systemctl status ollama(or restartsudo systemctl restart ollama) and that your model is pulled:ollama list.
- Ensure Ollama is running:
Verify the retrieval step alone
- Temporarily comment out the generation calls and print the prompt to inspect the context that’s fed to the model.
Summary and next steps
You just built a minimal, end-to-end RAG pipeline on Linux:
Ingest and chunk your docs
Embed and index locally with Chroma
Retrieve relevant chunks
Generate grounded answers with a local or hosted LLM
From here, you can:
Add a simple REST API or TUI wrapper around query.py
Store source URLs and add clickable references
Swap in different embedding/LLM models
Schedule incremental re-indexing with a Makefile or systemd timer
Call to action:
- Point this at a folder you actually use at work—playbooks, READMEs, or SOPs—then ask 3 questions that normally take you 10+ minutes to answer. Measure the time saved, and iterate.