The self-finished edge that keeps your scattered knowledge from unraveling.
A selvage is the woven edge of fabric that stops it from fraying. Selvage the project is the same idea for your team's knowledge: a thin, hybrid-searchable layer over Slack, wikis, code, and databases, queryable by humans, MCP clients, and agents. Self-hosted, local-first, MIT-licensed.
Every growing team reinvents the same broken thing: a half-searchable wiki, a Slack history nobody can find, docs that quietly go stale. The usual fix, "put everything in one place," never survives contact with how people actually work. Data keeps living where it's produced.
Selvage takes the other path: a common interface over data wherever it already lives. Connectors normalize each source into one shared shape and land it in a single Postgres table. Retrieval knows nothing about where a record came from, so exact error codes and paraphrased questions both work, and the same query serves a person at a CLI, an agent over MCP, or the web UI.
- Pluggable connectors. A small contract, not a fixed integration list. Ships with filesystem/git-repo, Slack, and generic Postgres-table connectors. Writing your own is about 50 lines (guide).
- One unified store. Postgres + pgvector. Every source lands in the same
documentstable; nothing connector-specific leaks into retrieval. - Hybrid retrieval. Full-text, vector, and rare-term lists, fused with Reciprocal Rank Fusion, recency-decayed, then cross-encoder reranked.
- Local by default. Embeddings and reranking run on your CPU via ONNX. The quickstart needs no API key, no cloud account, no tokens.
- MCP-native. Narrow, LLM-free tools any agent can compose, plus a minimal web UI that adds a plan → fan-out → synthesis loop for humans.
Five minutes, needs Node 20+ and Docker.
git clone https://github.com/naidx0/selvage.git && cd selvage
npm install
npm run setup # starts Postgres+pgvector in Docker (port 5433), migrates schema
npm run sync # indexes examples/docs via the filesystem connector
npx tsx cli.ts search "ERR_GRIPPER_TIMEOUT_0x2F" # exact-token query
npx tsx cli.ts search "how do new engineers get set up" # semantic query
npx tsx cli.ts who-knows "gripper stalls" # who has written about a topicThe first query in a process loads the local models (a few seconds, up to ~20s cold on a busy machine); the first run ever downloads ~40MB of ONNX models from the Hugging Face hub and caches them for later. No account required.
The quickstart indexes the sample corpus in examples/docs. To index your own,
edit selvage.config.json and change the filesystem source's path to any
folder of markdown, code, or text, then sync again.
Before (ships in the repo):
{
"sources": [
{ "id": "example-docs", "connector": "filesystem", "config": { "path": "./examples/docs" } }
],
"projects": {
"handbook": { "description": "Example project scoping only the sample docs", "sources": ["example-docs"] }
}
}After (your handbook plus a code repo, bundled into one project):
{
"sources": [
{ "id": "handbook", "connector": "filesystem", "config": { "path": "/Users/you/notes" } },
{ "id": "app-code", "connector": "filesystem", "config": { "path": "/Users/you/src/app" } }
],
"projects": {
"engineering": { "description": "Docs + code for the app team", "sources": ["handbook", "app-code"] }
}
}npm run sync # re-index everything
npx tsx cli.ts sources # list sources + document counts
npx tsx cli.ts search "your query" --project engineeringA project is a named bundle of sources that scopes a query so you don't get
cross-domain noise. A source can live in many projects without being duplicated.
SELVAGE_CONFIG points at a different config file if you don't want it in the
repo root.
A connector is one self-contained folder, connectors/<name>/index.ts, that
implements a four-field contract: { name, syncMode, validateConfig, sync }.
Core handles embedding, batching, upserts, change detection, pruning, and
project scoping. Three ship in the box.
Points at a directory and indexes every known-text file, chunked at
language-aware boundaries (markdown at headings, code at declaration
boundaries, prose at paragraphs). full sync, so deleted and renamed files
drop out of the index on the next run. For markdown it also extracts an author
(YAML frontmatter author: or a leading **Author:** line) so who-knows
works on plain files.
{
"id": "handbook",
"connector": "filesystem",
"config": { "path": "./docs", "exclude": [".git", "node_modules"], "maxFileSizeKb": 512 }
}No secrets. exclude and maxFileSizeKb are optional (defaults shown).
Polls conversations.history per channel and resumes from a durable cursor,
so it runs fine from a cron job or a one-shot sync with just a bot token. It
also walks thread replies. The bot must be a member of each channel.
{
"id": "eng-slack",
"connector": "slack",
"config": { "channels": ["C0123ABC", "C0456DEF"], "lookbackDays": 180 }
}Set the token in the environment (never in the config file):
export SLACK_BOT_TOKEN=xoxb-...Required bot scopes: channels:history, channels:read, users:read,
team:read. Override the env var name with tokenEnv if you prefer.
The "bring your own database" reference. Point it at a table (or arbitrary
SELECT) and map columns onto the shared schema. Keyset pagination, full
sync. examples/postgres-seed.sql is a ready-made sample table to try it.
{
"id": "tickets",
"connector": "postgres",
"config": {
"table": "support_tickets",
"columns": { "id": "id", "content": "body", "title": "subject", "author": "agent", "timestamp": "updated_at" }
}
}Set the source database URL in the environment:
export SELVAGE_PG_SOURCE_URL=postgres://user:pass@host:5432/dbnameOverride the env var name with connectionStringEnv. Provide exactly one of
table or query; the id column must be orderable.
The contract is the whole surface, and core never needs touching. docs/connector-authoring.md walks through a complete JSON-lines connector in about 50 lines, with the rules that keep a connector honest (secrets from env, fail loudly, pure exported normalization, log to stderr) and a pre-ship checklist.
flowchart LR
subgraph sources [Your data, where it already lives]
FS[Folder / git repo]
SL[Slack]
PG[(Any Postgres table)]
X[Your connector...]
end
subgraph selvage [Selvage]
C1[Connectors: normalize]
DB[(Postgres + pgvector<br/>unified documents table)]
subgraph retrieval [Hybrid retrieval, LLM-free]
FTS[full-text GIN]
VEC[vector HNSW]
RARE[rare-term IDF]
RRF[RRF fusion k=60]
DECAY[recency decay]
RERANK[cross-encoder rerank]
FTS --> RRF
VEC --> RRF
RARE --> RRF
RRF --> DECAY --> RERANK
end
C1 -->|shared schema + embeddings| DB
DB --> FTS & VEC & RARE
end
subgraph clients [Clients own orchestration]
MCP[MCP server]
WEB[Web UI]
CLI[CLI]
end
FS & SL & PG & X --> C1
RERANK --> MCP & WEB & CLI
Pure vector search loses on exact tokens like error codes and flag names, and short-but-rare messages sink under generic filler on cosine similarity. So Selvage runs three ranked lists per query, each over the resolved project or source scope:
- Full-text over a stored
tsvectorwith a GIN index, ranked byts_rank_cd. Wins on exact tokens. - Vector, pgvector HNSW cosine, the query embedded with the BGE instruction prefix. Wins on paraphrase.
- Rare-term, an IDF-style guard: query tokens that are rare in the corpus get their own list so a single exact match can't be drowned out.
The three lists fuse with Reciprocal Rank Fusion: score(d) = Σ 1/(60 + rank). RRF needs only ranks, which is exactly right when ts_rank_cd and
cosine similarity live on incomparable scales. k=60 is the standard default
from Cormack, Clarke & Büttcher, "Reciprocal Rank Fusion Outperforms Condorcet
and Individual Rank Learning Methods" (SIGIR 2009).
A recency decay factor in [0.5, 1] (half-life 90 days) discounts stale
answers without erasing them, so an exact-token hit on an old postmortem still
surfaces. Finally the fused top-12 goes through a local cross-encoder
(Xenova/ms-marco-MiniLM-L-6-v2) that reads query and document together. Every
result carries a signals object with the per-list ranks, RRF score, decay
factor, and raw rerank logit, so any ranking is debuggable.
Embeddings are local ONNX (Xenova/bge-small-en-v1.5, 384-dim, q8) via
@huggingface/transformers, on CPU, no API key. Full detail and the decision
log: docs/architecture.md.
Real CLI output against the sample corpus (12 chunks), trimmed to the top hits.
Exact-token query lands on the error-registry chunk, with the matching postmortem right behind it:
$ npx tsx cli.ts search "ERR_GRIPPER_TIMEOUT_0x2F"
1. [0.997] example-docs · Fleet API reference › Error registry (excerpt)
## Error registry (excerpt) | Code | Meaning | | `ERR_GRIPPER_TIMEOUT_0x2F` |
Servo watchdog fired during gripper actuation. | ...
fts#2 vec#1 rare#2 rrf=0.0487 recency=1.00 rerank=8.11
2. [0.989] example-docs · Postmortem: fleet-wide gripper stalls, 2026-03-12
# Postmortem: fleet-wide gripper stalls, 2026-03-12 **Severity:** SEV-1 ...
fts#1 vec#2 rare#1 rrf=0.0489 recency=1.00 rerank=7.95
who-knows aggregates authored matches across sources:
$ npx tsx cli.ts who-knows "gripper stalls"
1. Priya Raman — 2 matches (sources: example-docs, latest 2026-07-17)
Add --json to any query for machine-readable output, --project <name> or
--sources <a,b> to scope it, and --no-rerank to skip the cross-encoder.
Expose retrieval to Claude Code or any MCP client. The tools are deliberately narrow and LLM-free, so an agent can fan out over them cheaply and owns its own orchestration.
# Run from the repo root; the path must be absolute.
claude mcp add selvage -- npx tsx "$(pwd)/mcp-server/index.ts"Tools exposed:
| Tool | Does |
|---|---|
selvage_search |
Hybrid search, returns chunks with their citation uri |
selvage_who_knows |
Ranks people by how much they authored on a topic |
selvage_list_sources |
Lists connected sources with document counts |
selvage_list_projects |
Lists projects (search scopes) and their sources |
selvage_search and selvage_who_knows take an optional project or sources
scope. Smoke-test the server end-to-end anytime:
npx tsx scripts/mcp-smoke.mtsnpm run web # http://localhost:3000 (override with SELVAGE_WEB_PORT)Search mode is fully local, the same hybrid pipeline as the CLI.
Ask mode adds a reference agent loop (plan → parallel tool fan-out →
synthesized, cited answer) over the same tools; it needs ANTHROPIC_API_KEY
set. Without the key, Search still works and Ask reports itself as disabled
rather than failing. Model defaults to claude-sonnet-5
(SELVAGE_ANTHROPIC_MODEL).
The web UI is single-node and has no built-in auth. Run it on localhost or behind your own proxy.
Everything has a working default except the secrets for optional connectors and
Ask mode. Copy .env.example to .env or export in your shell.
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
postgres://selvage:selvage@localhost:5433/selvage |
Selvage's own Postgres (matches docker-compose.yml) |
SELVAGE_CONFIG |
selvage.config.json |
Path to the source/project config |
SELVAGE_EMBEDDINGS |
local |
local (ONNX, no account) or openai (any OpenAI-compatible /v1/embeddings) |
SELVAGE_EMBED_MODEL |
Xenova/bge-small-en-v1.5 |
Local embedding model |
SELVAGE_EMBED_DIM |
384 |
Embedding dimension |
SELVAGE_EMBED_DTYPE |
q8 |
q8 (fast) or fp32; part of the index identity |
SELVAGE_HALF_LIFE_DAYS |
90 |
Recency-decay half-life |
SELVAGE_RERANK |
true |
Cross-encoder rerank of the fused top-N |
SELVAGE_RERANK_MODEL |
Xenova/ms-marco-MiniLM-L-6-v2 |
Cross-encoder model |
SLACK_BOT_TOKEN |
— | Bot token for a Slack source |
SELVAGE_PG_SOURCE_URL |
— | Source DB URL for a Postgres source |
ANTHROPIC_API_KEY |
— | Enables Ask mode in the web UI |
SELVAGE_ANTHROPIC_MODEL |
claude-sonnet-5 |
Model for Ask mode |
SELVAGE_WEB_PORT |
3000 |
Web UI port |
OPENAI_API_KEY / OPENAI_BASE_URL apply only when SELVAGE_EMBEDDINGS=openai.
Full annotated list in .env.example.
core/ shared schema, embeddings, chunking, retrieval (RRF, rerank, recency)
connectors/ one self-contained folder per connector (filesystem, slack, postgres)
mcp-server/ MCP tool exposure (stdio)
web/ minimal reference UI + agent loop
docs/ architecture + connector-authoring guide
examples/ sample corpus + postgres seed to try everything without real data
cli.ts migrate / sync / search / who-knows / sources / projects
Honest about what's proven and what isn't.
- Scale is unproven at the high end. Verified on a small corpus. CPU embeddings are fine for thousands of documents; hundreds of thousands is untested.
- English-only full-text. The Postgres FTS config is
'english'. Other languages still get vector and rare-term matching, but not the full-text list. - Slack is unit-tested, not live-tested. The normalization and polling logic have tests, but the connector has not run against a real workspace. Polling also misses edits and deletions of already-indexed messages (fetches are "newer than cursor" only).
access_scopeis stored, not enforced. It's surfaced on every result but never used as a filter. Selvage is a retrieval layer, not an authorization system; callers that need enforcement read the label and drop what a user may not see.- Single-node, no web UI auth. Run it on localhost or behind your own proxy.
- No migration versioning yet. The schema evolves by a fresh
migrate.
- More connectors. Notion, GitHub, and an Obsidian-vault connector are the next targets. Want a different source? Open a connector request.
- Incremental-sync reconciliation so incremental connectors (Slack) can pick up edits and deletes, not just new records.
- Non-English full-text configs, selectable per source.
Selvage was built and finished end-to-end with orchestrated AI agents: parallel exploration sweeps, phase-gated build waves with hard file-ownership boundaries so no two agents touched the same file, and three rounds of adversarial external review that converged to zero findings. A 37-test gate (9 of them DB-backed integration) stands over every change, and every claim in this README was executed live before publication. The full receipt, including the plan and what actually shipped, is in docs/finish-plan.md.
Bug reports, connector requests, and PRs welcome. Start with CONTRIBUTING.md; adding a connector is the headline contribution path.
MIT. Fork it, extend it, ship it. See LICENSE.