Self-hosted documentation search and RAG chat over multiple knowledge spaces, with per-user access control.
You point it at your markdown (an Obsidian vault, exported docs, .docx files), and it gives you hybrid semantic + keyword search, a chat that answers from your own documents with citations back to the source chunk, and per-space permissions so the support team doesn't see the infra runbooks. It also exposes the same corpus over MCP, so coding agents can search your docs directly.
Warning
This project is not actively maintained. It was built for one small team's internal use and is published as a reference, not a product. There is no test suite, no release process, no support, and no guarantee that a future Supabase or Next.js release won't break it. Issues and pull requests may go unanswered. Read Known issues before deploying it anywhere that matters — in particular, the Row Level Security policies are known to be broken and the app compensates in application code.
Documents, tree, and chat in one resizable workspace — chat keeps the open document in context.
Chat answers from retrieved chunks only, with inline citations that link back to the exact source document.
Hybrid search — pgvector cosine similarity and Postgres full-text search, fused with Reciprocal Rank Fusion, so both "how do I get time off" and an exact error code find their document.
Per-space, per-user access as an editable matrix. Spaces a user has no role in are invisible to them, not merely unlinked.
Personal access tokens for the remote MCP endpoint, shown once at mint time.
Ingest (src/lib/ingest/pipeline.ts) has two tiers:
- Indexed —
.md,.markdown,.txt,.docx, and.ziparchives of those. Extracted to markdown, chunked on heading and paragraph boundaries, embedded in batches of 100, and written todocuments+chunks. Obsidian-style[[wikilinks]]are resolved to real document references. Large code blocks are stored but deliberately not embedded — they dilute the vector space without improving recall. - Metadata-only —
.pdf,.pptx,.xlsx, and friends. Uploaded to object storage with adocumentsrow and no chunks. Findable by title and tag, but not semantically. These become indexed automatically if you add an extractor for the format.
A content hash short-circuits re-uploads of unchanged files, and every create/replace writes an audit row.
Retrieval (src/lib/search.ts) embeds the query, then calls the hybrid_search() Postgres function, which runs pgvector cosine similarity and Postgres FTS independently and fuses the two rankings with Reciprocal Rank Fusion. RRF reads only rank, not score, so it needs no calibration between two systems whose scores aren't comparable. The top results are then optionally reranked by a cross-encoder; a reranker failure falls back to RRF order rather than failing the query.
Chat (src/app/api/chat/route.ts) streams from OpenRouter and can call retrieval as a tool, capped at 6 iterations per turn. The system prompt constrains answers to retrieved context, and citations are parsed back out of the response and linked to their source chunk.
Everything routes through a single OpenRouter key — embeddings, chat, and reranking — so there's one bill and one place to set a spend ceiling.
- Next.js 16 (App Router, RSC, Server Actions, TypeScript strict), React 19
- shadcn/ui, Tailwind 4, Radix
- Supabase — Postgres 15 with pgvector, pg_trgm and unaccent, plus Storage and Auth
- OpenRouter for embeddings (
openai/text-embedding-3-small, 1536 dims), chat, and reranking (cohere/rerank-3.5) - Drizzle ORM for TypeScript types only — the SQL in
supabase/migrations/is the schema source of truth - Deployed on Vercel; the chat route runs on the Node runtime with a 60s max duration
You need a Supabase project and an OpenRouter key. Expect a few dollars a month at small scale — embedding a few thousand chunks is well under $1, and chat is whatever model you pick.
Copy the project URL, publishable key, and secret key from Project Settings → Data API, and the transaction-mode pooler connection string from Project Settings → Database, into .env.local:
cp .env.example .env.local # then fill it in
npm install
npm run db:migratedb:migrate applies every .sql file in supabase/migrations/ in order, idempotently:
| File | Contents |
|---|---|
0001_extensions.sql |
vector, pg_trgm, unaccent |
0002_schema.sql |
Tables and indexes |
0003_rls.sql |
Row Level Security policies — see Known issues |
0004_hybrid_search.sql |
The hybrid_search() function |
0005_storage.sql |
The originals bucket and its storage policies |
0006_user_settings.sql |
Per-user model overrides |
0007_mcp_tokens.sql |
Hashed personal access tokens |
These files are the source of truth. Change the file and re-run; don't edit policies live, and don't use drizzle-kit to generate migrations against the live database — the Drizzle schema in src/lib/db/schema.ts is a hand-maintained typing mirror.
Create a key, add credits, and set a hard monthly spend ceiling in the OpenRouter dashboard. MONTHLY_SPEND_CEILING in .env.local is informational only — it does not stop spend.
npm run seed you@example.comInvites the email, marks the user admin, and creates three starter spaces with owner access. A magic link goes to that inbox.
On a network where Supabase's built-in SMTP gets quarantined, skip email entirely:
npm run setpassword you@example.com <password>The /login page takes a password as well as a magic link.
npm run devImport the repo in Vercel and add every variable from .env.example as a project env var. The weekly cron in vercel.json starts firing on deploy.
If you serve the app on a bare apex and a www host, set NEXT_PUBLIC_CANONICAL_HOST=www.your-domain.com and configure the apex to serve the app rather than platform-redirect to www. src/proxy.ts then 308s pages to the canonical host while leaving /mcp and /api alone — an apex→www redirect is a cross-origin hop, and fetch strips the Authorization header across it, which silently breaks token auth. Leave the variable unset to disable canonicalization entirely.
The admin UI at /admin/upload takes individual files and zips. For bulk imports, the CLI is better:
npm run docbased -- import ./my-vault --space engineering --tags importedIt walks a folder, resolves images referenced by ![[...]] or  against the Obsidian vault root, and uploads them as assets alongside each note. --dry-run shows what would happen.
Two surfaces reach the corpus, with different auth.
CLI — authenticates by environment variable, not token. Service mode (the default) uses the Supabase secret key and acts as the first admin, or --as <email>. Setting DOCBASED_EMAIL and DOCBASED_PASSWORD runs it scoped to a real user's permissions instead.
npm run docbased -- spaces
npm run docbased -- search "database failover" --space engineering --rerank
npm run docbased -- ask "what is the refund window?"
npm run docbased -- doc get engineering/Runbooks/incident-responsenpm run docbased -- --help lists everything. npm link makes it available as docbased from any directory.
Remote MCP — Streamable HTTP at /mcp, authenticated with a personal access token (Authorization: Bearer dbk_…). Mint one in Settings → Access tokens; it's shown once and revocable there. The tools are list_spaces, search_documents, list_documents, get_document, get_chunk, get_chunk_neighbors, and save_document, all scoped to the token owner's space access.
claude mcp add --transport http docbased https://your-domain.com/mcp \
--header "Authorization: Bearer dbk_..."Claude Desktop needs the mcp-remote bridge; the Settings page prints a copy-pasteable config. There's also a standalone stdio server in packages/docbased-mcp/ that shares the same tool definitions (src/lib/core/mcp-tools.ts).
Backups. scripts/backup.ts runs pg_dump and uploads to a private backups Storage bucket, which you create once by hand. It needs a host that has pg_dump — Vercel functions can't shell out to it, so the weekly cron at /api/cron/backup is only a heartbeat that POSTs to CRON_BACKUP_WEBHOOK to trigger your own runner (GitHub Actions works).
Restore. Create a fresh project, download the latest weekly/kb-*.sql, psql $DATABASE_URL < kb-<date>.sql, then run npm run db:migrate — it's safe on a restore and re-applies any policy changes newer than the snapshot.
Changing embedding model. Set EMBEDDING_MODEL to something that also produces 1536-dim vectors, or migrate the chunks.embedding column and the index first — both hybrid_search() and the index assume 1536. Then POST /api/admin/reembed repeatedly until it returns { remaining: 0 }; each call does 100 chunks.
Controlling cost. Lower CHAT_DAILY_LIMIT (default 50 messages/user/day), set RERANKER_ENABLED=false, or switch DEFAULT_CHAT_MODEL to something cheaper. Per-user usage is at /admin/usage. New chat models must be added to both CHAT_MODEL_ALLOWLIST in src/lib/env.ts and src/lib/ai/openrouter-models.ts, or the UI will reject them.
Admins. /admin/users has a "Make admin" toggle, or update users set is_admin = true where email = '…'. Keep at least two — note that admins can see every space (src/lib/auth.ts), so admin is not a neutral role.
The RLS policies are broken, and the app works around them in application code. The users SELECT policy in 0003_rls.sql checks for admin with exists (select 1 from users me where me.id = auth.uid() and me.is_admin) — a subquery against the very table the policy guards, which recurses. The same self-referencing admin branch is repeated in the spaces, space_access, and documents policies.
The consequence is that the app cannot rely on RLS for those tables. Instead it validates the session with the user-scoped client and then reads and writes through the service client, which bypasses RLS, having done the authorization check itself in src/lib/auth.ts. That works, but it means access control lives in application code, not in the database, so any new query path that forgets its own check is an access-control bug rather than something the database catches. RLS is still load-bearing for chunks and audit_log.
Fixing this properly means moving the admin lookup into a security definer function so the policy doesn't re-enter the table it protects. That has not been done.
Verify isolation before putting real content in. Take two users with access to different spaces and confirm from SQL — connecting with each user's JWT as the context, not the service key — that neither can read the other's documents. Without that check you are trusting these policies to be correct, and per the above, they are not.
src/
app/
(app)/ auth-required routes, wrapped in the persistent shell
space/[slug]/ browse a space
doc/[id]/ viewer and editor
chat/[id]/ chat threads
search/ full search results
admin/ upload, users, spaces, access, audit, usage
(docs)/docs/ self-hosted documentation site (fumadocs)
api/ route handlers
mcp/ remote MCP endpoint
login/, auth/ magic-link and password sign-in, OAuth callback
components/ sidebar, top bar, chat panel, command palette, viewer
lib/
auth.ts requireUser / requireAdmin / getAccessibleSpaces
env.ts centralized env access, fails loud at boot
core/ tool bodies shared by the CLI and both MCP servers
ingest/ extractors, chunker, pipeline, wikilinks, hashing
ai/ OpenRouter client, model allowlist, tool specs
search.ts hybrid_search wrapper and reranker
chat.ts system prompt, context builder, citation parsing
supabase/ server, browser, and proxy clients
db/schema.ts Drizzle typing mirror of the SQL
proxy.ts Next 16 proxy (formerly middleware) — refreshes sessions
supabase/migrations/ SQL — schema, RLS, hybrid_search, storage policies
packages/docbased-mcp/ standalone stdio MCP server
scripts/ migrations, seed, backup, CLI, bulk import
content/docs/ MDX for the built-in docs site
docs/screenshots/ README images and the shot list that regenerates them
There is no test suite. npm run typecheck is the only automated check.
MIT — see LICENSE.




