vkb
by inkiebeard
README.md
# vkb — Vector Knowledge Base
A self-contained, local-first semantic memory system built on Postgres + pgvector and Ollama. Exposes a full [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server so any MCP-capable host (Claude Desktop, Claude CLI, etc.) can ingest documents, query semantically, and traverse a knowledge graph.
---
## Architecture overview
```mermaid
graph TD
subgraph Clients
A[MCP Host\nClaude Desktop / CLI]
B[Browser UI\nlocalhost:4242]
end
subgraph vkb process
MCP[MCP Server\nstdio or HTTP Streamable :3333]
HTTP[HTTP Server\n:4242]
COORD[Coordinator\nWorker Pool]
subgraph Workers
IW[ingest-worker × N]
FW[finetune-worker]
RW[retune-worker]
end
subgraph Pipelines
IPIPE[Ingest Pipeline]
FPIPE[Finetune Pipeline]
RPIPE[Retune Pipeline]
end
subgraph Adapters
FETCH[fetch\nreadability / pdf / epub]
CHUNK[chunk\nsliding-window]
EMBED[embed\nollama]
SECT[section\nsimilarity-valley]
REL[relation\nheuristic + LLM]
LLM[llm\nollama]
RAW[rawstore\nfilesystem or s3]
end
end
subgraph External
PG[(Postgres\npgvector)]
OLL[Ollama]
FS[Filesystem / S3\nrawstore/]
end
A -->|MCP tools| MCP
B -->|REST / WebSocket| HTTP
MCP --> COORD
HTTP --> COORD
COORD --> IW & FW & RW
IW --> IPIPE
FW --> FPIPE
RW --> RPIPE
IPIPE & FPIPE & RPIPE --> FETCH & CHUNK & EMBED & SECT & REL & LLM & RAW
EMBED -->|vectors| OLL
LLM --> OLL
RAW --> FS
IPIPE & FPIPE & RPIPE -->|upsert| PG
```
---
## Ingestion pipeline
```mermaid
flowchart TD
START([Job queued]) --> DEDUP
DEDUP{Content hash\nalready known?}
DEDUP -- yes, unchanged --> SKIP([Skip — existing entity returned])
DEDUP -- no / changed --> FETCH
subgraph FETCH["fetching — Persist"]
FETCH2[Fetch raw text\nURL → readability / PDF / EPUB\nor staging file]
FETCH2 --> HASH[Compute SHA-256\ncontent hash]
HASH --> WRITE[Write entity.md + chunks.ndjson\nto RawStore]
WRITE --> F_COMMIT[Commit raw_store_key\nto DB ✓]
end
F_COMMIT --> CHUNK_S
subgraph CHUNK_S["chunking"]
CHUNK2[Sliding-window chunk\nwrite chunk rows to DB]
end
CHUNK_S --> EMBED_S
subgraph EMBED_S["embedding"]
EMBED2[Embed each chunk\nvia Ollama]
end
EMBED_S --> SECT_S
subgraph SECT_S["sectioning"]
SECT2[Group chunks into sections\nsimilarity-valley or positional]
end
SECT_S --> SUM_S
subgraph SUM_S["summarising"]
SUM_C[Summarise each chunk — LLM]
SUM_SEC[Summarise each section — LLM]
SUM_E[Summarise entity — LLM]
end
SUM_S --> LINK_S
subgraph LINK_S["linking"]
REL_H[Heuristic relation extraction]
REL_L[LLM relation extraction\noptional]
end
LINK_S --> TAG_S
subgraph TAG_S["tagging"]
TAG[Extract tags from meta\nAssert tag:* relations\nbetween co-tagged entities]
end
TAG_S --> DONE([Entity ready — status = ready])
```
## Finetune pipeline
The finetune pipeline enriches already-ingested entities without re-chunking or re-embedding. It runs as a separate `finetune-worker` process and is triggered via `vkb_finetune` (MCP) or `POST /finetune` (HTTP).
```mermaid
flowchart TD
START([Finetune job queued]) --> EX
subgraph EX["extracting"]
TOP[Find top-N nearest neighbours\nvia embedding similarity]
TOP --> LLM_REL[LLM relation extractor\nupsert content_llm relations]
end
EX --> TAG
subgraph TAG["tagging"]
LLM_TAG[LLM keyword tagger\nmerge tags into meta.tags]
LLM_TAG --> TAG_REL[Assert tag:* relations\nbetween co-tagged entities]
end
TAG --> DONE([Finetune complete])
```
---
## Requirements
| Tool | Version |
|---|---|
| Node.js | ≥ 22 |
| Docker + Docker Compose | any recent |
| [Ollama](https://ollama.ai) | any recent |
Pull the models vkb uses by default:
```bash
ollama pull nomic-embed-text
ollama pull gemma4:e4b
```
---
## Quick start
```bash
# 1. Clone and install
git clone <repo-url> galactic-vkb
cd galactic-vkb
npm install
# 2. Configure
cp .env.example .env
# Edit .env if needed — defaults work out of the box with Docker Compose
# 3. Start Postgres (pgvector-enabled)
npm run db:up
# 4. Run migrations
npm run migrate
# 5. Start vkb
npm run dev # development (tsx watch)
# or
npm run build && npm start # production
```
The UI is available at **http://localhost:4242/** once running. Navigate between the graph view (`#viz`) and ingest form (`#ingest`) using the header tabs.
---
## Adding vkb as an MCP server
vkb uses **stdio transport** when `MCP_PORT` is `0`. The MCP server is also accessible over **HTTP Streamable** (MCP 2025-03-26 spec) when `MCP_PORT` > 0 (default **3333**), with session management for multi-client use.
### Claude Desktop (`claude_desktop_config.json`)
Open (or create) `~/.claude/claude_desktop_config.json` and add an entry under `mcpServers`:
```json
{
"mcpServers": {
"vkb": {
"command": "node",
"args": ["/absolute/path/to/galactic-vkb/dist/index.js"],
"env": {
"DATABASE_URL": "postgres://vkb:vkb@localhost:5433/vkb",
"OLLAMA_BASE_URL": "http://localhost:11434",
"EMBED_MODEL": "nomic-embed-text",
"LLM_MODEL": "gemma4:e4b",
"MCP_PORT": "0"
}
}
}
}
```
> **Tip:** Run `npm run build` first so `dist/index.js` exists.
> `MCP_PORT=0` forces stdio mode — no HTTP server is started.
For **development** (no build step), use `tsx` instead:
```json
{
"mcpServers": {
"vkb": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/galactic-vkb/src/index.ts"],
"env": {
"DATABASE_URL": "postgres://vkb:vkb@localhost:5433/vkb",
"OLLAMA_BASE_URL": "http://localhost:11434",
"MCP_PORT": "0"
}
}
}
}
```
### Claude CLI (inline)
```bash
MCP_PORT=0 DATABASE_URL=postgres://vkb:vkb@localhost:5433/vkb \
claude --mcp-server "vkb:node /absolute/path/to/galactic-vkb/dist/index.js"
```
---
## Available MCP tools
| Tool | Description |
|---|---|
| `vkb_ingest` | Submit text, a URL, or a file path for ingestion. Optional `source_context` (`external`\|`conversation`\|`self_authored`) and `meta` object. Inline text is deduplicated by SHA-256 before queuing. |
| `vkb_ingest_bulk` | Submit up to 200 items in a single call. Each item has the same shape as `vkb_ingest`. Deduplication is applied per-item; unchanged items are returned with `skipped: true`. |
| `vkb_job` | Poll a background job by ID. Returns `stage`, progress counters, `entity_id`, `kind`, and `error_detail` on failure. |
| `vkb_query` | Semantic search across all ingested content. Supports `k`, `type`, `threshold` (float 0–1), and `include_sections`. Returns an actionable hint when results are empty. |
| `vkb_get` | Fetch an entity or chunk by ID (`kind`: `entity`\|`chunk`). Entity responses include chunk IDs, sections, relations, and `tag_context` (co-tagged entities). |
| `vkb_raw` | Read the raw stored text for an entity or chunk from the RawStore. |
| `vkb_relate` | Assert an explicit relation between any two entity or chunk IDs. `weight` is auto-computed from cosine similarity if omitted. Asserted relations are never pruned. |
| `vkb_neighbors` | Retrieve an N-hop relation subgraph from a seed node. |
| `vkb_delete` | Delete an entity and all its data (chunks, sections, relations, RawStore files). Non-reversible. |
| `vkb_finetune` | Queue a finetune job: LLM relation extraction + LLM keyword tagging. No re-chunking or re-embedding. Accepts optional `entity_ids` array or `scope` (entity type filter). |
| `vkb_retune` | Trigger a re-embedding / relation refresh sweep immediately. `force: true` reprocesses all chunks regardless of embed model. |
| `vkb_status` | Full system snapshot (entity/chunk/relation counts, queue depth, worker state, config). |
| `vkb_migrate` | Run all pending SQL migrations. Idempotent. |
### `vkb_relate` — the feedback loop tool
> "The `vkb_relate` tool is underrated. As Claude works with your data and draws connections, you can have it assert new relations back into the graph. Over time Claude becomes a **contributor** to the knowledge base, not just a consumer. That's a genuinely interesting feedback loop."
Every relation asserted via `vkb_relate` is marked `origin: asserted` — it is never pruned by retune sweeps and carries `confidence: 1.0`. The optional `weight` parameter (float 0–1) lets you express relative strength; if omitted it is computed automatically from the cosine similarity between the two nodes. This makes Claude's synthesis durable: connections it draws during a session persist and become first-class edges that future queries and `vkb_neighbors` traversals can follow.
Typical pattern:
```
# 1. Query for relevant chunks
vkb_query { text: "transformer attention mechanism" }
# 2. Identify a cross-document insight, then assert it
vkb_relate { source_id: "<chunk-A>", target_id: "<chunk-B>",
rel_type: "shares_mechanism_with" }
# 3. Traverse what's grown
vkb_neighbors { id: "<chunk-A>", hops: 2 }
```
You can also use `vkb_finetune` to have the LLM automatically extract relations and keyword tags across a set of entities — a useful complement to explicit `vkb_relate` calls when working with a large corpus.
### `vkb_neighbors` — N-hop subgraph retrieval
Walks the relation graph outward from a seed node up to `hops` steps (default 2, max 5). Returns:
- **`nodes`** — every reachable entity or chunk, annotated with `kind`, `hop` distance from the seed, and its summary.
- **`edges`** — all relations *between discovered nodes* (not just the traversal path), enabling local graph rendering or further reasoning.
| Parameter | Default | Description |
|---|---|---|
| `id` | required | Seed entity or chunk UUID |
| `hops` | `2` | Traversal depth (1–5) |
| `min_confidence` | `0.0` | Skip edges below this confidence |
| `rel_type` | — | Only follow edges of this type |
| `max_nodes` | `50` | Cap on total nodes returned |
---
## HTTP API
The observability server runs on `OBS_PORT` (default **4242**) and exposes both the SPA and a REST API.
All responses follow the envelope `{ ok: true, data: … }` / `{ ok: false, error: "…" }`.
When Postgres is unreachable, routes that need the DB return **503** with `"Database unavailable"`.
### Authentication
Set `OBS_SECRET=your-secret` in `.env`. When set, all API requests must include:
```
Authorization: Bearer your-secret
```
### Health
```
GET /health
```
Returns the live status of each dependency:
```json
{ "ok": true, "uptime": 42.3, "postgres": true, "ollama": true }
```
Returns `503` with `"postgres": false` when Postgres is unreachable.
### Ingest
```bash
# URL
curl -X POST http://localhost:4242/ingest \
-H "Content-Type: application/json" \
-d '{ "type": "url", "ref": "https://example.com/article" }'
# Local file (supports .md, .txt, .pdf, .epub, .yaml, .json)
curl -X POST http://localhost:4242/ingest \
-H "Content-Type: application/json" \
-d '{ "type": "doc", "ref": "/absolute/path/to/file.md" }'
# Inline text
curl -X POST http://localhost:4242/ingest \
-H "Content-Type: application/json" \
-d '{ "type": "note", "text": "Some text…", "source_context": "conversation", "meta": { "tags": ["example"] } }'
```
Response:
```json
{ "ok": true, "data": { "job_id": "3f2a1b4c-…", "entity_id": "9d8e7f6a-…" } }
```
Ingestion is **asynchronous** — poll `/jobs` for completion. Inline text is deduplicated by SHA-256 content hash; if identical content already exists as a `ready` entity, the job is skipped and the existing entity is returned.
### Re-ingest
```bash
# Re-run pipeline for a single entity (must have stored raw content)
curl -X POST http://localhost:4242/reingest \
-H "Content-Type: application/json" \
-d '{ "entity_id": "9d8e7f6a-…" }'
# Re-queue all entities that have stored raw content
curl -X POST http://localhost:4242/reingest \
-H "Content-Type: application/json" \
-d '{}'
```
### Finetune
Runs LLM relation extraction and keyword tagging on already-ingested entities without re-chunking or re-embedding:
```bash
# Finetune specific entities
curl -X POST http://localhost:4242/finetune \
-H "Content-Type: application/json" \
-d '{ "entity_ids": ["9d8e7f6a-…"] }'
# Finetune all entities of a given type
curl -X POST http://localhost:4242/finetune \
-H "Content-Type: application/json" \
-d '{ "scope": "url" }'
```
### Query
```bash
curl -X POST http://localhost:4242/query \
-H "Content-Type: application/json" \
-d '{ "text": "How does quantum entanglement work?", "k": 5, "threshold": 0.7 }'
```
### Retune
Starts a background retune sweep (re-embeds stale chunks, prunes weak relations):
```bash
curl -X POST http://localhost:4242/retune \
-H "Content-Type: application/json" \
-d '{ "scope": "all", "force": false }'
```
### Jobs
```
GET /jobs?kind=ingest&stage=queued&limit=50
```
| Query param | Values | Description |
|---|---|---|
| `kind` | `ingest`, `retune`, `finetune` | Filter by job type |
| `stage` | `queued`, `fetching`, `chunking`, `embedding`, `sectioning`, `summarising`, `linking`, `tagging`, `extracting`, `done`, `error` | Filter by stage |
| `limit` | 1–200 (default 50) | Max results |
Results include the entity `ref` and `meta` for context.
### Entities
```
GET /entities?type=url&status=ready&source_context=external&q=quantum&id=<uuid>&from=2025-01-01&limit=50&offset=0
GET /entities/broken
GET /entities/projection?offset=0&limit=500
GET /entities/:id
GET /entities/:id/raw
DELETE /entities/:id
POST /entities/bulk-action
```
`/entities/broken` returns non-ready entities annotated with their latest job and a remediation hint:
- `reingest` — raw content is available, pipeline can be re-run
- `no_raw` — source content was not persisted; manual intervention needed
- `stuck` — an active job exists but has not progressed
`/entities/projection` returns a paginated UMAP 3D projection (mean-pooled chunk embeddings per entity), used by the graph view. The projection is cached and recomputed in the background whenever an ingest job completes.
`POST /entities/bulk-action` applies an action to a list of entity IDs:
```json
{ "ids": ["<uuid>", "…"], "action": "delete" | "reingest" | "reingest_force" | "finetune" }
```
Returns `{ results, succeeded, failed }`.
### Chunks
```
GET /chunks?entity_id=<uuid>&limit=500&offset=0
GET /chunks/projection?offset=0&limit=500
GET /chunks/:id
GET /chunks/:id/raw
```
`/chunks/projection` returns a paginated UMAP 3D projection of individual chunk embeddings (same cache/versioning as entity projection).
### Relations
```
GET /relations?origin=heuristic&rel_type=related_to&min_confidence=0.7&limit=50
```
| Query param | Description |
|---|---|
| `origin` | `content_heuristic`, `content_llm`, `semantic`, `asserted` |
| `rel_type` | Relation label string |
| `min_confidence` | Float 0–1 |
| `min_weight` | Float |
| `source_kind` | `entity` or `chunk` |
| `limit` | 1–50000 (default 50) |
### Status
```
GET /status
```
Returns entity/chunk/relation counts, queue depths, worker state, index status, and active config.
---
## WebSocket event stream
Connect to `ws://localhost:4242/stream` (or `wss://` with TLS) to receive live pipeline events. Browsers that cannot send custom headers can authenticate via query param: `?token=<OBS_SECRET>`.
| Event type | Payload fields | Description |
|---|---|---|
| `stage_change` | `job_id`, `stage` | Job moved to a new pipeline stage |
| `complete` | `job_id` | Job finished successfully |
| `error` | `job_id`, `payload` | Job failed — payload contains error detail |
| `heartbeat` | `job_id` (empty), `pid` | Sent every ~10 s per worker; confirms the coordinator is alive |
| `worker_crash` | `name`, `code`, `signal`, `ts` | A worker process exited unexpectedly and is being respawned |
| `projection_version` | `resolution` (`chunk`\|`entity`), `version`, `total`, `ts` | UMAP projection recomputed after a job completes |
| `retune_scheduled` | `ts` | Coordinator queued a periodic retune sweep |
| `db_unavailable` | `ts` | Postgres connection lost — UI shows an alert banner |
| `db_available` | `ts` | Postgres connection restored |
---
## npm scripts
| Script | Description |
|---|---|
| `npm run dev` | Start with tsx watch (hot reload) |
| `npm run build` | Compile TypeScript → `dist/` |
| `npm start` | Run compiled build |
| `npm run migrate` | Apply database migrations |
| `npm run db:up` | Start Dockerised Postgres |
| `npm run db:down` | Stop containers |
| `npm run db:reset` | Wipe volume and restart Postgres |
| `npm run pack:mcpb` | Build and package as a `.mcpb` bundle |
---
## Configuration
All settings are read from environment variables (or a `.env` file). Defaults are shown.
### Infrastructure
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | `postgres://localhost/vkb` | Postgres connection string |
| `RAWSTORE_ADAPTER` | `filesystem` | Raw content storage: `filesystem` or `s3` |
| `RAWSTORE_PATH` | `./rawstore` | Root path for filesystem rawstore |
| `RAWSTORE_S3_BUCKET` | — | S3 bucket name (when `RAWSTORE_ADAPTER=s3`) |
| `RAWSTORE_S3_ENDPOINT` | — | S3-compatible endpoint URL (optional override) |
### Ollama / models
| Variable | Default | Description |
|---|---|---|
| `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama API base URL |
| `EMBED_MODEL` | `nomic-embed-text` | Embedding model |
| `EMBED_DIM` | `768` | Embedding dimension (must match model output) |
| `LLM_MODEL` | `gemma4:e4b` | LLM model for relation extraction and summarisation |
| `LLM_RELATION_EXTRACTION` | `true` | Use LLM to extract relations (set `false` to use heuristics only) |
| `LLM_EXTRACT_CANDIDATES` | `20` | Candidate chunks the LLM considers per extraction pass |
### Chunking & sectioning
| Variable | Default | Description |
|---|---|---|
| `CHUNK_SIZE` | `512` | Target chunk size in tokens |
| `CHUNK_OVERLAP` | `64` | Overlap between adjacent chunks |
| `SECTION_STRATEGY` | `similarity_valley` | Sectioning strategy: `similarity_valley` or `positional` |
| `SECTION_SPLIT_THRESHOLD` | `0.65` | Cosine similarity drop that triggers a section boundary |
| `SECTION_WINDOW_SIZE` | `5` | Sliding window size for valley detection |
| `SECTION_MAX_SIZE` | `8` | Max chunks per section |
### Relations
| Variable | Default | Description |
|---|---|---|
| `RELATION_THRESHOLD` | `0.75` | Minimum cosine similarity to create a relation |
| `RELATION_TOP_K` | `10` | Nearest neighbours considered per chunk |
| `RELATION_CONFIDENCE_STEP` | `0.05` | Increment applied on relation confirmation |
| `RELATION_TTL_DAYS` | `30` | Days before unconfirmed relations are pruned |
| `RELATION_PRUNE_THRESHOLD` | `0.6` | Confidence below which relations are pruned on retune |
### Summarisation
| Variable | Default | Description |
|---|---|---|
| `SUMMARY_CONCURRENCY` | `4` | Parallel LLM calls during the summarising stage |
| `SUMMARY_MAX_INPUT_CHARS` | `12000` | Max characters fed to the entity-level summary prompt (~3 k tokens) |
### Vector index
| Variable | Default | Description |
|---|---|---|
| `IVFFLAT_THRESHOLD` | `1000` | Chunk count above which an ivfflat index is created/maintained |
| `IVFFLAT_LISTS` | `100` | Number of ivfflat lists (tune alongside chunk count) |
### Workers & jobs
| Variable | Default | Description |
|---|---|---|
| `WORKER_CONCURRENCY` | `2` | Number of ingest worker processes |
| `INGEST_MAX_RETRIES` | `3` | Max times a failed job is re-queued |
| `RETUNE_INTERVAL_HOURS` | `6` | How often the retune worker runs automatically (`0` = disabled) |
| `RETUNE_SUMMARISE` | `false` | Regenerate entity summaries during retune |
| `JOB_TTL_DAYS` | `7` | Days before completed/failed jobs are expired |
### Servers & security
| Variable | Default | Description |
|---|---|---|
| `MCP_PORT` | `3333` | MCP server port (`0` = stdio mode only, no auth layer) |
| `OBS_PORT` | `4242` | Observability HTTP/WebSocket server port |
| `OBS_SECRET` | — | Bearer token for the REST/browser API on `OBS_PORT` (leave unset to disable) |
| `MCP_SECRET` | — | Bearer token for the HTTP MCP endpoint on `MCP_PORT` (leave unset to disable) |
| `TLS_CERT` | — | Path to TLS certificate file (enables HTTPS/WSS on all servers) |
| `TLS_KEY` | — | Path to TLS private key file |
| `LOG_LEVEL` | `info` | Log verbosity: `debug`, `info`, `warn`, `error`. Pass `--debug` at startup as a shorthand for `LOG_LEVEL=debug` — also enables full request/response logging on all HTTP endpoints and MCP tool call tracing |
> **Note — stdio mode has no auth layer.** When `MCP_PORT=0` the process communicates over its own stdin/stdout pipe; `MCP_SECRET` and `OBS_SECRET` have no effect on it. Only set `MCP_SECRET` when running in HTTP mode and exposing the port outside localhost — most MCP clients (including Claude Desktop) do not send an `Authorization` header, so setting `MCP_SECRET` in a Claude Desktop config will silently block all tool calls with `401 Unauthorized`.
### Custom prompts
Provide paths to YAML or plain-text files to override the built-in LLM prompts:
| Variable | Description |
|---|---|
| `SUMMARY_PROMPT_FILE` | Entity summary prompt |
| `CHUNK_SUMMARY_PROMPT_FILE` | Per-chunk summary prompt |
| `SECTION_SUMMARY_PROMPT_FILE` | Section summary prompt |
| `RELATION_EXTRACT_PROMPT_FILE` | Relation extraction prompt |
---
## Observability UI
The SPA at **http://localhost:4242/** has two views, selectable from the header:
**Graph (`#viz`)**
- Entity nodes coloured by status (ready / pending / error)
- Edges coloured by origin (heuristic / semantic / LLM / asserted)
- Click a node to inspect entity details, chunks, and relations
- Confidence histogram for relations
**Ingest (`#ingest`)**
- Submit URLs, local files, or inline text
- Live job progress via WebSocket
- Per-job stage tracker
A banner appears at the top of the UI if Postgres becomes unreachable while the server is running, and dismisses automatically on reconnection.
---
## Database resilience
On startup vkb probes the Postgres connection up to 10 times (3-second intervals) before giving up. This means it tolerates the Docker container taking a few seconds to become ready after `docker compose up`.
While running:
- HTTP routes that need the DB return **503** when the connection is lost
- The coordinator broadcasts a `db_unavailable` WebSocket event on the first failed heartbeat
- Ingest workers pause for 30 seconds between retries instead of the normal 2-second poll interval
- A `db_available` event is broadcast and the UI banner clears automatically when connectivity is restored
---
## TLS
Set `TLS_CERT` and `TLS_KEY` to paths of a certificate and private key to enable HTTPS and WSS on all servers. A self-signed cert for local development can be generated with:
```powershell
scripts/gen-cert.ps1
```
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues