Implementation Plan — Local-Model RAG on the 1 vCPU / 2 GB vServer
Goal: Run the AI Chat API (tools/docs-vector-search) with local models — embedding + reranking + generation in-process on the 1 vCPU / 2 GB box — replacing the hosted OpenAI calls, while keeping a hosted fallback for generation when RAM is tight.
Targets: the existing service (its chat() / embed() seams in providers.py) and the docs-vector-search vServer deploy (1 CPU / 2 GB).
Corpus reality: docs/ has Vietnamese and English docs (e.g. SAE-CIR-VN.md, campaign-slide-VN.md), so the default embedder must be multilingual.
1. Component choices (for a 1 vCPU / 2 GB box)
| Component | Model | Size | Runtime | Why |
|---|---|---|---|---|
| Embed (default — VN+EN) | intfloat/multilingual-e5-small | ~470 MB | fastembed / ONNX, in-process | trained for retrieval; use query: / passage: prefixes; 384-dim |
| Embed (EN-only alt) | BAAI/bge-small-en-v1.5 | ~130 MB | fastembed / ONNX | best English retrieval at this size; 384-dim |
| Rerank (biggest quality win) | BAAI/bge-reranker-base | ~280 MB | fastembed TextCrossEncoder / ONNX | cross-encode top-20 → top-3–5; +100–300 ms on CPU |
| Generate (default) | Qwen2.5-0.5B-Instruct Q4_K_M (GGUF) | ~400–600 MB | llama-cpp-python (CPU) | best tiny model for grounded Q&A + Vietnamese; Apache-2.0 |
| Generate (fallback) | OpenAI / GreenNode MaaS | — | HTTP (existing seam) | when RAM is tight, offload generation and keep embed+rerank local |
| Vector store | pgvector on the existing VNGCloud vDB (PostgreSQL 15) | off-box | managed Postgres | reuses deployments/postgres; HNSW cosine index; separate UAT/PROD instances; frees the app box’s RAM |
Licensing: Qwen2.5 (Apache-2.0), bge/e5 (MIT/Apache) — all fine for commercial use.
2. Why this changes the pipeline (chunking + rerank)
The current service embeds whole documents (37 docs) and ranks them with in-memory numpy cosine. A quality local RAG needs two structural additions:
- Chunking — split docs into passages so retrieval and reranking operate on granular, relevant spans (a 15k-token research paper shouldn’t be one vector). More vectors (hundreds–thousands of chunks) → store them in pgvector on the existing vDB.
- Reranking — a cross-encoder re-scores the top-20 retrieved chunks against the query and keeps the top 3–5. On tiny generators this matters more than the LLM: tight, correct context is what keeps a 0.5B model from hallucinating.
flowchart LR subgraph offline["Offline — enrich"] D["docs/ *.md"] --> K["chunk (heading-aware + token window)"] K --> E["embed passages<br/>e5-small 'passage:'"] E --> S[("pgvector on the vDB<br/>chunk vectors + metadata")] end subgraph online["Online — /ask"] Q(["question"]) --> QE["embed 'query:'"] QE --> R["retrieve top-20 (cosine)"] R --> RR["rerank → top-5<br/>bge-reranker-base"] RR --> G["generate (grounded)<br/>Qwen2.5-0.5B | hosted fallback"] G --> O(["answer + cited chunks"]) S -.-> R end
3. Component design (mapped to the existing code)
3.1 Chunking — chunk.py (new)
- Split each doc into passages: heading-aware first (split on
#/##), then a ~400-token window with ~50-token overlap for long sections. - Each chunk carries
{doc_path, title, heading_path, ordinal, text}; the chunk id ispath#ordinal. - e5 needs the
passage:prefix at embed time; the reranker/LLM see the raw chunk text.
3.2 Embedding — extend embed() (the seam returns)
Re-introduce EMBED_PROVIDER (removed in the OpenAI-only pass) with local backends behind the same embed(texts, task):
e5-small(default): fastembedTextEmbedding("intfloat/multilingual-e5-small"); prependquery:whentask="query",passage:whentask="document".bge-small: fastembedTextEmbedding("BAAI/bge-small-en-v1.5")(no prefixes).openai(kept): existingtext-embedding-3-smallfor a hosted option.
3.3 Vector store — store.py (new) → pgvector on the vDB
Use pgvector in the existing VNGCloud vDB (PostgreSQL 15, provisioned by deployments/postgres). This moves the index off the 2 GB app box and reuses infra we already run — a strong reason to prefer it over an in-process store here.
- Extension (once per DB):
CREATE EXTENSION IF NOT EXISTS vector;— apply viadeployments/postgres/run-sql.sh. - Schema (dedicated
ragschema so it never collides with product tables):CREATE TABLE rag.doc_chunks ( id text PRIMARY KEY, -- "<path>#<ordinal>" path text NOT NULL, title text, heading text, ordinal int, content_hash text, text text NOT NULL, embedding vector(384) NOT NULL -- e5-small / bge-small dim ); CREATE INDEX ON rag.doc_chunks USING hnsw (embedding vector_cosine_ops); - Retrieve:
SELECT id, path, title, text FROM rag.doc_chunks ORDER BY embedding <=> :qvec LIMIT :n;(cosine distance;n = RETRIEVE_TOP_N = 20). - Client:
psycopg+ thepgvectoradapter; connection from env (below). HNSW gives strong recall on a small corpus; switch to IVFFlat only at large scale. - Per-env: UAT app → UAT vDB, PROD app → PROD vDB (the two Terraform-provisioned instances).
3.4 Reranker — reranker.py (new)
- fastembed
TextCrossEncoder("BAAI/bge-reranker-base");rerank(query, [chunk_texts]) -> scores; keep topRERANK_TOP_K(default 5). - Gated by
DOCS_RERANK_ENABLED(defaulttrue) so it can be dropped first under RAM pressure.
3.5 Generator — extend chat() (the seam returns)
DOCS_LLM_PROVIDER with:
local:llama-cpp-pythonloads the path inDOCS_LOCAL_LLM_MODEL_PATH; strict grounded system prompt — “Answer only from the context below; if it’s not there, say you don’t know. Cite the source titles.” Low temperature, small output budget.openai/gemini: hosted generation through the provider-specific API settings in the configuration below.
3.6 Serve — server.py unchanged in shape
Same /ask, /search, /health; /ask now runs retrieve → rerank → generate. Load models once at startup (fastembed + llama model are the expensive part) and reuse.
4. Config (env-driven seams)
# Embedding provider selection
DOCS_EMBEDDING_PROVIDER=openai # openai | gemini | local
DOCS_OPENAI_EMBEDDING_MODEL=text-embedding-3-small
DOCS_OPENAI_EMBEDDING_DIMENSIONS=384
DOCS_GEMINI_EMBEDDING_MODEL=gemini-embedding-001
DOCS_GEMINI_EMBEDDING_DIMENSIONS=384
DOCS_LOCAL_EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
DOCS_LOCAL_EMBEDDING_DIMENSIONS=384
# Reranking
DOCS_RERANK_ENABLED=true
DOCS_RERANK_MODEL=BAAI/bge-reranker-base
RETRIEVE_TOP_N=20
RERANK_TOP_K=5
# Generation provider selection
DOCS_LLM_PROVIDER=openai # openai | gemini | local
DOCS_HOSTED_LLM_MAX_OUTPUT_TOKENS=1024
DOCS_LOCAL_LLM_MAX_OUTPUT_TOKENS=512
DOCS_OPENAI_LLM_MODEL=gpt-5.6-luna
DOCS_OPENAI_API_KEY=... # secret
DOCS_OPENAI_API_BASE_URL=https://api.openai.com/v1
DOCS_GEMINI_LLM_MODEL=gemini-2.5-flash
DOCS_GEMINI_API_KEY=... # secret
DOCS_GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta
DOCS_LOCAL_LLM_MODEL_PATH=/app/models/Qwen2.5-0.5B-Instruct-Q4_K_M.gguf
DOCS_LOCAL_LLM_CONTEXT_TOKENS=2048
DOCS_LOCAL_LLM_THREADS=2
DOCS_LOCAL_LLM_BATCH_SIZE=512
# Chunking
CHUNK_TOKENS=400
CHUNK_OVERLAP=50
# Vector store — pgvector on the VNGCloud vDB (per-env: UAT vDB / PROD vDB)
PG_HOST=... # vDB host (from deployments/postgres outputs)
PG_PORT=5432
PG_DATABASE=...
PG_USER=...
PG_PASSWORD=... # secret — keep in .env only
PG_SCHEMA=rag
RETRIEVE_TOP_N=20
# Model cache (fastembed downloads ONNX here)
FASTEMBED_CACHE=/app/models/fastembed
5. RAM budget & tiered profiles (the 2 GB constraint)
| Profile | Resident (models) | Fits 2 GB? | Use |
|---|---|---|---|
| A — Hybrid (recommended default) | e5 (~500 MB) + reranker (~300 MB) ≈ 0.8 GB; generation hosted | ✅ comfortable | retrieval + rerank local, LLM on OpenAI/GreenNode |
| B — Full local | + Qwen 0.5B (~600 MB) ≈ 1.4 GB | ⚠️ tight | fully offline; needs swap; drop reranker first if OOM |
| C — Minimal local | e5 only (~500 MB); rerank off; generation hosted | ✅ roomy | lowest RAM |
Plus ~200–400 MB for Python + onnxruntime + FastAPI. The vector index lives in the vDB (pgvector), off the app box — it no longer competes for the 2 GB. Recommendation: ship Profile A as the default on 2 GB (safe, and generation quality stays high), and make Profile B an opt-in with a 2 GB swapfile configured on the vServer. If Profile B OOMs, the order to shed is: reranker → then generation to hosted.
The seams make this a config change, not a code change — the same image runs A, B, or C by env.
6. Dependencies & image
Add to requirements.txt:
fastembed>=0.4 # ONNX embeddings + TextCrossEncoder reranker (pulls onnxruntime)
llama-cpp-python>=0.3 # CPU GGUF generation (Qwen) — Profile B only
psycopg[binary]>=3.2 # vDB (PostgreSQL) client
pgvector>=0.3 # pgvector adapter for psycopg
# keep: openai, tiktoken (hosted fallback), fastapi, uvicorn, numpy, python-frontmatter, httpx
Model weights (~1–1.4 GB) — do not bake into the image (keeps it lean, avoids re-pushing on code changes). Instead mount a models volume and fetch on first boot:
- fastembed auto-downloads its ONNX models to
FASTEMBED_CACHE. - the Qwen GGUF is pulled once (huggingface-cli or curl) into
DOCS_LOCAL_LLM_MODEL_PATH.
llama-cpp-python needs a C/C++ toolchain to build in python:3.12-slim — either add build deps in a builder stage or use a prebuilt CPU wheel; only needed for Profile B.
7. Deployment impact (docs-vector-search/)
- Compose: add a
modelsnamed volume (/app/models), set theDOCS_LOCAL_*model/cache settings, keep the 1 CPU / 2 GB limits. For Profile B, document adding a 2 GB swapfile on the host (fallocate/swapon) — container memory limit stays 2 GB but swap gives headroom. - deploy.sh: add a one-time model-fetch step (into the volume) before
up; the enrich step now chunks + embeds locally (no API cost) and upserts chunks into the vDB. - vDB (pgvector): one-time
CREATE EXTENSION vector+ therag.doc_chunksDDL per env viadeployments/postgres/run-sql.sh(UAT vDB and PROD vDB separately). The app connects with the vDB creds in.env.<env>; the index lives in the managed DB, so it does not count against the app’s 2 GB. Prerequisite: confirm the VNGCloud vDB allows thevectorextension — managed Postgres sometimes gates extensions; if it’s unavailable, request it or run a pgvector-enabled Postgres. - Latency on 1 vCPU: embed query ~ms; rerank 20 chunks ~100–300 ms; Qwen 0.5B generation a few tokens/s → answers in a few seconds. Single CPU ⇒ serialize requests (low concurrency); acceptable for an internal API. Hosted generation (Profile A) is faster and frees the CPU.
8. Phased rollout
Phase 1 — local retrieval (biggest correctness win, safe RAM).
-
chunk.py(heading + token-window chunking). -
embed()→ support the provider-specific OpenAI, Gemini, and local fastembed models; defaultDOCS_EMBEDDING_PROVIDER=openai. - Enable pgvector on the vDB (
CREATE EXTENSION vector+rag.doc_chunksDDL viarun-sql.sh);store.pyupserts chunks+vectors;retrieverruns the<=>top-N query. - Keep generation hosted (Profile A). Ship + measure retrieval quality vs the whole-doc baseline.
Phase 2 — local reranking.
-
reranker.py(bge-reranker-base); wire retrieve-top-20 → rerank → top-5.DOCS_RERANK_ENABLED=true. - Measure rerank lift + latency + RAM. Still Profile A.
Phase 3 — local generation (full local).
-
chat()→ supportDOCS_LLM_PROVIDER=local(llama-cpp-python, GGUF, strict grounded prompt). - Configure swap; run Profile B; measure RAM/latency/quality. Keep hosted fallback (
DOCS_LLM_PROVIDER=openai|gemini) one env-flip away.
9. Verification & eval
- A small
questions.jsonl(EN and VN) with expected source docs. - Metrics: recall@5 (retrieval), rerank lift (recall@5 before/after), answer groundedness (cites real chunks, declines when absent), p50/p95 latency, and peak RSS per profile.
- Gate each phase on: retrieval recall ≥ baseline, RSS within budget, latency acceptable.
10. Decisions to confirm
- Embedding:
multilingual-e5-small(recommended — the corpus has Vietnamese) vsbge-small-en(English-only, smaller/faster). - Default profile: A (hybrid) on 2 GB vs B (full local) with swap. Recommend A default, B opt-in.
- vDB / pgvector: confirm the VNGCloud vDB (PG 15) has the
vectorextension available; pick the embedding dimension (384 for e5 / bge-small) and index type (HNSW recommended). - Hosted fallback: keep OpenAI and GreenNode MaaS (OpenAI-compatible) selectable? Recommend yes — one
base_url/key swap. - Model delivery:
modelsvolume + first-boot download (recommended) vs baking weights into the image.
Appendix — model sources
- Embed:
intfloat/multilingual-e5-small,BAAI/bge-small-en-v1.5(HF; via fastembedTextEmbedding). - Rerank:
BAAI/bge-reranker-base(HF; via fastembedTextCrossEncoder). Note:bge-reranker-v2-m3is stronger for Vietnamese but ~600 MB — too big here;baseis the size/quality compromise. - Generate:
Qwen2.5-0.5B-Instruct-GGUF(Q4_K_M) (HF; viallama-cpp-python). - Store: pgvector on the VNGCloud vDB (PostgreSQL 15) via
psycopg+ thepgvectoradapter; DDL applied withdeployments/postgres/run-sql.sh. - Fits the
docs-vector-searchseams (chat()/embed()) and thedocs-vector-search1 CPU / 2 GB deploy.