Configuration
All configuration is done through environment variables. Variables are validated at startup; the server fails fast with a clear message if a required variable is missing.
Environment variables
| Variable | Default | Description |
|---|---|---|
MCP_API_KEY | (optional — no auth without it) | Bearer token for REST + MCP over HTTP. Ignored in stdio mode. When unset the server starts without authentication (all requests accepted). |
MCP_SESSION_TTL_SECONDS | 1800 | Idle timeout (seconds) before an orphaned MCP session is closed. HTTP mode only. |
MCP_SESSION_MAX | 100 | Maximum concurrent MCP sessions (503 when exceeded). HTTP mode only. |
MCP_SESSION_CREATE_RATE_PER_MINUTE | 30 | New-session creation rate per minute per IP (POST /mcp without a session id). HTTP mode only. |
EMBEDDINGS_BASE_URL | http://localhost:11434/v1 | OpenAI-compatible /v1/embeddings endpoint. |
EMBEDDINGS_API_KEY | (none) | Bearer token for the embeddings API. |
EMBEDDINGS_MODEL | bge-m3 | Embedding model name. |
EMBEDDINGS_DIMENSION | 1024 | Fixed vector dimension for the embedding column (bge-m3 = 1024). Used by the PostgreSQL backend to size the pgvector column. |
EMBEDDINGS_TIMEOUT_MS | 60000 | Abort the embeddings HTTP call after this delay (ms) instead of hanging. |
CHUNK_MAX_CHARS | 3200 | Maximum characters per text chunk. Lower it for small-token embedding models. See Chunk sizing below. |
SEARCH_SNIPPET_MAX_CHARS | 1000 | Maximum characters of chunk content returned per rag_search / GET /search result. Independent from CHUNK_MAX_CHARS; use rag_read for the full document content. |
CONTEXTUAL_CHUNKING_ENABLED | false | At indexing time, generate a short context sentence per chunk (via the configured LLM) that is prepended to the chunk before embedding. See Contextual chunking below. |
CONTEXTUAL_CHUNKING_BASE_URL | EMBEDDINGS_BASE_URL | OpenAI-compatible /v1/chat/completions endpoint used to generate chunk context. Defaults to the embeddings base URL. |
CONTEXTUAL_CHUNKING_MODEL | phi3:minimal | Lightweight LLM that writes the per-chunk context sentence. |
CONTEXTUAL_CHUNKING_TIMEOUT_MS | 60000 | Abort a single context-generation call after this delay (ms). |
CONTEXTUAL_CHUNKING_CONCURRENCY | 4 | Max concurrent /chat/completions calls per document while enriching chunks. |
KB_ROOT | ./kbs (stdio) / /data/kbs (http) | Root directory for knowledge base folders. |
SCAN_INTERVAL | 300 | Scan interval in seconds (0 = disabled). HTTP mode only. |
INDEXER_CONCURRENCY | 4 | Number of index jobs processed in parallel by the async worker. |
INDEXER_RETRY_MAX | 3 | Attempts before a failed job is parked (manual retry via /admin/jobs/:id/retry). |
INDEXER_STALE_TIMEOUT | 1800 | Seconds before a processing job orphaned by a crash is reclaimed as pending. Keep it above INDEXER_JOB_TIMEOUT_MS/1000 so a live job is never reclaimed. |
INDEXER_JOB_TIMEOUT_MS | 600000 | Hard wall-clock deadline for a single job (ms). On expiry the job is aborted (signal propagated to the embeddings / contextual-chunking calls) and failed, instead of spinning forever. |
INDEXER_RETRY_BASE_MS | 5000 | Base delay of the exponential retry backoff: base * 2^(attempts-1). 0 disables backoff. |
INDEXER_RETRY_MAX_MS | 300000 | Upper bound of the retry backoff delay (ms). |
SEARCH_RATE_PER_MINUTE | 60 | Rate limit on GET /search per minute per IP (HTTP mode only). |
REINDEX_RATE_PER_MINUTE | 60 | Rate limit on POST /admin/reindex per minute per IP (HTTP mode only). |
PORT | 8000 | HTTP listen port. HTTP mode only. |
DB_PATH | ./rag.db (stdio) / /data/index/rag.db (http) | SQLite database path. |
STORE_BACKEND | sqlite | sqlite (default, standalone) or postgres (requires a reachable Postgres). |
DATABASE_URL | (none) | Postgres connection string. Takes precedence over the individual PG_* vars. |
PG_HOST | localhost | Postgres host. |
PG_PORT | 5432 | Postgres port. |
PG_DATABASE | raghub | Postgres database name. |
PG_USER | (none) | Postgres user. |
PG_PASSWORD | (none) | Postgres password. |
PG_SSL | false | Enable TLS for the Postgres connection. |
CORS_ORIGINS | (none) | Allowed CORS origins (comma-separated). Empty = disables the CORS restriction. |
TEXT_EXTENSIONS | (none) | Additional text extensions to index (comma-separated, e.g. .kt,.java,.go). Merged with the built-in list, not replacing it. Unknown extensions are also auto-detected as text via magic bytes. |
RAG_TRANSPORT | stdio | stdio or http. The --http flag wins. |
VERSION | package.json | Reported version. Defaults to the version field of package.json. |
LOG_LEVEL | info | pino log level (trace, debug, info, warn, error). |
NODE_ENV | production | development enables pretty log output; otherwise JSON on stderr. |
Recommended embedding models
| Model | Notes |
|---|---|
| bge-m3 | Multilingual (FR/EN), 1024d, best open-source retrieval, CPU-friendly. Recommended. |
nomic-embed-text | Lighter, English-focused. |
text-embedding-3-small | OpenAI API. |
Any OpenAI-compatible /v1/embeddings endpoint works — Ollama, Bifrost, OpenAI, LM Studio…
Chunk sizing
CHUNK_MAX_CHARS caps the size of each chunk fed to the embedding model. If a chunk exceeds the model's token limit, the embeddings call fails. Estimate a safe value for your model with:
CHUNK_MAX_CHARS ≈ chars-per-token × token limitThe chars-per-token ratio depends on the script. Dense scripts tokenize heavily, so a character budget that works in English can overflow a small-token model in another language.
| Script | Chars/token | CHUNK_MAX_CHARS for a 512-token model |
|---|---|---|
| Latin (EN, FR…) | ~4 | ~2000 |
| Cyrillic (RU…) | ~2.5 | ~1200 |
Lower CHUNK_MAX_CHARS when you use a small-token model (e.g. 512) or dense scripts. The 400-character overlap is preserved independently of this value.
Contextual chunking
When enabled, a lightweight LLM writes a short context sentence for each chunk at indexing time. That sentence is prepended to the chunk only when computing the embedding — the stored content stays unchanged. This anchors the vector to its place in the document, improving search precision at zero runtime latency (the cost is paid once, per chunk, when re-indexing).
CONTEXTUAL_CHUNKING_ENABLED=true \
CONTEXTUAL_CHUNKING_BASE_URL=http://localhost:11434/v1 \
CONTEXTUAL_CHUNKING_MODEL=phi3:minimalOpt-in and default-off. CONTEXTUAL_CHUNKING_BASE_URL defaults to EMBEDDINGS_BASE_URL; pick a light model (phi3:minimal, qwen2.5:0.5b, …) since it is called once per chunk at index time. Concurrent calls are bounded per document by CONTEXTUAL_CHUNKING_CONCURRENCY and each call is aborted after CONTEXTUAL_CHUNKING_TIMEOUT_MS. When the LLM is unreachable or too slow, the chunk is embedded raw so indexing never fails.
Note — enabling contextual chunking changes embeddings, so rebuild the index from scratch (
rag_reindex { force: true }via MCP, orPOST /admin/reindex?force=truevia REST) for previously indexed KBs to benefit. A plain incremental scan skips unchanged files.
Storage backends
Two backends implement the same Store interface. SQLite is the default and runs standalone; PostgreSQL is opt-in.
SQLite (default)
A single file (DB_PATH) with FTS5 full-text search. No database server required — nothing else to start.
PostgreSQL (pgvector)
Opt-in for deployments that prefer a server-side store. Embeds the pgvector extension (vector column + ts_rank full-text search). Point the server at a reachable Postgres with the vector extension:
STORE_BACKEND=postgres \
DATABASE_URL=postgres://raghub:raghub@localhost:5432/raghub \
node dist/index.jsThe schema and the vector extension are created idempotently on first connect. Set EMBEDDINGS_DIMENSION to match your model before ingesting — the column is sized once at migration time.
Tests : the PostgreSQL backend is exercised without any server via PGlite, a real Postgres engine compiled to WASM with the pgvector extension bundled, instantiated in-memory for the duration of the suite (see
src/test/e2e/pg-store.e2e.test.ts).
Transport modes
- stdio (default): serve MCP tools on stdin/stdout for a local agent. No scan loop, no REST.
- http (
--http/RAG_TRANSPORT=http): REST API + streamable-http MCP onPORT.
In stdio mode, logs go to stderr (stdout is reserved for JSON-RPC); there is no periodic scan.
Scan behavior
Indexing is asynchronous. The producer (scanAll) discovers files on disk, computes their SHA-256, compares with known records, and enqueues index jobs. A background worker picks up jobs, processes them with configurable concurrency (INDEXER_CONCURRENCY), and handles retries.
- Sync fallback for tests — e2e tests use a
createSyncTestQueuehelper that processes jobs immediately on enqueue, preserving the synchronous test pattern without a running worker. - File records are not created by the producer — they appear only once the worker has successfully indexed the chunks. Until then, the file is unknown to the index and will be re-enqueued on the next scan.
- Skipped vs excluded — the scan tally (
+added ~modified -deleted =skipped xexcluded) distinguishes files that are unchanged (skipped) from files that were scanned but not indexed because they have no extractable text (excluded, i.e. binary or empty). Run withLOG_LEVEL=debugto see the per-file reason. - Self-healing vectors — if the embeddings endpoint is down during a scan, chunks are stored without a vector. On the next scan any file with null-vector chunks is automatically re-indexed (content unchanged is not enough to skip it), so no manual table wipe is needed to recover. The scan log reports these as
re-indexing (null embeddings). - Failed jobs — after
INDEXER_RETRY_MAXfailed attempts a job is parked withstatus=failed. It stays visible inGET /admin/jobsand can be retried manually viaPOST /admin/jobs/:id/retry. The scan never re-enqueues a parked job (no infinite retry loop). - Retry backoff — a failed attempt is not retried immediately: the job is parked as
pendingwith aretry_attimestamp computed asINDEXER_RETRY_BASE_MS * 2^(attempts-1), capped atINDEXER_RETRY_MAX_MS. The worker only claims jobs whoseretry_athas elapsed, so a struggling embeddings endpoint is not hammered. - Per-job deadline — every job runs under an
INDEXER_JOB_TIMEOUT_MSwall-clock deadline. On expiry the job'sAbortSignalis propagated to the embeddings and contextual-chunking HTTP calls (which are aborted) and the job is failed, rather than hanging a worker slot forever. - Crash recovery — the
reclaimStalemechanism runs every poll cycle: any job stuck inprocessingfor longer thanINDEXER_STALE_TIMEOUTseconds is reclaimed. Reclaiming counts as an attempt, so a job that never completes eventually parks asfailedinstead of looping between reclaim and claim forever. KeepINDEXER_STALE_TIMEOUTaboveINDEXER_JOB_TIMEOUT_MS/1000 (a startup warning flags the opposite) so a live job always finishes before it is considered stale. A worker also never starts a second execution of a job whose previous attempt is still in flight, even if it is re-claimed. - Progress visibility —
GET /admin/status/rag_statusreport the number of documents with unsearchable (null-embedding) chunks and list the jobs currentlyprocessingwith their per-job chunk progress (done/total);GET /admin/jobs/rag_jobsadditionally report each in-flight job's running time.