qmd

mini cli search engine for your docs, knowledge bases, meeting notes, whatever. Tracking current sota approaches while being all local

28,793 stars TypeScript Markdown Skills API Spec
AI Prompts & Specs

Repository: tobi/qmd


Stars: 21994

CLAUDE.md

QMD - Query Markup Documents

Use Bun instead of Node.js (bun not node, bun install not npm install).

Commands

sh
qmd collection add . --name <n>   # Create/index collection
qmd collection list # List all collections with details
qmd collection remove <name> # Remove a collection by name
qmd collection rename <old> <new> # Rename a collection
qmd ls [collection[/path]] # List collections or files in a collection
qmd context add [path] "text" # Add context for path (defaults to current dir)
qmd context list # List all contexts
qmd context check # Check for collections/paths missing context
qmd context rm <path> # Remove context
qmd get <file> # Get document by path or docid (#abc123)
qmd multi-get <pattern> # Get multiple docs by glob or comma-separated list
qmd status # Show index status and collections
qmd update [--pull] # Re-index all collections (--pull: git pull first)
qmd embed # Generate vector embeddings (uses node-llama-cpp)
qmd query <query> # Search with query expansion + reranking (recommended)
qmd search <query> # Full-text keyword search (BM25, no LLM)
qmd vsearch <query> # Vector similarity search (no reranking)
qmd mcp # Start MCP server (stdio transport)
qmd mcp --http [--port N] # Start MCP server (HTTP, default port 8181)
qmd mcp --http --daemon # Start as background daemon
qmd mcp stop # Stop background MCP daemon

Collection Management

sh

List all collections


qmd collection list

Create a collection with explicit name


qmd collection add ~/Documents/notes --name mynotes --mask '/*.md'

Remove a collection


qmd collection remove mynotes

Rename a collection


qmd collection rename mynotes my-notes

List all files in a collection


qmd ls mynotes

List files with a path prefix


qmd ls journals/2025
qmd ls qmd://journals/2025

Context Management

sh

Add context to current directory (auto-detects collection)


qmd context add "Description of these files"

Add context to a specific path


qmd context add /subfolder "Description for subfolder"

Add global context to all collections (system message)


qmd context add / "Always include this context"

Add context using virtual paths


qmd context add qmd://journals/ "Context for entire journals collection"
qmd context add qmd://journals/2024 "Journal entries from 2024"

List all contexts


qmd context list

Check for collections or paths without context


qmd context check

Remove context


qmd context rm qmd://journals/2024
qmd context rm / # Remove global context

Document IDs (docid)

Each document has a unique short ID (docid) - the first 6 characters of its content hash.
Docids are shown in search results as #abc123 and can be used with get and multi-get:

sh

Search returns docid in results


qmd search "query" --json

Output: [{"docid": "#abc123", "score": 0.85, "file": "docs/readme.md", ...}]

Get document by docid


qmd get "#abc123"
qmd get abc123 # Leading # is optional

Docids also work in multi-get comma-separated lists


qmd multi-get "#abc123, #def456"

Options

sh

Search & retrieval


-c, --collection <name> # Restrict search to a collection (matches pwd suffix)
-n <num> # Number of results
--all # Return all matches
--min-score <num> # Minimum score threshold
--full # Show full document content
--line-numbers # Add line numbers to output

Multi-get specific


-l <num> # Maximum lines per file
--max-bytes <num> # Skip files larger than this (default 10KB)

Output formats (search and multi-get)


--json, --csv, --md, --xml, --files

Development

sh
bun src/cli/qmd.ts <command>   # Run from source
bun link # Install globally as 'qmd'

Tests

All tests live in test/. Run everything:

sh
npx vitest run --reporter=verbose test/
bun test --preload ./src/test-preload.ts test/

Architecture

- SQLite FTS5 for full-text search (BM25)
- sqlite-vec for vector similarity search
- node-llama-cpp for embeddings (embeddinggemma), reranking (qwen3-reranker), and query expansion (Qwen3)
- Reciprocal Rank Fusion (RRF) for combining results
- Smart chunking: 900 tokens/chunk with 15% overlap, prefers markdown headings as boundaries
- AST-aware chunking: use --chunk-strategy auto to chunk code files (.ts/.js/.py/.go/.rs) at function/class/import boundaries via tree-sitter. Default is regex (existing behavior). Markdown and unknown file types always use regex chunking.

Important: Do NOT run automatically

- Never run qmd collection add, qmd embed, or qmd update automatically
- Never modify the SQLite database directly
- Write out example commands for the user to run manually
- Index is stored at ~/.cache/qmd/index.sqlite

Do NOT compile

- Never run bun build --compile - it overwrites the shell wrapper and breaks sqlite-vec
- The qmd file is a shell script that runs compiled JS from dist/ - do not replace it
- npm run build compiles TypeScript to dist/ via tsc -p tsconfig.build.json

Releasing

Use /release <version> to cut a release. Full changelog standards,
release workflow, and git hook setup are documented in the
release skill.

Key points:
- Add changelog entries under ## [Unreleased] as you make changes
- The release script renames [Unreleased] β†’ [X.Y.Z] - date at release time
- Credit external PRs with #NNN (thanks @username)
- GitHub releases roll up the full minor series (e.g. 1.2.0 through 1.2.3)


README.md

QMD - Query Markup Documents

An on-device search engine for everything you need to remember. Index your markdown notes, meeting transcripts, documentation, and knowledge bases. Search with keywords or natural language. Ideal for your agentic flows.

QMD combines BM25 full-text search, vector semantic search, and LLM re-rankingβ€”all running locally via node-llama-cpp with GGUF models.

!QMD Architecture

You can read more about QMD's progress in the CHANGELOG.

Quick Start

sh

Install globally (Node or Bun)


npm install -g @tobilu/qmd

or


bun install -g @tobilu/qmd

Or run directly


npx @tobilu/qmd ...
bunx @tobilu/qmd ...

Create collections for your notes, docs, and meeting transcripts


qmd collection add ~/notes --name notes
qmd collection add ~/Documents/meetings --name meetings
qmd collection add ~/work/docs --name docs

Add context to help with search results, each piece of context will be returned when matching sub documents are returned. This works as a tree. This is the key feature of QMD as it allows LLMs to make much better contextual choices when selecting documents. Don't sleep on it!


qmd context add qmd://notes "Personal notes and ideas"
qmd context add qmd://meetings "Meeting transcripts and notes"
qmd context add qmd://docs "Work documentation"

Generate embeddings for semantic search


qmd embed

Search across everything


qmd search "project timeline" # Fast keyword search
qmd vsearch "how to deploy" # Semantic search
qmd query "quarterly planning process" # Hybrid + reranking (best quality)

Get a specific document


qmd get "meetings/2024-01-15.md"

Get a document by docid (shown in search results)


qmd get "#abc123"

Get multiple documents by glob pattern


qmd multi-get "journals/2025-05*.md"

Search within a specific collection


qmd search "API" -c notes

Export all matches for an agent


qmd search "API" --all --files --min-score 0.3

Using with AI Agents

QMD's --json and --files output formats are designed for agentic workflows:

sh

Get structured results for an LLM


qmd search "authentication" --json -n 10

List all relevant files above a threshold


qmd query "error handling" --all --files --min-score 0.4

Retrieve full document content


qmd get "docs/api-reference.md" --full

MCP Server

Although the tool works perfectly fine when you just tell your agent to use it on the command line, it also exposes an MCP (Model Context Protocol) server for tighter integration.

Tools exposed:
- query β€” Search with typed sub-queries (lex/vec/hyde), combined via RRF + reranking
- get β€” Retrieve a document by path or docid (with fuzzy matching suggestions)
- multi_get β€” Batch retrieve by glob pattern, comma-separated list, or docids
- status β€” Index health and collection info

Claude Desktop configuration (~/Library/Application Support/Claude/claude_desktop_config.json):

json
{
"mcpServers": {
"qmd": {
"command": "qmd",
"args": ["mcp"]
}
}
}

Claude Code β€” Install the plugin (recommended):

bash
claude plugin marketplace add tobi/qmd
claude plugin install qmd@qmd

Or configure MCP manually in ~/.claude/settings.json:

json
{
"mcpServers": {
"qmd": {
"command": "qmd",
"args": ["mcp"]
}
}
}

#### HTTP Transport

By default, QMD's MCP server uses stdio (launched as a subprocess by each client). For a shared, long-lived server that avoids repeated model loading, use the HTTP transport:

sh

Foreground (Ctrl-C to stop)


qmd mcp --http # localhost:8181
qmd mcp --http --port 8080 # custom port

Background daemon


qmd mcp --http --daemon # start, writes PID to ~/.cache/qmd/mcp.pid
qmd mcp stop # stop via PID file
qmd status # shows "MCP: running (PID ...)" when active

The HTTP server exposes two endpoints:
- POST /mcp β€” MCP Streamable HTTP (JSON responses, stateless)
- GET /health β€” liveness check with uptime

LLM models stay loaded in VRAM across requests. Embedding/reranking contexts are disposed after 5 min idle and transparently recreated on the next request (~1s penalty, models remain loaded).

Point any MCP client at http://localhost:8181/mcp to connect.

SDK / Library Usage

Use QMD as a library in your own Node.js or Bun applications.

#### Installation

sh
npm install @tobilu/qmd

#### Quick Start

typescript
import { createStore } from '@tobilu/qmd'

const store = await createStore({
dbPath: './my-index.sqlite',
config: {
collections: {
docs: { path: '/path/to/docs', pattern: '/*.md' },
},
},
})

const results = await store.search({ query: "authentication flow" })
console.log(results.map(r => ${r.title} (${Math.round(r.score * 100)}%)))

await store.close()

#### Store Creation

createStore() accepts three modes:

typescript
import { createStore } from '@tobilu/qmd'

// 1. Inline config β€” no files needed besides the DB
const store = await createStore({
dbPath: './index.sqlite',
config: {
collections: {
docs: { path: '/path/to/docs', pattern: '/*.md' },
notes: { path: '/path/to/notes' },
},
},
})

// 2. YAML config file β€” collections defined in a file
const store2 = await createStore({
dbPath: './index.sqlite',
configPath: './qmd.yml',
})

// 3. DB-only β€” reopen a previously configured store
const store3 = await createStore({ dbPath: './index.sqlite' })

#### Search

The unified search() method handles both simple queries and pre-expanded structured queries:

typescript
// Simple query β€” auto-expanded via LLM, then BM25 + vector + reranking
const results = await store.search({ query: "authentication flow" })

// With options
const results2 = await store.search({
query: "rate limiting",
intent: "API throttling and abuse prevention",
collection: "docs",
limit: 5,
minScore: 0.3,
explain: true,
})

// Pre-expanded queries β€” skip auto-expansion, control each sub-query
const results3 = await store.search({
queries: [
{ type: 'lex', query: '"connection pool" timeout -redis' },
{ type: 'vec', query: 'why do database connections time out under load' },
],
collections: ["docs", "notes"],
})

// Skip reranking for faster results
const fast = await store.search({ query: "auth", rerank: false })

For direct backend access:

typescript
// BM25 keyword search (fast, no LLM)
const lexResults = await store.searchLex("auth middleware", { limit: 10 })

// Vector similarity search (embedding model, no reranking)
const vecResults = await store.searchVector("how users log in", { limit: 10 })

// Manual query expansion for full control
const expanded = await store.expandQuery("auth flow", { intent: "user login" })
const results4 = await store.search({ queries: expanded })

#### Retrieval

typescript
// Get a document by path or docid
const doc = await store.get("docs/readme.md")
const byId = await store.get("#abc123")

if (!("error" in doc)) {
console.log(doc.title, doc.displayPath, doc.context)
}

// Get document body with line range
const body = await store.getDocumentBody("docs/readme.md", {
fromLine: 50,
maxLines: 100,
})

// Batch retrieve by glob or comma-separated list
const { docs, errors } = await store.multiGet("docs//*.md", {
maxBytes: 20480,
})

#### Collections

typescript
// Add a collection
await store.addCollection("myapp", {
path: "/src/myapp",
pattern: "/*.ts",
ignore: ["node_modules/", "*.test.ts"],
})

// List collections with document stats
const collections = await store.listCollections()
// => [{ name, pwd, glob_pattern, doc_count, active_count, last_modified, includeByDefault }]

// Get names of collections included in queries by default
const defaults = await store.getDefaultCollectionNames()

// Remove / rename
await store.removeCollection("myapp")
await store.renameCollection("old-name", "new-name")

#### Context

Context adds descriptive metadata that improves search relevance and is returned alongside results:

typescript
// Add context for a path within a collection
await store.addContext("docs", "/api", "REST API reference documentation")

// Set global context (applies to all collections)
await store.setGlobalContext("Internal engineering documentation")

// List all contexts
const contexts = await store.listContexts()
// => [{ collection, path, context }]

// Remove context
await store.removeContext("docs", "/api")
await store.setGlobalContext(undefined) // clear global

#### Indexing

typescript
// Re-index collections by scanning the filesystem
const result = await store.update({
collections: ["docs"], // optional β€” defaults to all
onProgress: ({ collection, file, current, total }) => {
console.log([${collection}] ${current}/${total} ${file})
},
})
// => { collections, indexed, updated, unchanged, removed, needsEmbedding }

// Generate vector embeddings
const embedResult = await store.embed({
force: false, // true to re-embed everything
chunkStrategy: "auto", // "regex" (default) or "auto" (AST for code files)
onProgress: ({ current, total, collection }) => {
console.log(Embedding ${current}/${total})
},
})

#### Types

Key types exported for SDK consumers:

typescript
import type {
QMDStore, // The store interface
SearchOptions, // Options for search()
LexSearchOptions, // Options for searchLex()
VectorSearchOptions, // Options for searchVector()
HybridQueryResult, // Search result with score, snippet, context
SearchResult, // Result from searchLex/searchVector
ExpandedQuery, // Typed sub-query { type: 'lex'|'vec'|'hyde', query }
DocumentResult, // Document metadata + body
DocumentNotFound, // Error with similarFiles suggestions
MultiGetResult, // Batch retrieval result
UpdateProgress, // Progress callback info for update()
UpdateResult, // Aggregated update result
EmbedProgress, // Progress callback info for embed()
EmbedResult, // Embedding result
StoreOptions, // createStore() options
CollectionConfig, // Inline config shape
IndexStatus, // From getStatus()
IndexHealthInfo, // From getIndexHealth()
} from '@tobilu/qmd'

Utility exports:

typescript
import {
extractSnippet, // Extract a relevant snippet from text
addLineNumbers, // Add line numbers to text
DEFAULT_MULTI_GET_MAX_BYTES, // Default max file size for multiGet (10KB)
Maintenance, // Database maintenance operations
} from '@tobilu/qmd'

#### Lifecycle

typescript
// Close the store β€” disposes LLM models and DB connection
await store.close()

The SDK requires explicit dbPath β€” no defaults are assumed. This makes it safe to embed in any application without side effects.

Architecture

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ QMD Hybrid Search Pipeline β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ User Query β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Query Expansionβ”‚ β”‚ Original Queryβ”‚
β”‚ (fine-tuned) β”‚ β”‚ (Γ—2 weight) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β”‚ 2 alternative queries β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Original Query β”‚ β”‚ Expanded Query 1β”‚ β”‚ Expanded Query 2β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”
β”‚ BM25 β”‚ β”‚Vector β”‚ β”‚ BM25 β”‚ β”‚Vector β”‚ β”‚ BM25 β”‚ β”‚Vector β”‚
β”‚(FTS5) β”‚ β”‚Search β”‚ β”‚(FTS5) β”‚ β”‚Search β”‚ β”‚(FTS5) β”‚ β”‚Search β”‚
β””β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”˜
β”‚ β”‚ β”‚ β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ RRF Fusion + Bonus β”‚
β”‚ Original query: Γ—2 β”‚
β”‚ Top-rank bonus: +0.05β”‚
β”‚ Top 30 Kept β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ LLM Re-ranking β”‚
β”‚ (qwen3-reranker) β”‚
β”‚ Yes/No + logprobs β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Position-Aware Blend β”‚
β”‚ Top 1-3: 75% RRF β”‚
β”‚ Top 4-10: 60% RRF β”‚
β”‚ Top 11+: 40% RRF β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Score Normalization & Fusion

Search Backends

| Backend | Raw Score | Conversion | Range |
|---------|-----------|------------|-------|
| FTS (BM25) | SQLite FTS5 BM25 | Math.abs(score) | 0 to ~25+ |
| Vector | Cosine distance | 1 / (1 + distance) | 0.0 to 1.0 |
| Reranker | LLM 0-10 rating | score / 10 | 0.0 to 1.0 |

Fusion Strategy

The query command uses Reciprocal Rank Fusion (RRF) with position-aware blending:

1. Query Expansion: Original query (Γ—2 for weighting) + 1 LLM variation
2. Parallel Retrieval: Each query searches both FTS and vector indexes
3. RRF Fusion: Combine all result lists using score = Ξ£(1/(k+rank+1)) where k=60
4. Top-Rank Bonus: Documents ranking #1 in any list get +0.05, #2-3 get +0.02
5. Top-K Selection: Take top 30 candidates for reranking
6. Re-ranking: LLM scores each document (yes/no with logprobs confidence)
7. Position-Aware Blending:
- RRF rank 1-3: 75% retrieval, 25% reranker (preserves exact matches)
- RRF rank 4-10: 60% retrieval, 40% reranker
- RRF rank 11+: 40% retrieval, 60% reranker (trust reranker more)

Why this approach: Pure RRF can dilute exact matches when expanded queries don't match. The top-rank bonus preserves documents that score #1 for the original query. Position-aware blending prevents the reranker from destroying high-confidence retrieval results.

Score Interpretation

| Score | Meaning |
|-------|---------|
| 0.8 - 1.0 | Highly relevant |
| 0.5 - 0.8 | Moderately relevant |
| 0.2 - 0.5 | Somewhat relevant |
| 0.0 - 0.2 | Low relevance |

Requirements

System Requirements

- Node.js >= 22
- Bun >= 1.0.0
- macOS: Homebrew SQLite (for extension support)

sh
brew install sqlite

GGUF Models (via node-llama-cpp)

QMD uses three local GGUF models (auto-downloaded on first use):

| Model | Purpose | Size |
|-------|---------|------|
| embeddinggemma-300M-Q8_0 | Vector embeddings (default) | ~300MB |
| qwen3-reranker-0.6b-q8_0 | Re-ranking | ~640MB |
| qmd-query-expansion-1.7B-q4_k_m | Query expansion (fine-tuned) | ~1.1GB |

Models are downloaded from HuggingFace and cached in ~/.cache/qmd/models/.

Custom Embedding Model

Override the default embedding model via the QMD_EMBED_MODEL environment variable.
This is useful for multilingual corpora (e.g. Chinese, Japanese, Korean) where
embeddinggemma-300M has limited coverage.

sh

Use Qwen3-Embedding-0.6B for better multilingual (CJK) support


export QMD_EMBED_MODEL="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"

After changing the model, re-embed all collections:


qmd embed -f

Supported model families:
- embeddinggemma (default) β€” English-optimized, small footprint
- Qwen3-Embedding β€” Multilingual (119 languages including CJK), MTEB top-ranked

Note: When switching embedding models, you must re-index with qmd embed -f

since vectors are not cross-compatible between models. The prompt format is

automatically adjusted for each model family.

Installation

sh
npm install -g @tobilu/qmd

or


bun install -g @tobilu/qmd

Development

sh
git clone https://github.com/tobi/qmd
cd qmd
npm install
npm link

Usage

Collection Management

sh

Create a collection from current directory


qmd collection add . --name myproject

Create a collection with explicit path and custom glob mask


qmd collection add ~/Documents/notes --name notes --mask "/*.md"

List all collections


qmd collection list

Remove a collection


qmd collection remove myproject

Rename a collection


qmd collection rename myproject my-project

List files in a collection


qmd ls notes
qmd ls notes/subfolder

Generate Vector Embeddings

sh

Embed all indexed documents (900 tokens/chunk, 15% overlap)


qmd embed

Force re-embed everything


qmd embed -f

Enable AST-aware chunking for code files (TS, JS, Python, Go, Rust)


qmd embed --chunk-strategy auto

Also works with query for consistent chunk selection


qmd query "auth flow" --chunk-strategy auto

AST-aware chunking (--chunk-strategy auto) uses tree-sitter to chunk code
files at function, class, and import boundaries instead of arbitrary text
positions. This produces higher-quality chunks and better search results for
codebases. Markdown and other file types always use regex-based chunking
regardless of strategy.

The default is regex (existing behavior). Use --chunk-strategy auto to
opt in. Run qmd status to verify which grammars are available.

Note: Tree-sitter grammars are optional dependencies. If they are not

installed, --chunk-strategy auto falls back to regex-only chunking

automatically. Tested on both Node.js and Bun.

Context Management

Context adds descriptive metadata to collections and paths, helping search understand your content.

sh

Add context to a collection (using qmd:// virtual paths)


qmd context add qmd://notes "Personal notes and ideas"
qmd context add qmd://docs/api "API documentation"

Add context from within a collection directory


cd ~/notes && qmd context add "Personal notes and ideas"
cd ~/notes/work && qmd context add "Work-related notes"

Add global context (applies to all collections)


qmd context add / "Knowledge base for my projects"

List all contexts


qmd context list

Remove context


qmd context rm qmd://notes/old

Search Commands

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Search Modes β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ search β”‚ BM25 full-text search only β”‚
β”‚ vsearch β”‚ Vector semantic search only β”‚
β”‚ query β”‚ Hybrid: FTS + Vector + Query Expansion + Re-ranking β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

sh

Full-text search (fast, keyword-based)


qmd search "authentication flow"

Vector search (semantic similarity)


qmd vsearch "how to login"

Hybrid search with re-ranking (best quality)


qmd query "user authentication"

Options

sh

Search options


-n <num> # Number of results (default: 5, or 20 for --files/--json)
-c, --collection # Restrict search to a specific collection
--all # Return all matches (use with --min-score to filter)
--min-score <num> # Minimum score threshold (default: 0)
--full # Show full document content
--line-numbers # Add line numbers to output
--explain # Include retrieval score traces (query, JSON/CLI output)
--index <name> # Use named index

Output formats (for search and multi-get)


--files # Output: docid,score,filepath,context
--json # JSON output with snippets
--csv # CSV output
--md # Markdown output
--xml # XML output

Get options


qmd get <file>[:line] # Get document, optionally starting at line
-l <num> # Maximum lines to return
--from <num> # Start from line number

Multi-get options


-l <num> # Maximum lines per file
--max-bytes <num> # Skip files larger than N bytes (default: 10KB)

Output Format

Default output is colorized CLI format (respects NO_COLOR env).

When stdout is a TTY, result paths are emitted as clickable terminal hyperlinks (OSC 8). Clicking a path opens the file in your editor using an editor URI template.

When stdout is not a TTY (for example piped to another command or redirected to a file), QMD emits plain text paths with no escape sequences.

TTY example:

text
docs/guide.md:42 #a1b2c3
Title: Software Craftsmanship
Context: Work documentation
Score: 93%

This section covers the craftsmanship of building
quality software with attention to detail.
See also: engineering principles


notes/meeting.md:15 #d4e5f6
Title: Q4 Planning
Context: Personal notes and ideas
Score: 67%

Discussion about code quality and craftsmanship
in the development process.

Configure the editor link target with QMD_EDITOR_URI (or editor_uri in config):

sh

VS Code (default)


export QMD_EDITOR_URI="vscode://file/{path}:{line}:{col}"

Cursor


export QMD_EDITOR_URI="cursor://file/{path}:{line}:{col}"

Zed


export QMD_EDITOR_URI="zed://file/{path}:{line}:{col}"

Sublime Text


export QMD_EDITOR_URI="subl://open?url=file://{path}&line={line}"

Template placeholders:
- {path} absolute filesystem path (URI-encoded)
- {line} 1-based line number
- {col} or {column} 1-based column number

- Path: Collection-relative path (e.g., docs/guide.md)
- Docid: Short hash identifier (e.g., #a1b2c3) - use with qmd get #a1b2c3
- Title: Extracted from document (first heading or filename)
- Context: Path context if configured via qmd context add
- Score: Color-coded (green >70%, yellow >40%, dim otherwise)
- Snippet: Context around match with query terms highlighted

Examples

sh

Get 10 results with minimum score 0.3


qmd query -n 10 --min-score 0.3 "API design patterns"

Output as markdown for LLM context


qmd search --md --full "error handling"

JSON output for scripting


qmd query --json "quarterly reports"

Inspect how each result was scored (RRF + rerank blend)


qmd query --json --explain "quarterly reports"

Use separate index for different knowledge base


qmd --index work search "quarterly reports"

Index Maintenance

sh

Show index status and collections with contexts


qmd status

Re-index all collections


qmd update

Re-index with git pull first (for remote repos)


qmd update --pull

Get document by filepath (with fuzzy matching suggestions)


qmd get notes/meeting.md

Get document by docid (from search results)


qmd get "#abc123"

Get document starting at line 50, max 100 lines


qmd get notes/meeting.md:50 -l 100

Get multiple documents by glob pattern


qmd multi-get "journals/2025-05*.md"

Get multiple documents by comma-separated list (supports docids)


qmd multi-get "doc1.md, doc2.md, #abc123"

Limit multi-get to files under 20KB


qmd multi-get "docs/*.md" --max-bytes 20480

Output multi-get as JSON for agent processing


qmd multi-get "docs/*.md" --json

Clean up cache and orphaned data


qmd cleanup

Data Storage

Index stored in: ~/.cache/qmd/index.sqlite

Schema

sql
collections     -- Indexed directories with name and glob patterns
path_contexts -- Context descriptions by virtual path (qmd://...)
documents -- Markdown content with metadata and docid (6-char hash)
documents_fts -- FTS5 full-text index
content_vectors -- Embedding chunks (hash, seq, pos, 900 tokens each)
vectors_vec -- sqlite-vec vector index (hash_seq key)
llm_cache -- Cached LLM responses (query expansion, rerank scores)

Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| XDG_CACHE_HOME | ~/.cache | Cache directory location |

How It Works

Indexing Flow

text
Collection ──► Glob Pattern ──► Markdown Files ──► Parse Title ──► Hash Content
β”‚ β”‚ β”‚
β”‚ β”‚ β–Ό
β”‚ β”‚ Generate docid
β”‚ β”‚ (6-char hash)
β”‚ β”‚ β”‚
└──────────────────────────────────────────────────►└──► Store in SQLite
β”‚
β–Ό
FTS5 Index

Embedding Flow

Documents are chunked into ~900-token pieces with 15% overlap using smart boundary detection:

text
Document ──► Smart Chunk (~900 tokens) ──► Format each chunk ──► node-llama-cpp ──► Store Vectors
β”‚ "title | text" embedBatch()
β”‚
└─► Chunks stored with:
- hash: document hash
- seq: chunk sequence (0, 1, 2...)
- pos: character position in original

Smart Chunking

Instead of cutting at hard token boundaries, QMD uses a scoring algorithm to find natural markdown break points. This keeps semantic units (sections, paragraphs, code blocks) together.

Break Point Scores:

| Pattern | Score | Description |
|---------|-------|-------------|
| # Heading | 100 | H1 - major section |
| ## Heading | 90 | H2 - subsection |
| ### Heading | 80 | H3 |
| #### Heading | 70 | H4 |
| ##### Heading | 60 | H5 |
| ###### Heading | 50 | H6 |
| ` | 80 | Code block boundary |
| --- / * | 60 | Horizontal rule |
| Blank line | 20 | Paragraph boundary |
| - item / 1. item | 5 | List item |
| Line break | 1 | Minimal break |

Algorithm:

1. Scan document for all break points with scores
2. When approaching the 900-token target, search a 200-token window before the cutoff
3. Score each break point: finalScore = baseScore Γ— (1 - (distance/window)Β² Γ— 0.7)
4. Cut at the highest-scoring break point

The squared distance decay means a heading 200 tokens back (score ~30) still beats a simple line break at the target (score 1), but a closer heading wins over a distant one.

Code Fence Protection: Break points inside code blocks are ignoredβ€”code stays together. If a code block exceeds the chunk size, it's kept whole when possible.

AST-Aware Chunking (Code Files):

For supported code files, QMD also parses the source with tree-sitter and adds AST-derived break points that are merged with the regex scores above:

| AST Node | Score | Languages |
|----------|-------|-----------|
| Class / interface / struct / impl / trait | 100 | All |
| Function / method | 90 | All |
| Type alias / enum | 80 | All |
| Import / use declaration | 60 | All |

Supported for .ts, .tsx, .js, .jsx, .py, .go, and .rs files. Enable with --chunk-strategy auto. Markdown and other file types always use regex chunking.

Query Flow (Hybrid)

text
Query ──► LLM Expansion ──► [Original, Variant 1, Variant 2]
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό
For each query: FTS (BM25)
β”‚ β”‚
β–Ό β–Ό
Vector Search Ranked List
β”‚
β–Ό
Ranked List
β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
RRF Fusion (k=60)
Original query Γ—2 weight
Top-rank bonus: +0.05/#1, +0.02/#2-3
β”‚
β–Ό
Top 30 candidates
β”‚
β–Ό
LLM Re-ranking
(yes/no + logprob confidence)
β”‚
β–Ό
Position-Aware Blend
Rank 1-3: 75% RRF / 25% reranker
Rank 4-10: 60% RRF / 40% reranker
Rank 11+: 40% RRF / 60% reranker
β”‚
β–Ό
Final Results

Model Configuration

Models are configured in src/llm.ts as HuggingFace URIs:

typescript
const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";

EmbeddingGemma Prompt Format

text
// For queries
"task: search result | query: {query}"

// For documents
"title: {title} | text: {content}"

Qwen3-Reranker

Uses node-llama-cpp's createRankingContext() and rankAndSort() API for cross-encoder reranking. Returns documents sorted by relevance score (0.0 - 1.0).

Qwen3 (Query Expansion)

Used for generating query variations via LlamaChatSession.

License

MIT