A chatbot that answers from your own documents is one of the most useful AI features a business can add. The technique behind it is called retrieval-augmented generation, and you can build it with PostgreSQL and a few hundred lines of code.
Retrieval-Augmented Generation (RAG) combines an LLM with your own data — product docs, knowledge base, legal contracts — so the AI answers from your content instead of hallucinating. This guide walks through the full RAG stack: chunking, embeddings, vector databases (pgvector, Qdrant, Weaviate), and retrieval, running on your own VPS.
What RAG solves
A raw LLM only knows what it learned at training. It doesn't know:
- Your product manual written last month
- Your company's internal policies
- Your customer's support history
- Today's pricing
RAG fixes this in three steps:
- Index — split your docs into chunks, compute embeddings, store in a vector DB
- Retrieve — on user question, find the most relevant chunks by vector similarity
- Generate — send question + retrieved chunks to the LLM, which answers grounded in your data
End result: AI that speaks in your voice, from your source of truth.
Where each part runs on Domain India
| Component | Shared hosting | App Platform | VPS |
|---|---|---|---|
| LLM API calls (OpenAI, Anthropic) | Yes on cPanel; test cURL on DirectAdmin | Yes | Yes |
| Embedding API calls | Same as above | Yes | Yes |
| pgvector | No (you can't install database extensions) | PostgreSQL included; pgvector not confirmed, ask support | Yes, you install it |
| Qdrant, Weaviate | No | No (they are separate services) | Yes |
| Bulk indexing jobs | No (long-running processes are stopped) | Ask support | Yes |
For anything beyond a prototype, use a VPS: you need a database with vector support and somewhere to run indexing jobs. A Domain India VPS is self-managed, so you install and maintain the software yourself.
Step 1 — Choose a vector database
| Vector DB | Setup | Best for | Memory |
|---|---|---|---|
| pgvector | PostgreSQL extension | You already use PostgreSQL | Grows with vector count (see FAQ) |
| Qdrant | Single binary or container | Medium to large projects, filtering | Grows with vector count; on-disk mode available |
| Weaviate | Container | Larger scale, built-in hybrid search | Grows with vector count |
| Chroma | Python library, embedded | Prototypes and small collections | Grows with vector count |
For most small projects, pgvector is the winner: if you already run PostgreSQL, it is one extension and no new service to operate.
Step 2 — Install pgvector on your VPS
The easiest route on an AlmaLinux or Rocky Linux 9 VPS is the official PostgreSQL (PGDG) repository, which packages pgvector. Replace 17 with the PostgreSQL major version you want, and pick the repository file for your OS version from postgresql.org:
- Log in to the VPS over SSH as root, or as a user with sudo
- Add the PGDG repository, then install PostgreSQL and pgvector:
bash sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm sudo dnf -qy module disable postgresql sudo dnf install -y postgresql17-server pgvector_17 sudo /usr/pgsql-17/bin/postgresql-17-setup initdb sudo systemctl enable --now postgresql-17 - Enable the extension in your database:
bash sudo -u postgres psql -d your_db -c "CREATE EXTENSION IF NOT EXISTS vector;" - Verify:
SELECT '[1,2,3]'::vector;should return[1,2,3].
On Debian or Ubuntu, the PostgreSQL APT repository has an equivalent postgresql-17-pgvector package. You can also build pgvector from source (see its GitHub README).
Step 3 — Design your schema
CREATE TABLE documents (
id bigserial PRIMARY KEY,
source text NOT NULL, -- "docs/setup.md" or URL
chunk_index int NOT NULL, -- 0, 1, 2 ...
content text NOT NULL, -- the chunk itself
embedding vector(1536), -- must match your embedding model's dimensions
metadata jsonb, -- {category, author, date, ...}
created_at timestamptz DEFAULT now()
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON documents USING gin (metadata);HNSW is pgvector's approximate nearest-neighbour index: fast at scale. Plain vector columns can be indexed up to 2,000 dimensions; for larger embeddings, store them as halfvec or ask the model for fewer dimensions.
Step 4 — Chunk your documents
Naive approach: split by character count. Better: split by headings/paragraphs with overlap.
Python example using LangChain's text splitter (pip install langchain-text-splitters):
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "],
)
with open("docs/setup.md") as f:
chunks = splitter.split_text(f.read())
print(f"Got {len(chunks)} chunks")Rule of thumb: chunk size 500–1500 characters, overlap 10–20%. Smaller = more precise retrieval but more chunks; bigger = more context per chunk but less focused.
Step 5 — Compute embeddings
Embedding APIs are cheap compared with chat models, but check the provider's current price. This example uses OpenAI's text-embedding-3-small (1,536 dimensions) with psycopg 3 and the pgvector adapter (pip install openai "psycopg[binary]" pgvector numpy):
import numpy as np
import psycopg
from openai import OpenAI
from pgvector.psycopg import register_vector
from psycopg.types.json import Jsonb
client = OpenAI()
def embed(text):
return np.array(client.embeddings.create(
model="text-embedding-3-small",
input=text,
).data[0].embedding)
conn = psycopg.connect("dbname=your_db")
register_vector(conn)
cur = conn.cursor()
for i, chunk in enumerate(chunks):
cur.execute(
"INSERT INTO documents (source, chunk_index, content, embedding, metadata) "
"VALUES (%s, %s, %s, %s, %s)",
("docs/setup.md", i, chunk, embed(chunk), Jsonb({"category": "setup"})),
)
conn.commit()Batch embeddings. Send many chunks per call (OpenAI accepts a list of inputs, within per-request limits). You pay the same per token, but it is far faster and uses far fewer requests against your rate limit:
response = client.embeddings.create(
model="text-embedding-3-small",
input=chunks_batch # list of strings
)
vecs = [np.array(item.embedding) for item in response.data]Re-embedding everything on every update wastes money. Store a hash of each chunk's text next to its embedding, and only re-embed chunks whose hash has changed. If you switch embedding models, re-embed everything: vectors from different models can't be compared.
Step 6 — Retrieve
On user question, embed the question and find similar chunks:
def retrieve(question, k=5):
q_vec = embed(question)
cur.execute(
"""
SELECT content, metadata,
1 - (embedding <=> %(q)s) AS similarity
FROM documents
ORDER BY embedding <=> %(q)s
LIMIT %(k)s
""",
{"q": q_vec, "k": k},
)
return cur.fetchall()
question = "How do I reset my password?"
results = retrieve(question)
for chunk, meta, sim in results:
print(f"[{sim:.3f}] {chunk[:100]}...")<=> is pgvector's cosine distance operator: lower means more similar.
Step 7 — Generate the answer
Send question + retrieved chunks to the LLM:
import os
context = "\n---\n".join(chunk for chunk, _, _ in results)
completion = client.chat.completions.create(
model=os.environ["OPENAI_MODEL"], # a current model ID from the provider's models page
messages=[
{"role": "system", "content": (
"You are a helpful assistant answering questions from the provided context. "
"If the answer isn't in the context, say 'I don't know'. Don't make things up."
)},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
],
)
print(completion.choices[0].message.content)Hybrid search — better than pure vector
Pure vector retrieval misses exact matches (product codes, SKUs, error numbers). Combine it with PostgreSQL full-text search:
SELECT content, metadata
FROM documents
WHERE (
embedding <=> %(q)s < 0.3 -- semantic match
OR to_tsvector('english', content) @@ websearch_to_tsquery('english', %(text)s) -- keyword match
)
ORDER BY (
0.7 * (1 - (embedding <=> %(q)s))
+ 0.3 * ts_rank(to_tsvector('english', content), websearch_to_tsquery('english', %(text)s))
) DESC
LIMIT 5;websearch_to_tsquery accepts raw user text safely, where to_tsquery throws errors on stray symbols. For larger tables, add a generated tsvector column with a GIN index, or run the vector and keyword searches separately and merge them with reciprocal rank fusion, so both indexes are used.
Evaluating retrieval quality
Build a small eval set — 20–50 question-answer pairs from your actual docs. Measure:
- Recall@5 — is the right chunk in the top 5? Target >80%.
- Precision@5 — of the top 5, how many are relevant?
- Answer accuracy — does the LLM give correct answers?
Tools: RAGAS (Python lib) measures all of these automatically.
Common pitfalls
FAQ
pgvector or Qdrant?
pgvector if you already use PostgreSQL and your collection is in the millions of chunks or fewer: one database, transactions, and joins with your other data. Qdrant or another dedicated vector database if you need very high query rates, advanced filtering or multi-tenancy at scale. For most small projects, pgvector is enough.
How much RAM does a vector database need?
Do the arithmetic. A 1,536-dimension vector stored as 32-bit floats is about 6 KB, so one million vectors is roughly 6 GB before the HNSW index, which adds more. For good speed the index should fit in RAM. To shrink it, use halfvec (half the size), an embedding model with fewer dimensions, or a vector database with on-disk or quantised storage.
Can I use open-source embeddings instead of OpenAI?
Yes. The sentence-transformers library runs embedding models locally on CPU with no API cost. Small models such as all-MiniLM-L6-v2 need little RAM but give lower quality than larger or newer models; test several on your own questions and check the model's licence.
How do I stop the LLM from hallucinating even with RAG?
Use a strict system prompt ("If the answer is not in the context, say you don't know"), retrieve fewer but better chunks, and return citations (the source of each chunk) so users can check. Measure answer accuracy on an evaluation set rather than trusting spot checks.
What about Claude instead of OpenAI?
It works the same way. Anthropic doesn't offer its own embedding model, so pair Claude for generation with any embedding provider or a local embedding model. The embeddings and the chat model don't need to come from the same company.
Running this on Domain India
- VPS: self-managed with full root access, so you can install PostgreSQL with pgvector, Qdrant or Weaviate, and run indexing jobs with cron or systemd timers. Size RAM to your vector count (see the FAQ).
- Backups: VPS plans include no backups or snapshots. Dump the database regularly (
pg_dump) and copy it off the server. - Shared hosting: you can host the chat front end and call LLM APIs (PHP cURL works on cPanel; test it on DirectAdmin), but you can't install pgvector, and long-running indexing jobs are stopped.
- App Platform: includes PostgreSQL on every plan. Whether pgvector is enabled there is not confirmed, so ask support before you design around it.
Ready to build? Read the OpenAI and Claude API integration guide for production patterns, see fine-tuning with LoRA for when RAG isn't enough, or choose a Domain India VPS. For hosting questions, open a ticket.
A self-managed VPS with full root access for PostgreSQL and pgvector, your indexing jobs and your chat backend.
View VPS plans