Paul Visciano Blogs

Sci-Fi Labs · Field notes

Local AI + RAG: the Full Stack in 16 GB

A 27B model, an embedding model, Whisper, Postgres, a RAG server, an API, a worker, and a SvelteKit UI. All in 16 GB. Live numbers from the desk. Not the model card.

Space-black MacBook Pro at night running a local AI telemetry dashboard in 16 GBGitHub · paulvisciano/knowledge-graph
The whole stack. Running, offline, on one laptop.

This is the technical companion to the Knowledge Graph project page. That page says what it is. This page says what it costs: memory, swap, tokens per second, months of tuning. Every number here is live from the running system.

On this laptop: a 14-inch MacBook Pro, Apple M2 Pro. 10-core CPU, 16-core GPU, 16 GB unified memory. CPU and GPU share one pool. There is no separate VRAM. Every model eats from the same 16 GB as the OS, the browser, and the editor. 16 GB is the machine most people actually have.

The model stack

Every model is a file on disk. No API keys. No remote endpoints. No silent uploads. That is not a workaround. It is the design constraint.

Model files on disk

Bonsai-27B LLM + Vision · Q1_0 1-bit
3.5 GB
3.5 GB
Whisper medium transcription · multilingual
1.4 GB
1.4 GB
Bonsai mmproj vision projection · Q8_0
600 MB
600 MB
BGE-M3 embeddings · Q4_K_M · 1024-d
417 MB
417 MB
Total disk
~5.9 GB
5.9 GB

Bonsai-27B is a 27B-parameter model with PrismML’s 1-bit quantization (Q1_0_g128). The 1-bit Metal kernels are not in stock llama.cpp. This project builds the PrismML-Eng fork from source. The Homebrew bottle will refuse the GGUF.

The same model serves chat and vision. Bonsai-27B is built on Qwen3.6-27B. The vision encoder ships as a separate mmproj file. The server runs with -np 2: two parallel slots, so chat and vision do not block each other. The project config caps VLM concurrency at 1 so one slot always stays free for chat. Both slots share the same 16 GB pool.

Embeddings: BGE-M3 on its own llama-server. Transcription: Whisper on whisper-server (Metal). Separate processes. Separate slots. No fighting for the same queue.

Architecture

Model servers stay on the host for direct Metal access. The service layer runs in Docker. They talk over host.docker.internal.

System architecture. Everything on one laptop, nothing in the cloud

HOST (Metal GPU)
llama-server :8080: Bonsai-27B LLM + mmproj vision (2 slots, 16K ctx each)
llama-server :8081: BGE-M3 embeddings
whisper-server :8090: Whisper medium transcription
host.docker.internal ▲ ▼
DOCKER (internal bridge network)
LightRAG :9621: entity extraction, graph retrieval, query routing
Postgres + pgvector: KV store, vector index (HNSW), doc status
KG API :8000: FastAPI image pipeline, chat, sync, settings
Worker: background image processing (EXIF → entities → VLM → graph)
LLM Worker: chat job queue, MCP tool calls
MCP Server :9653: Model Context Protocol bridge
Nexus UI :3000: SvelteKit + Three.js infinite canvas
Browser ▲ ▼
CLIENT
localhost:3000: spatial canvas, chat panel, ingestion, voice. One page

LightRAG is the memory engine: entity-and-relation extraction, dual-level retrieval (local entity match + higher-level themes). KV, vectors, and document status live in Postgres with pgvector and an HNSW index. The graph stays in NetworkX, in-process, because a personal archive is small enough that an in-memory graph beats a network hop.

The KG API owns what LightRAG does not: image ingestion, chat (SSE + job queue), settings. A separate worker owns image processing so a long VLM batch never blocks the event loop. The queue is Postgres (SELECT FOR UPDATE SKIP LOCKED). The event bus is pg_notify. One database. One queue. One event stream.

The image pipeline

A photo does not go straight to the model. Cheap work first. Expensive work second. A batch of 50 photos should not monopolize the GPU for an hour.

Two-phase image processing. One photo, four stages, all local

01
EXIF
Camera, date, GPS → reverse-geocoded place names (offline, via reverse_geocoder)
02
Entities
EXIF data becomes graph nodes: Date, Location, Camera. Via LightRAG
03
VLM
Bonsai-27B + mmproj: image → natural-language description
04
Graph
Visual description ingested by LightRAG → entities, relations, embeddings

Phase 1 is CPU-bound: ExifRead + offline reverse geocoding. High concurrency (up to 10). Never touches the LLM. Face recognition (DeepFace / MTCNN / ArcFace) exists in the codebase and is currently disabled. face_detection_enabled is false. On 16 GB that trade was not worth it. The code stays for a bigger machine.

Phase 2 is GPU-bound. Photos resize to max 768px (mmproj caps image tokens at 280). Bonsai returns a scene description. LightRAG ingests it as text. A dinner in Lisbon becomes nodes for the food, the restaurant, the city, tied to date and camera.

The split exists because of the 16 GB constraint. Phase 1 finishes in seconds. Phase 2 trickles through the single VLM slot overnight. The other LLM slot stays free for chat.

The chat and query path

You speak or type. Nexus streams to the KG API. The LLM worker calls Bonsai with chat context and streams back over SSE. If the question needs memory (“what did I do in Lisbon?”), LightRAG embeds with BGE-M3, retrieves top-k entity subgraphs from Postgres, and hands a context block to the model. The answer comes from your data, not the training set.

Voice memos hit Whisper on the host GPU first. The transcript becomes another node on the canvas, timestamped to the moment it was spoken. The MCP server exposes the same graph to external tools over a standard interface. Another agent on this machine can ask without the data leaving the laptop.

The graph right now

Live Postgres counts, queried while the system was serving the conversation that produced this writing.

The graph right now. Live Postgres counts

Relation vectors BGE-M3 1024-d
4,420
4,420
Entity vectors BGE-M3 1024-d
2,258
2,258
LLM cache entries cached completions
862
862
Documents ingested
278
278
Image processing jobs
222
222
Photos with metadata
253
253
Entities in graph
270
270
Relations (edges)
256
256
Chunk vectors
275
275
Messages
79
79
Conversations
31
31

862 cached completions mean the model does not re-run extraction on text it has already seen. 270 entities and 256 relations is dense for a personal archive: nearly every entity connects to at least one other. Database size: 243 MB. A personal archive. Not a data lake.

Throughput

Measured live with a direct /v1/chat/completions call to Bonsai-27B on port 8080:

Throughput. Measured live on port 8080

Generation speed tokens out
15.1 tok/s
15.1 tok/s
Prompt eval speed tokens in
10.3 tok/s
10.3 tok/s
16,384
ctx / slot
2
slots
248K
vocab
26.9B
params
3.5 GB
Q1_0 on disk

Fifteen tokens per second is not cloud-fast. It is fast enough for a conversation. It comes from a 27B model with no network cable. Prompt eval at 10.3 tok/s is the bottleneck: a long context (2,997 tokens in the last chat slot) takes ~5 seconds before the first token. DRY and XTC samplers in start-llama-servers.sh exist because at 1-bit the model paraphrase-loops without them.

The claim vs the machine

Model cards publish MPG. The sticker is a test track. Your road has traffic.

PrismML’s HuggingFace model card leads with “~44 tok/s on an Apple M5 Pro laptop.” Marketing leads with “87 tok/s on M5 Max.” An independent reviewer on a 48 GB M4 Pro measured 27 tok/s. Here is that scoreboard next to the machine that runs Knowledge Graph:

Claimed tok/s vs measured. The MPG problem

PrismML marketing M5 Max · vendor-reported
87
87 tok/s
PrismML model card M5 Pro · vendor-reported
44
44 tok/s
PrismML model card M4 Pro · vendor-reported
26
26 tok/s
StrideNote review M4 Pro 48 GB · independent
27
27 tok/s
Knowledge Graph M2 Pro 16 GB · live, full stack
15.1
15.1 tok/s

The gap between 44 and 15.1 is not a lie. It is the difference between a benchmark and a desk. Vendor numbers come from llama-bench: one model, alone, 128 tokens from a 512-token prompt. 15.1 comes from two model slots, an embedding server, Whisper, Postgres, LightRAG, FastAPI, a worker, and a SvelteKit UI, sharing 16 GB with a browser and an editor.

The card says “3.9 GB deployed footprint.” Correct for weights. Live at idle: ~3.5 GB wired GPU for weights, 1.7 GB Whisper, 0.4 GB BGE-M3, ~4.5 GB OS wired. When generation starts, wired memory spikes another 5.8 GB for KV cache and activations. The deployed footprint is not the deployed cost. The machine breathes between 4.9 GB and 10.7 GB wired, 8.3 to 8.7 GB of swap, and ~73 MB free. The model still answers.

16 GB, accounted for

On Apple Silicon there is one pool. Weights with -ngl 99 live in wired memory. Idle and active look like different machines.

Laptop on a desk with the network cable cut. Offline stack still running
Offline is the proof. The budget has to survive with the cable out.

Idle: stack running, nobody talking

ProcessRAMNotes
macOS + window server + kernel~4.5 GBhost · wired
Bonsai-27B weights~3.5 GBhost · wired GPU · slots idle
Compressed swap~3.7 GBhost · compressor
Whisper medium~1.7 GBhost · wired GPU
Brave + VS Code + opencode~1.5 GBhost · resident
Docker VM1.5 GB7 containers · capped
BGE-M3 embeddings~0.4 GBhost · wired GPU
Total used / 16 GB15.0 GB73 MB free · 8.3 GB swap

Active: conversation or image pipeline

Generation fills the KV cache. Wired memory jumps by nearly 6 GB.

Idle vs active. The cost of thinking

Wired memory
+5.8 GB
4.9 GB
+5.8 GB
Compressor compressed swap
-0.9 GB
3.7 GB idle
2.8 GB active
Swap used
+0.4 GB
8.3 GB
+0.4
Free memory
-2 MB
73 MB
71 MB
idle spike / active

What is the KV cache?

A transformer predicts the next token from every previous token. Re-running the whole model each step would be impossibly slow. So it caches Keys and Values for tokens already seen. Weights are long-term memory. The KV cache is short-term memory for the active conversation. When the conversation ends, the cache goes away.

Bonsai-27B has 64 transformer layers. Hybrid attention means only 16 grow a full KV cache. The other 48 use linear attention with a fixed state. That is why a 262K-token context is even discussable on a laptop. A conventional 27B with 64 full-attention layers would need ~20 GB of KV alone at 32K context. More than the entire machine. Bonsai needs ~5 GB at full 32K per slot.

The server starts with -c 32768 -np 2: 32K total across two slots, 16K each. Deliberate. Full 32K per slot would put ~5 GB of KV on top of 3.5 GB weights, 1.7 GB Whisper, and 4.5 GB OS. Running at 16K per slot cuts KV to ~2.5 GB. Context depth traded for stability. On a personal archive, 16K is enough.

KV cache. Where the 5.8 GB spike comes from

Full-attention KV cache 16/64 layers · 2 slots × 16K ctx · q4_0
~2.5 GB
2.5 GB
Activations + buffers + overhead
~3.3 GB
3.3 GB
Total spike when generating
~5.8 GB
5.8 GB

Why Bonsai fits: hybrid attention vs conventional 27B

Bonsai-27B KV 16 full-attn · running 16K/slot · q4_0
2.5 GB
2.5 GB
Bonsai-27B KV 16 full-attn · full 32K/slot · q4_0
5.0 GB
5.0 GB
Conventional 27B KV 64 full-attn · full 32K/slot · q4_0
20.0 GB
20.0 GB

KV is started with -ctk q4_0 -ctv q4_0: 4-bit quantized cache. Without it, full-attention cache alone would be ~10 GB at 16K per slot. The card reports 0.0011 nats of output divergence from FP16 KV. Invisible in practice.

Idle: weights sit at ~3.5 GB, cache empty, fan quiet. You send a message. Prompt tokens fill the cache. That allocation is the spike. Response adds more. Conversation ends. OS reclaims. The system breathes.

That budget is why the reranker was removed. bge-reranker-v2-m3 worked. It added ~600 MB of resident GPU for a quality bump that did not justify the cost on a machine already swapping. On a 32 GB box it comes back. A config change, not a rewrite.

The constraint is not a footnote. Idle: 15 GB used, 8.3 GB of swap, fan quiet. Active: 10.7 GB wired, 8.7 GB of swap, fan hard, model answering at 15 tok/s. If it only works on a $4,000 workstation, it is not a personal memory system.

What runs on the desk now

The conversation works. Fan audible. Wired memory doubled. Browser and editor open. You ask. The model retrieves from the graph. The answer streams. No timeouts. Slow by cloud standards. Fast enough to use.

That took months. Memory limits per container. Two-phase pipelines. A separate worker. DRY and XTC tuned for 1-bit. A Whisper model that fits. A reranker removed because 600 MB mattered. Each decision was a failure that became a fix.

The live conversation surface right now also includes Open WebUI pointed at the same Bonsai server. Whisper in, Kokoro JS out. Open WebUI is temporary. Next swap is the Knowledge Graph UI already built. Same models. Canvas instead of bubbles. Published sites are demos. The product is the copy on your machine.

Offline is the proof. Memory you own, models you run.

More from Sci-Fi Labs

Bonsai: the 27B Model That Fits in 16 GB → The local agent that knows your month → Where is Paul? → Reclaim your data →