Skip to content

joycodetech/qmd-ja

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

581 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🇯🇵 qmd-ja — Japanese-enhanced fork of qmd

qmd-ja is a fork of tobi/qmd (MIT) with accurate Japanese morphological analysis for BM25 full-text search.

What's different from upstream

tobi/qmd (upstream) joycodetech/qmd-ja
CJK tokenizer Unigram (character-level) Vaporetto WASM (morphological)
in CJK_RUN_PATTERN Missing (bug) Fixed ✅
Default embed model embeddinggemma-300M (English) mochiya98/ruri-v3-310m-onnx (Japanese)
Default generate model qmd-query-expansion-1.7B (English) adsholoko/qmd-query-expansion-ja (Japanese)
Default rerank model Qwen3-Reranker-0.6B (multilingual) hotchpotch/japanese-reranker-xsmall-v2 (Japanese)

Installation

npm install -g @joycodetech/qmd-ja

Based on tobi/qmd (MIT). Use qmd-ja as the CLI command and @joycodetech/qmd-ja as the package name.


qmd-ja — On-Device Search Engine

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 agentic workflows.

qmd-ja combines BM25 full-text search, vector semantic search, and LLM re-ranking — with Japanese morphological tokenization and Japanese-optimized models replacing the upstream defaults.

QMD Architecture

qmd-ja note: The architecture diagram reflects the upstream pipeline. In qmd-ja, the BM25 tokenizer is replaced with Vaporetto WASM morphological analysis, and the default models are replaced with Japanese-optimized alternatives. See Japanese Optimization for details.

You can read more about changes in the CHANGELOG.

Japanese Optimization

qmd-ja replaces two components of the upstream pipeline for Japanese-heavy corpora.

Tokenizer — Vaporetto WASM

The BM25 full-text search engine uses Vaporetto WASM morphological analysis instead of character-level unigram tokenization. Compound words remain intact as single tokens:

upstream qmd-ja
ナレッジベース ナレッジベ ー ス ナレッジベース
プロンプトコンパイラ 17 tokens ❌ 1 token ✅
Startup overhead 0 ms 16 ms (one-time)
Throughput 3 µs/query 5 µs/query

Recommended Models for Japanese Corpora

Place the following in your index.yml (or .qmd/index.yml for project-local indexes):

models:
  embed: onnxe:mochiya98/ruri-v3-310m-onnx/q8
  generate: hf:adsholoko/qmd-query-expansion-ja/Qwen3-1.7B.Q4_K_M.gguf
  rerank: onnx:hotchpotch/japanese-reranker-xsmall-v2/model_qint8_avx2
Role Model Notes
embed mochiya98/ruri-v3-310m-onnx Japanese ONNX embedding model
generate adsholoko/qmd-query-expansion-ja Japanese query expansion, fine-tuned by adsholoko
rerank hotchpotch/japanese-reranker-xsmall-v2 Japanese ONNX reranker

After changing models:

qmd-ja pull      # download GGUF models
qmd-ja doctor    # verify setup
qmd-ja embed -f  # re-embed with the new embedding model

The built-in defaults target English corpora. For Japanese-only or Japanese-heavy corpora, the configuration above produces significantly better results.


Quick Start

# Install globally
npm install -g @joycodetech/qmd-ja

# Or run directly
npx @joycodetech/qmd-ja ...

# Create collections for your notes, docs, and meeting transcripts
qmd-ja collection add ~/notes --name notes
qmd-ja collection add ~/Documents/meetings --name meetings
qmd-ja 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-ja context add qmd://notes "Personal notes and ideas"
qmd-ja context add qmd://meetings "Meeting transcripts and notes"
qmd-ja context add qmd://docs "Work documentation"

# Generate embeddings for semantic search
qmd-ja embed

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

# Get a specific document
qmd-ja get "meetings/2024-01-15.md"

# Get a document by docid (shown in search results)
qmd-ja get "#abc123"

# Get multiple documents by glob pattern
qmd-ja multi-get "journals/2025-05*.md"

# Search within a specific collection
qmd-ja search "API" -c notes

# Export all matches for an agent
qmd-ja search "API" --all --files --min-score 0.3

Using with AI Agents

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

# Get structured results for an LLM
qmd-ja search "authentication" --json -n 10

# List all relevant files above a threshold
qmd-ja query "error handling" --all --files --min-score 0.4

# Retrieve full document content
qmd-ja 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):

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

Claude Code — configure MCP manually in ~/.claude/settings.json:

{
  "mcpServers": {
    "qmd": {
      "command": "qmd-ja",
      "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:

# Foreground (Ctrl-C to stop)
qmd-ja mcp --http                    # localhost:8181
qmd-ja mcp --http --port 8080        # custom port
qmd-ja mcp --http --host 0.0.0.0     # bind all interfaces (e.g. container probes)

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

The server binds to localhost by default. Pass --host (or set the QMD_HOST environment variable) to override — --host 0.0.0.0 is useful when the server runs in a container and a liveness probe connects from a non-loopback address.

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.

MCP Tool Parameters

Tool Parameter Type Notes
query searches array Typed sub-queries (lex/vec/hyde), 1–10. Required. First gets 2x weight.
query collections string[] Filter by collection names (OR). Array only — singular collection is silently ignored.
query intent string Disambiguation context (does not search on its own)
query limit number Max results (default 10)
query minScore number Minimum relevance 0–1 (default 0)
query candidateLimit number Max candidates to rerank (default 40)
query rerank boolean Run LLM reranking (default true); set false for RRF-only
get file string Path, docid (#abc123), or path:from:count (e.g. #abc123:120:40)
get fromLine number Start line (1-indexed); overrides the :from suffix
get maxLines number Limit returned lines
get lineNumbers boolean Prefix lines with numbers (default true)
multi_get pattern string Glob pattern or comma-separated list
multi_get maxBytes number Skip files larger than N (default 10240)
multi_get maxLines number Limit lines per file
multi_get lineNumbers boolean Prefix lines with numbers (default true)

Unknown parameters are silently ignored (not rejected) — double-check names if results seem unscoped. The HTTP /query and /search endpoints return qmd://collection/path URIs in the file field, matching the CLI and MCP output.

SDK / Library Usage

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

Installation

npm install @joycodetech/qmd-ja

Quick Start

import { createStore } from '@joycodetech/qmd-ja'

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:

import { createStore } from '@joycodetech/qmd-ja'

// 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:

// 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:

// 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

// 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

// 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:

// 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

// 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:

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 '@joycodetech/qmd-ja'

Utility exports:

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 (64KB)
  Maintenance,                 // Database maintenance operations
} from '@joycodetech/qmd-ja'

Lifecycle

// 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

┌─────────────────────────────────────────────────────────────────────────────┐
│                         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)
    brew install sqlite

Models: GGUF and ONNX

qmd-ja uses local models for embedding, reranking, and query expansion:

  • Embedding: GGUF via node-llama-cpp or ONNX via @huggingface/transformers
  • Reranking: GGUF via node-llama-cpp or ONNX via @huggingface/transformers
  • Query expansion / generation: GGUF via node-llama-cpp

The default configuration uses GGUF models:

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

GGUF models are downloaded from Hugging Face and cached in ~/.cache/qmd/models/. ONNX embedding/rerank models are resolved by @huggingface/transformers and use the Transformers cache. ONNX generation is not currently supported.

Model URI formats:

  • GGUF: hf:<org>/<repo>/<file.gguf> or a local .gguf path
  • ONNX embedding: onnxe:<model-id> or onnxe:<model-id>/<dtype>
  • ONNX rerank: onnx:<model-id>/<model-file-name>

The ONNX branching for embedding and rerank is implemented in the Provider layer, so search/indexing code does not need model-specific onnx checks.

Japanese ONNX Model Configuration

For Japanese-heavy corpora, this configuration uses ONNX for embedding and reranking while keeping the GGUF query-expansion model:

models:
  embed: onnxe:mochiya98/ruri-v3-310m-onnx/q8
  generate: hf:adsholoko/qmd-query-expansion-ja/Qwen3-1.7B.Q4_K_M.gguf
  rerank: onnx:hotchpotch/japanese-reranker-xsmall-v2/model_qint8_avx2

You can also set the models with environment variables:

export QMD_EMBED_MODEL="onnxe:mochiya98/ruri-v3-310m-onnx/q8"
export QMD_RERANK_MODEL="onnx:hotchpotch/japanese-reranker-xsmall-v2/model_qint8_avx2"

After changing models:

qmd-ja pull
qmd-ja doctor
qmd-ja embed -f

qmd-ja pull downloads/checks GGUF models. ONNX embedding/rerank models are handled by the Transformers cache and are first resolved when used. qmd-ja doctor treats both cache styles as valid, and qmd-ja embed -f will run the ONNX embedding provider for onnxe: 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.

# Use Qwen3-Embedding-0.6B GGUF 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-ja 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-ja embed -f since vectors are not cross-compatible between models. The prompt format is automatically adjusted for each model family.

Installation

npm install -g @joycodetech/qmd-ja

Development

git clone https://github.com/joycodetech/qmd-ja
cd qmd-ja
npm install
npm link

Usage

Collection Management

# Create a collection from current directory
qmd-ja collection add . --name myproject

# Create a collection with explicit path and custom glob mask
qmd-ja collection add ~/Documents/notes --name notes --mask "**/*.md"

# List all collections
qmd-ja collection list

# Remove a collection
qmd-ja collection remove myproject

# Rename a collection
qmd-ja collection rename myproject my-project

# List files in a collection
qmd-ja ls notes
qmd-ja ls notes/subfolder

# Show collection details (path, glob mask, include status, context count)
qmd-ja collection show notes

# Include or exclude a collection from default (unscoped) queries
qmd-ja collection include notes
qmd-ja collection exclude notes

# Run a command before every `qmd-ja update` (e.g. git pull); empty arg clears it
qmd-ja collection update-cmd notes 'git pull --rebase'
qmd-ja collection update-cmd notes

Generate Vector Embeddings

# Embed all indexed documents (900 tokens/chunk, 15% overlap)
qmd-ja embed

# Force re-embed everything
qmd-ja embed -f

# Enable AST-aware chunking for code files (TS, JS, Python, Go, Rust)
qmd-ja embed --chunk-strategy auto

# Also works with query for consistent chunk selection
qmd-ja query "auth flow" --chunk-strategy auto

# Memory control for large corpora / constrained systems
qmd-ja embed --max-docs-per-batch 50   # cap docs per embedding batch
qmd-ja embed --max-batch-mb 64         # cap batch size in MB

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-ja 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.

# Add context to a collection (using qmd:// virtual paths)
qmd-ja context add qmd://notes "Personal notes and ideas"
qmd-ja context add qmd://docs/api "API documentation"

# Add context from within a collection directory
cd ~/notes && qmd-ja context add "Personal notes and ideas"
cd ~/notes/work && qmd-ja context add "Work-related notes"

# Add global context (applies to all collections)
qmd-ja context add / "Knowledge base for my projects"

# List all contexts
qmd-ja context list

# Remove context
qmd-ja context rm qmd://notes/old

Configuring index.yml

The collection and context commands above all read and write a single YAML config file — you can also edit it directly. Everything QMD knows about your collections (paths, masks, exclusions, per-collection update hooks, contexts, and optional model overrides) lives here. A fully-commented starter template ships as example-index.yml in this repo.

Location: ~/.config/qmd/index.yml by default. The directory honors XDG_CONFIG_HOME (→ $XDG_CONFIG_HOME/qmd/index.yml) and QMD_CONFIG_DIR. A named index uses {name}.ymlqmd --index work … reads/writes work.yml. A project-local index created with qmd init lives at .qmd/index.yml (.qmd/index.yaml is also accepted) alongside a project-local index.sqlite, so config and index stay inside the project instead of ~/.config / ~/.cache.

# ~/.config/qmd/index.yml

# Context applied to every collection (system-message style). Optional.
global_context: "Knowledge base for my projects"

# Terminal hyperlink template for search results. Optional.
# Overridden by the QMD_EDITOR_URI env var. See "Editor Links" below.
editor_uri: "vscode://file{path}:{line}:{col}"

# Override the default GGUF models per role. Optional — omit to use the
# built-in defaults. `qmd init` writes this block pre-filled with the
# resolved defaults. See "Model Configuration" for the default URIs.
models:
  embed: "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"
  rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"
  generate: "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf"

# One entry per collection. The key is the collection name.
collections:
  notes:
    path: /Users/me/notes        # absolute path to index (required)
    pattern: "**/*.md"           # glob mask (default: **/*.md)
    ignore:                      # glob patterns to exclude from indexing
      - "Archive/**"
      - "**/drafts/**"
    update: "git pull --rebase"  # bash command run before each `qmd update`
    includeByDefault: true       # include in unscoped queries (default: true)
    context:                     # path prefix → description; longest match wins
      "/": "Personal notes and ideas"
      "/work": "Work-related notes"
Key Scope Purpose
global_context top-level Context prepended for every collection. Set via qmd context add /.
editor_uri (alias editor_uri_template) top-level Hyperlink template for clickable result paths; QMD_EDITOR_URI overrides.
models.embed / .rerank / .generate top-level HuggingFace GGUF URIs (hf:<user>/<repo>/<file>) overriding the built-in defaults per role.
collections.<name>.path per-collection Absolute directory to index.
collections.<name>.pattern per-collection Glob mask. Set via qmd collection add --mask. Default **/*.md.
collections.<name>.ignore per-collection Glob patterns excluded from indexing — useful to stop nested collections double-indexing. YAML-only — no CLI command sets this. Additive with QMD's built-in exclusions (node_modules, .git, .cache, vendor, dist, build), which you cannot un-ignore.
collections.<name>.update per-collection Bash command run before qmd update re-indexes this collection. Set via qmd collection update-cmd.
collections.<name>.includeByDefault per-collection Whether unscoped queries search it. Toggle with qmd collection include/exclude. Default true.
collections.<name>.context per-collection Path-prefix → description map; the most specific (longest) matching prefix wins. Set via qmd context add.

Note: Editing index.yml changes which directories and models QMD uses, but does not re-index on its own. Run qmd update after changing path, pattern, or ignore, and qmd embed after changing models.embed.

Automatic update commands

A collection's update field is QMD's built-in refresh hook: when you run qmd update, each collection's update command runs first, then the collection is re-indexed. This keeps a collection in sync with an upstream source (a git remote, a sync script) without wrapping qmd yourself.

collections:
  wiki:
    path: ~/reference/wiki
    update: "git pull --ff-only"
$ qmd update
[1/3] wiki (**/*.md)
    Running update command: git pull --ff-only
    Already up to date.
Collection: ~/reference/wiki (**/*.md)
Indexed: 0 new, 2 updated, 340 unchanged, 0 removed

The command runs via bash -c in the collection's own directory (its path), not your current working directory. If it exits non-zero, qmd update prints the failure and aborts the entire run — collections after the failing one are not re-indexed. Set or clear it from the CLI instead of editing YAML by hand:

qmd collection update-cmd wiki 'git pull --ff-only'   # set
qmd collection update-cmd wiki                         # clear

Search Commands

┌──────────────────────────────────────────────────────────────────┐
│                        Search Modes                              │
├──────────┬───────────────────────────────────────────────────────┤
│ search   │ BM25 full-text search only                           │
│ vsearch  │ Vector semantic search only                          │
│ query    │ Hybrid: FTS + Vector + Query Expansion + Re-ranking  │
└──────────┴───────────────────────────────────────────────────────┘
# Full-text search (fast, keyword-based)
qmd-ja search "authentication flow"

# Vector search (semantic similarity)
qmd-ja vsearch "how to login"

# Hybrid search with re-ranking (best quality)
qmd-ja query "user authentication"

Two aliases exist for the semantic/hybrid modes: vector-search (→ vsearch) and deep-search (→ query).

Options

# 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
--intent "<text>"  # Disambiguation context (e.g. "web page load times")
--no-rerank        # Skip LLM reranking (RRF scores only; faster on CPU)
-C, --candidate-limit <n>  # Max candidates to rerank (default: 40)
--full-path        # Emit on-disk filesystem paths instead of qmd:// URIs

# Output formats (for search and multi-get)
--format <kind>    # cli (default) | json | csv | md | xml | files
                   # (--json, --csv, --md, --xml, --files are legacy aliases)

# Get options
qmd-ja get <file>[:from[:count]]  # Get document; optional start line and count
-l <num>                       # Maximum lines to return
--from <num>                   # Start line (overrides the :from suffix)
--no-line-numbers              # Disable line numbering (on by default)

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

Collection Filtering

The -c/--collection flag filters results by collection name (as shown by qmd-ja collection list). Collections are a global registry — you can search any collection from any directory:

qmd-ja search "auth" -c notes           # single collection
qmd-ja search "auth" -c notes -c docs   # multiple collections (OR)

With no -c flag, all default-included collections are searched. Collections marked excluded (qmd-ja collection exclude <name>) are skipped unless named explicitly with -c.

Note: With multiple -c flags, results come from a global top-K pool and are then filtered. If one collection dominates the rankings, matches from smaller collections may not appear at the default limit — raise -n or use --all.

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:

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):

# 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-ja get #a1b2c3

  • Title: Extracted from document (first heading or filename)

  • Context: Path context if configured via qmd-ja context add

  • Score: Color-coded (green >70%, yellow >40%, dim otherwise)

  • Snippet: Context around match with query terms highlighted

Examples

# Get 10 results with minimum score 0.3
qmd-ja query -n 10 --min-score 0.3 "API design patterns"

# Output as markdown for LLM context
qmd-ja search --md --full "error handling"

# JSON output for scripting
qmd-ja query --json "quarterly reports"

# Inspect how each result was scored (RRF + rerank blend)
qmd-ja query --json --explain "quarterly reports"

# Use separate index for different knowledge base
qmd-ja --index work search "quarterly reports"

The --explain flag attaches a score breakdown to each result: the FTS/vector backend scores plus the RRF fusion math (rank, weight, top-rank bonus) and every sub-query's contribution. Abbreviated:

{
  "docid": "#6c90f0",
  "score": 0.89,
  "file": "qmd://qmd/README.md",
  "explain": {
    "ftsScores": [0.892, 0.907],
    "vectorScores": [0.540, 0.484],
    "rrf": {
      "rank": 1,
      "weight": 0.75,
      "baseScore": 0.123,
      "topRankBonus": 0.05,
      "totalScore": 0.173,
      "contributions": [
        { "source": "fts", "queryType": "original", "query": "reranking",
          "rank": 1, "weight": 2, "backendScore": 0.892, "rrfContribution": 0.0328 }
      ]
    }
  }
}

Index Maintenance

# Show index status and collections with contexts
qmd-ja status

# Re-index all collections. If a collection has a configured update command
# (e.g. `git pull`), it runs first — set one with `qmd-ja collection update-cmd`.
qmd-ja update

# Diagnose the install (runtime, sqlite-vec, embedding fingerprints, GPU probe)
qmd-ja doctor

# Initialize a project-local index in the current directory
qmd-ja init

# Get document by filepath (with fuzzy matching suggestions)
qmd-ja get notes/meeting.md

# Get document by docid (from search results)
qmd-ja get "#abc123"

# Get document starting at line 50, max 100 lines
qmd-ja get notes/meeting.md:50 -l 100

# Read 40 lines starting at line 120 via the :from:count suffix (works with docids)
qmd-ja get notes/meeting.md:120:40
qmd-ja get "#abc123:120:40"

# get / multi-get are line-numbered by default; disable with --no-line-numbers
qmd-ja get notes/meeting.md --no-line-numbers

# Get multiple documents by glob pattern
qmd-ja multi-get "journals/2025-05*.md"

# Get multiple documents by comma-separated list (supports docids)
qmd-ja multi-get "doc1.md, doc2.md, #abc123"

# Limit multi-get to files under 20KB
qmd-ja multi-get "docs/*.md" --max-bytes 20480

# Output multi-get as JSON for agent processing
qmd-ja multi-get "docs/*.md" --json

# Clean up cache and orphaned data
qmd-ja cleanup

Benchmarking

Measure search quality across all four backends with qmd-ja bench and a fixture file of queries with known-relevant documents.

From a git checkout, an example fixture and its test corpus ship in the repo:

# One-time setup (indexes the repo's test corpus into its own collection)
qmd-ja collection add test/eval-docs --name eval-docs
qmd-ja embed -c eval-docs

# Run the benchmark (table output)
qmd-ja bench src/bench/fixtures/example.json

# JSON output for programmatic analysis
qmd-ja bench src/bench/fixtures/example.json --json

Bench uses the active index and model configuration. The vector, hybrid, and full backends run through the same Store and Provider paths as normal search, so GGUF and ONNX embedding/rerank changes are reflected after the collection is embedded with the selected models. If you switch embedding models, run qmd-ja pull, qmd-ja doctor, and qmd-ja embed -f before comparing results.

The example fixture (src/bench/fixtures/example.json) and its test corpus (test/eval-docs/) exist only in a git checkout — they are not part of the published npm package. If you installed via npm/npx, write your own fixture (see below) against a collection you have already indexed:

qmd-ja bench my-fixture.json -c my-collection

Each query runs against four backends, reporting precision@k, recall, MRR, and F1:

Backend What it tests LLM required
bm25 Keyword search only (FTS5) No
vector Semantic similarity only Embedding model
hybrid BM25 + vector fusion (no reranking) Embedding model
full Full pipeline with LLM reranking All three models

Score interpretation: 1.00 = perfect (all expected docs in top results), 0.00 = complete miss. The example fixture typically shows bm25 ~0.50, vector ~0.70, and hybrid/full ~1.00 — a concrete demonstration of why hybrid search beats either backend alone.

Custom fixtures are JSON:

{
  "description": "My benchmark",
  "version": 1,
  "collection": "my-collection",
  "queries": [
    {
      "id": "find-auth",
      "query": "authentication flow",
      "type": "semantic",
      "expected_files": ["docs/auth-design.md"],
      "expected_in_top_k": 3
    }
  ]
}

expected_files are collection-relative paths as shown by qmd-ja ls. The type field (exact, semantic, topical, cross-domain, alias) labels queries for grouping — it does not change search behavior.

Heads-up: if the fixture's collection isn't indexed, bench currently runs to completion and reports all zeros with no warning. Verify setup with qmd-ja ls <collection> first.

Data Storage

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

Schema

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
XDG_CONFIG_HOME ~/.config Config directory location (where index.yml lives)
QMD_CONFIG_DIR unset Override the config directory outright (takes precedence over XDG_CONFIG_HOME)
QMD_LLAMA_GPU auto Force llama.cpp GPU backend (metal, vulkan, cuda) or disable GPU with false
QMD_FORCE_CPU unset Set to 1/true to force CPU mode before any CUDA/Vulkan/Metal probing. Equivalent CLI flag: --no-gpu.
QMD_EMBED_MODEL GGUF default Override embedding model. Supports hf:..., local GGUF paths, and onnxe:... ONNX embedding URIs.
QMD_RERANK_MODEL GGUF default Override reranker model. Supports hf:..., local GGUF paths, and onnx:... ONNX rerank URIs.
QMD_GENERATE_MODEL GGUF default Override query-expansion model. GGUF only, via node-llama-cpp.
QMD_EMBED_PARALLELISM automatic Override embedding/reranking context parallelism (1-8). Windows CUDA defaults to 1 because parallel CUDA contexts can crash with ggml-cuda.cu:98; use Vulkan or raise this only if your driver is stable.

How It Works

Indexing Flow

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:

Document ──► Smart Chunk (~900 tokens) ──► Format each chunk ──► EmbeddingProvider ──► Store Vectors
                │                           "title | text"        GGUF or ONNX
                │
                └─► 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)

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 resolved in this order:

  1. index config models
  2. environment variables (QMD_EMBED_MODEL, QMD_GENERATE_MODEL, QMD_RERANK_MODEL)
  3. defaults in src/llm.ts

Example index config (qmd-ja default — ONNX models for Japanese):

models:
  embed: onnxe:mochiya98/ruri-v3-310m-onnx/q8
  generate: hf:adsholoko/qmd-query-expansion-ja/Qwen3-1.7B.Q4_K_M.gguf
  rerank: onnx:hotchpotch/japanese-reranker-xsmall-v2/model_qint8_avx2

The built-in upstream defaults are GGUF Hugging Face URIs:

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";

ONNX URIs are supported for embedding (onnxe:) and reranking (onnx:). The generate model must remain a GGUF model because query expansion currently runs through node-llama-cpp.

Override models per-role without touching source via the models: block in index.yml or the QMD_EMBED_MODEL / QMD_RERANK_MODEL env vars. Re-run qmd-ja embed after changing the embedding model.

EmbeddingGemma Prompt Format

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

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

Qwen3-Reranker

The default GGUF Qwen3-Reranker uses node-llama-cpp's createRankingContext() and rankAndSort() API for cross-encoder reranking. ONNX rerank models are routed through the Provider layer and run via @huggingface/transformers. Returns documents sorted by relevance score (0.0 - 1.0).

Qwen3 (Query Expansion)

Used for generating query variations via LlamaChatSession.

License

MIT

About

Japanese-enhanced fork of qmd — Vaporetto WASM morphological tokenizer for accurate Japanese BM25 search

Topics

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors