Retrieval-Augmented Agents on a Single VPS
Ground agent answers in local files and SQLite on one Lucknow VPS, then call the model API only with retrieved chunks instead of the whole corpus.
Netbay Infrastructure Team
Netbay Engineering
On this page
An agent that cannot look things up will invent them. An agent that loads the whole wiki into the prompt will invent them slowly and expensively. Retrieval-augmented generation on one VPS is the boring middle: chunk your runbooks, store them in SQLite FTS5 on High-Speed SSD, retrieve a handful of passages, then call the remote model API with those passages and a strict citation rule.
You do not need a vector database product and you do not need a GPU from your VPS provider — Netbay does not sell one, and you should not wait for it. BM25 via FTS5 plus a local embedding optional extra is enough for runbooks, ticket macros, and API docs that fit on a disk. Intel Xeon Platinum in Lucknow will tokenize faster than you can type.
Chunk on ingest, not at query time
Split on headings and size, not on a vibe. A chunk is 200 to 400 words, tagged with source path, heading, and a hash of the bytes. Re-ingest when the hash changes. Keep the raw file; FTS5 stores the text you search, not the file you edit.
# /opt/agents/rag_ingest.py
import hashlib, sqlite3, re
from pathlib import Path
DB = Path("/var/lib/agents/rag.sqlite")
def chunks_of(text, source):
parts = re.split(r"(?m)^## ", text)
out = []
for i, part in enumerate(parts):
body = part.strip()
if len(body) < 40:
continue
cid = hashlib.sha256((source + str(i) + body).encode()).hexdigest()[:16]
out.append((cid, source, body[:2400]))
return out
def ingest(root):
con = sqlite3.connect(DB)
con.execute("CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(id, source, body)")
con.execute("DELETE FROM docs")
for path in Path(root).rglob("*.md"):
for cid, source, body in chunks_of(path.read_text(encoding="utf-8"), str(path)):
con.execute("INSERT INTO docs (id, source, body) VALUES (?, ?, ?)", (cid, source, body))
con.commit()Run ingest from a systemd path unit whenever /srv/runbooks changes. Do not let the agent write the corpus. The agent is a reader. If a worker is allowed to edit runbooks, that is a different role with a git commit, not an UPDATE in SQLite.
Retrieve, then prompt, then refuse
Query FTS5 with the user question plus two or three keywords the supervisor already extracted. Take the top 5 rows. If the best rank is weak, refuse instead of answering from model prior. The refusal is the feature: "no runbook hit" is better than a confident wrong flag.
# /opt/agents/rag_query.py
import sqlite3, json
DB = "/var/lib/agents/rag.sqlite"
def retrieve(q, k=5):
con = sqlite3.connect(DB)
rows = con.execute(
"SELECT id, source, body, rank FROM docs WHERE docs MATCH ? ORDER BY rank LIMIT ?",
(q, k),
).fetchall()
return [{"id": r[0], "source": r[1], "body": r[2]} for r in rows]
def build_prompt(question, hits):
if not hits:
return None
blocks = []
for h in hits:
blocks.append("SOURCE " + h["id"] + " " + h["source"] + "
" + h["body"])
return (
"Answer only from the SOURCE blocks. Cite SOURCE ids. "
"If the blocks do not contain the answer, say NOT_FOUND.
"
+ "
".join(blocks)
+ "
QUESTION: "
+ question
)Pass build_prompt() as the user message. Keep the system message short: no tools except cite. If you also have an agent that can open tickets, that is a second hop after a cited answer, not a tool the retriever may call while it is still reading.
One box, three directories
A workable layout on a single VPS:
- /srv/runbooks — git working copy, owned by git-sync, mode 0750.
- /var/lib/agents/rag.sqlite — FTS index, owned by agent-rag, mode 0640.
- /var/log/agents/rag.jsonl — query, hit ids, NOT_FOUND flag, latency.
The model API key lives in /etc/agents/rag.env, read by the rag worker unit only. Do not put the corpus on a network share; High-Speed SSD local disk keeps FTS queries in the low milliseconds, which is the whole reason this fits on one box.
L3/L4 filtering on the public NIC does not protect the index. Bind the query HTTP listener to 127.0.0.1 and let your existing app proxy it, or do not expose it at all and call retrieve() in-process.
When FTS5 is enough, and when it is not
FTS5 is enough for named runbooks, error strings, and "how do I rotate the session cookie." It is weak for "issues like last Tuesday's timeout" unless you ingested that incident. Do not compensate by stuffing six months of tickets into the prompt. Ingest a one-page incident template instead.
If you later add embeddings, keep them as a second table keyed by the same chunk id. Hybrid retrieve: FTS first, then rerank the 20 hits with cosine if you must. Do not start with a dense index of 40,000 tokens of marketing copy. Garbage in, confident garbage out.
Re-ingest is a batch, not a tool. A path unit or a 5-minute timer that diffs hashes is enough. If ingest fails, serve the previous SQLite file; do not query a half-written index. Copy rag.sqlite to rag.sqlite.new, then rename. Readers using sqlite3.connect will see a consistent file. That is the entire zero-downtime story on one VPS.
Takeaway: chunk, index locally, retrieve a few passages, and make NOT_FOUND a first-class answer. That is retrieval-augmented agents on one VPS, not a science project. An Ubuntu 24.04 box in Lucknow DC01 from Netbay is enough to follow along — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance