正在打开仓库文档
正在打开仓库文档
一个轻量本地搜索引擎,适合观察“先找对上下文,再调用模型”的工作方式。
中文阅读版只翻译说明性文字;术语、代码、命令、链接与原有 Markdown 结构保持不变。
一款完全在本地设备上运行的搜索引擎,帮你留住所有需要记住的内容。索引你的 markdown 笔记、会议记录、文档和知识库。支持关键词或自然语言搜索。非常适合你的 agentic 流程。
QMD 结合了 BM25 全文搜索、向量语义搜索和 LLM 重排序——全部通过 node-llama-cpp 在本地运行,使用 GGUF 模型。
flowchart LR
Q[User Query] --> X[Query Expansion]
Q --> FTS[BM25 Search]
Q --> VS[Vector Search]
X --> HYDE[HyDE]
X --> VEC[Vec dense sentences]
X --> LEX[Lex BM25 keywords]
HYDE --> VS
VEC --> VS
LEX --> FTS
VS --> RRF[Reciprocal Rank Fusion]
FTS --> RRF
RRF --> RR[LLM Reranker]
RR --> OUT[Final ranked results]
带类型的扩展查询按类型独占路由:lex → BM25/FTS,vec 和 hyde → 向量搜索。原始查询会同时发送到两个后端,然后通过 RRF 融合并进行重排序。
你可以在 CHANGELOG 中了解更多关于 QMD 进展的信息。
# 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
QMD 的 --json 和 --files 输出格式专为 agentic 工作流设计:
# 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
虽然你直接让 agent 在命令行上使用这个工具就已经完全没问题,但它也暴露了一个 MCP(Model Context Protocol)服务器,以实现更紧密的集成。
暴露的工具:
query — 使用带类型的子查询(lex/vec/hyde)进行搜索,通过 RRF + 重排序合并get — 按路径或 docid 检索文档(带模糊匹配建议)multi_get — 按 glob 模式、逗号分隔列表或 docid 批量检索status — 索引健康状况和 collection 信息,包括每个 collection 的主要 metadata 键metadata — 发现可用于筛选的 metadata 键、类型和值计数Claude Desktop 配置(~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"qmd": {
"command": "qmd",
"args": ["mcp"]
}
}
}
Claude Code — 安装插件(推荐):
claude plugin marketplace add tobi/qmd
claude plugin install qmd@qmd
或者手动在 ~/.claude/settings.json 中配置 MCP:
{
"mcpServers": {
"qmd": {
"command": "qmd",
"args": ["mcp"]
}
}
}
默认情况下,QMD 的 MCP 服务器使用 stdio(由各个客户端作为子进程启动)。如果想要一个共享的、长期运行的服务器来避免重复加载模型,可以使用 HTTP 传输方式:
# Foreground (Ctrl-C to stop)
qmd mcp --http # localhost:8181
qmd mcp --http --port 8080 # custom port
qmd mcp --http --host 0.0.0.0 # bind all interfaces (e.g. container probes)
# 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
服务器默认绑定到 localhost。传入 --host(或设置 QMD_HOST 环境变量)可以覆盖默认值——当服务器运行在容器中、且存活探针(liveness probe)从非 loopback 地址连接时,--host 0.0.0.0 会很有用。
HTTP 服务器暴露了两个端点:
POST /mcp — MCP Streamable HTTP(JSON 响应,无状态)POST /query(别名 /search)—— 不经过 MCP 协议的结构化搜索。接受与 query 工具相同的可选 filter 对象(无效的 filter 返回 400);参见 Metadata FilteringPOST /metadata —— 不经过 MCP 协议的 metadata 发现。请求体与 metadata 工具相同(无效的 filter 返回 400);参见 Metadata DiscoveryGET /health — 带运行时长的存活检查每个请求在路由之前都会被检查:携带的 Origin 头不是 loopback 地址的请求会被以 403 拒绝,Host 头指向的地址不是服务器所绑定地址的请求同样会被拒绝。这正是防止你访问的网页通过 DNS rebinding 读取你的索引的机制——仅绑定 loopback 并不能做到这一点,因为浏览器是从你自己的机器发起请求的。
没有 Origin 头的请求——curl、MCP 客户端、编辑器——不受影响,这涵盖了所有正常的本地客户端。
| 变量 | 效果 |
|---|---|
QMD_ALLOWED_ORIGINS | 除 loopback 之外额外接受的可访问来源,逗号分隔,例如 https://notes.internal。设为 * 可完全禁用该检查。 |
QMD_ALLOWED_HOSTS | 除 loopback 和绑定地址之外额外接受的 Host 值,逗号分隔。 |
--host 0.0.0.0 无法知道哪些 Host 值是合法的,因此它会跳过 host 检查并在启动时发出警告。设置 QMD_ALLOWED_HOSTS 可重新启用该检查。请记住这些端点没有身份验证——对于可从宿主机之外访问的服务器,请自行在其前面加一层认证。
LLM 模型会在多次请求之间保持加载在 VRAM 中。Embedding/重排序上下文在空闲 5 分钟后被释放,并在下一次请求时透明地重建(约 1 秒的代价,模型仍保持加载状态)。
将任何 MCP 客户端指向 http://localhost:8181/mcp 即可连接。
| 工具 | 参数 | 类型 | 说明 |
|---|---|---|---|
query | searches | array | 带类型的子查询(lex/vec/hyde),1–10 个。**必填。**第一个查询权重为 2 倍。 |
query | collections | string[] | 按 collection 名称筛选(OR 关系)。仅接受数组 —— 单数形式的 collection 会被静默忽略。 |
query | filter | object | metadata 过滤器(递归的、以 operator 区分的 JSON AST;参见 Metadata Filtering) |
query | intent | string | 消歧上下文(本身不会单独用于搜索) |
query | limit | number | 最大结果数(默认 10) |
query | minScore | number | 最小相关度 0–1(默认 0) |
query | candidateLimit | number | 重排序的最大候选数(默认 40) |
query | rerank | boolean | 是否运行 LLM 重排序(默认 true);设为 false 则仅使用 RRF |
get | file | string | 路径、docid(#abc123)或 path:from:count(例如 #abc123:120:40) |
get | fromLine | number | 起始行(从 1 开始);会覆盖 :from 后缀 |
get | maxLines | number | 限制返回的行数 |
get | lineNumbers | boolean | 在行前加上行号(默认 true) |
multi_get | pattern | string | glob 模式或逗号分隔列表 |
multi_get | maxBytes | number | 跳过大于 N 的文件(默认 10240) |
multi_get | maxLines | number | 限制每个文件的行数 |
multi_get | lineNumbers | boolean | 在行前加上行号(默认 true) |
metadata | collections | string[] | 将发现范围限制在指定的 collection 名称(默认:query 搜索的 collections) |
metadata | match | object | 只报告匹配此条件的 metadata 条目(AST 与 filter 相同,field 指定条目的 key 或 value) |
metadata | filter | object | 只统计匹配此过滤器的文档(AST 与 query 相同) |
metadata | keyLimit | number | 报告的键数量(默认 50)。其余部分由 totalKeys 和 remainingKeys 描述 |
metadata | keyOffset | number | 按报告顺序、在窗口之前跳过的键数量(默认 0) |
metadata | valueLimit | number | 每个键和类型报告的值数量(默认 10)。其余部分由 remainingValues 报告 |
metadata | valueOffset | number | 按 sort 顺序、在每个键和类型的窗口之前跳过的值数量(默认 0) |
metadata | sort | string | count(默认)或 value |
metadata | minCount | number | 隐藏持有文档数少于此值的取值(默认 1) |
未知的参数会被静默忽略(不会报错拒绝)——如果结果看起来没有按预期限定范围,请仔细检查参数名。HTTP 的 /query 和 /search 端点会在 file 字段中返回 qmd://collection/path 形式的 URI,与 CLI 和 MCP 输出一致。
在你自己的 Node.js 或 Bun 应用中把 QMD 当作库来使用。
npm install @tobilu/qmd
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()
createStore() 接受三种模式:
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() 方法既能处理简单查询,也能处理预先展开的结构化查询:
// 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
// Metadata filter — every returned result satisfies it (also available on
// searchLex() and searchVector()); results expose indexed metadata via
// r.metadata. See "Metadata Filtering" for the full grammar.
const published = await store.search({
query: "authentication flow",
filter: {
operator: "and",
operands: [
{ field: "topics", operator: "all", value: ["typescript"] },
{ field: "status", operator: "ne", value: "draft" },
],
},
})
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 })
// 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,
})
// 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 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
// 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}`)
},
})
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 '@tobilu/qmd'
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 '@tobilu/qmd'
// 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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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 │
└───────────────────────┘
| 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 |
The query command uses Reciprocal Rank Fusion (RRF) with position-aware blending:
score = Σ(1/(k+rank+1)) where k=60Why 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 | 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 |
brew install sqlite
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/.
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 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:
Note: When switching embedding models, you must re-index with
qmd embed -fsince vectors are not cross-compatible between models. The prompt format is automatically adjusted for each model family.
npm install -g @tobilu/qmd
# or
bun install -g @tobilu/qmd
git clone https://github.com/tobi/qmd
cd qmd
npm install
npm link
# 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"
# Comma-separated masks are a union (brace form `{a,b}` also works)
qmd collection add ~/notes --name notes --mask "sources/**/*.md,CO - *.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
# Show collection details (path, glob mask, include status, context count, top metadata keys)
qmd collection show notes
# Discover metadata keys, types, and value counts (see Metadata Discovery)
qmd collection metadata notes
qmd collection metadata notes --match '{"field":"key","operator":"eq","value":"topics"}'
qmd collection metadata notes --match '{"field":"value","operator":"eq","value":"docs-team"}'
# Include or exclude a collection from default (unscoped) queries
qmd collection include notes
qmd collection exclude notes
# Run a command before every `qmd update` (e.g. git pull); empty arg clears it
qmd collection update-cmd notes 'git pull --rebase'
qmd collection update-cmd notes
# 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
# Memory control for large corpora / constrained systems
qmd embed --max-docs-per-batch 50 # cap docs per embedding batch
qmd 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 status to verify which grammars are available.
Note: Tree-sitter grammars are optional dependencies. If they are not installed,
--chunk-strategy autofalls back to regex-only chunking automatically. Tested on both Node.js and Bun.
Context adds descriptive metadata to collections and paths, helping search understand your content.
# 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
index.ymlThe 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}.yml — qmd --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. Comma-separated lists and brace groups ({a,b}) are a union of patterns. |
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.ymlchanges which directories and models QMD uses, but does not re-index on its own. Runqmd updateafter changingpath,pattern, orignore, andqmd embedafter changingmodels.embed.
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
.qmd config is not trusted by defaultA project-local .qmd/index.yml travels with a git clone, and QMD adopts it
automatically for any command run inside the tree. Three fields in that file can
reach outside the project, and QMD will not use them unattended:
update commands — somebody else's shell script, run by qmd updatecollections.*.path pointing outside the project directorymodels.embed / models.rerank / models.generate other than the built-in
defaults (any hf: repo or local GGUF path)In-project collection paths (for example ./docs) still index. On a terminal
qmd update (and qmd embed / qmd pull / qmd query) lists the gated
fields and asks. Approving records the approval in ~/.config/qmd/trusted.json.
With no terminal to ask — agents, CI, MCP — those fields are skipped and
in-project indexing continues.
Approvals cover the exact gated set you saw. Editing a command, pointing a collection outside the project, or changing a custom model URI asks again.
qmd trust # review and approve this project's gated fields
qmd trust list # show every approved project config
qmd trust revoke # drop the approval for this project
Set QMD_TRUST_LOCAL_CONFIG=1 (or QMD_TRUST_UPDATE_HOOKS=1) for CI that
should allow them unattended. Your own ~/.config/qmd/*.yml — including
anything qmd collection update-cmd or qmd collection add writes — is
never gated.
┌──────────────────────────────────────────────────────────────────┐
│ 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 search "authentication flow"
# Vector search (semantic similarity)
qmd vsearch "how to login"
# Hybrid search with re-ranking (best quality)
qmd query "user authentication"
Two aliases exist for the semantic/hybrid modes: vector-search (→ vsearch)
and deep-search (→ query).
# 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)
--filter <json> # Metadata filter (recursive JSON AST; see Metadata Filtering)
--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
# (a result whose file has moved or been deleted since
# indexing keeps its qmd:// URI + docid, and a notice is
# printed to stderr — run `qmd update` to refresh)
# 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 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)
The -c/--collection flag filters results by collection name (as shown by
qmd collection list). Collections are a global registry — you can search any
collection from any directory:
qmd search "auth" -c notes # single collection
qmd search "auth" -c notes -c docs # multiple collections (OR)
With no -c flag, all default-included collections are searched. Collections
marked excluded (qmd collection exclude <name>) are skipped unless named
explicitly with -c.
Note: With multiple
-cflags, 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-nor use--all.
Documents can opt into typed metadata through a namespaced frontmatter block. A document without qmd.metadata behaves exactly as before, and the frontmatter stays ordinary searchable content (no chunking, embedding, or line-number changes):
---
qmd:
metadata:
topics:
- typescript
- programming
status: published
priority: 3
reviewed: true
---
# Document body starts here
Supported values are strings, numbers, booleans, and flat homogeneous arrays of one of those. Nested objects, nulls, empty arrays, and mixed-type arrays are rejected (the document still indexes; it is excluded from filtered search until corrected). Metadata keys are user-defined data — tags, topics, and labels are all ordinary keys with no special semantics.
Every search surface (CLI, SDK, MCP, HTTP) accepts the same recursive filter, a JSON AST discriminated by operator. A condition is a predicate over one field of the document's metadata: field names the metadata key, operator says how to compare, and value is what to compare against:
# One condition
qmd search "authentication" \
--filter '{"field":"status","operator":"eq","value":"published"}'
# Composed conditions — works with search, vsearch, and query
qmd query "dependency injection" --filter '{
"operator": "and",
"operands": [
{ "field": "topics", "operator": "all", "value": ["typescript", "programming"] },
{ "field": "status", "operator": "nin", "value": ["draft", "archived"] },
{ "operator": "or", "operands": [
{ "field": "priority", "operator": "gte", "value": 3 },
{ "field": "reviewed", "operator": "eq", "value": true }
] },
{ "field": "topics", "operator": "prefix", "value": "sql", "caseInsensitive": true },
{ "operator": "not", "operand": { "field": "audience", "operator": "eq", "value": "internal" } }
]
}'
| Node | Shape |
|---|---|
| Logical group | { "operator": "and" | "or", "operands": […] } |
| Negation | { "operator": "not", "operand": {…} } |
| Comparison | { "field", "operator": "eq" | "ne" | "gt" | "gte" | "lt" | "lte", "value" } |
| Membership | { "field", "operator": "in" | "nin" | "all", "value": […] } |
| Text | { "field", "operator": "contains" | "prefix" | "suffix", "value": "…" } |
| Type | { "field", "operator": "type", "value": "string" | "number" | "boolean" } |
| Presence | { "field", "operator": "exists", "value": true | false } |
Any condition whose value is a string or an array of strings may add "caseInsensitive": true.
Semantics:
ne and nin). Text operators match string values only. type matches the stored type of a key's values, which is how a filter reaches one side of a key whose documents disagree on type.all requires every filter value to be present, and ne/nin require that no element equals the operand (with at least one element of the operand's type present).ne/nin; combine with { "operator": "exists", "value": false } in an or group to include them.caseInsensitive, which folds ASCII letters on both sides. Non-ASCII letters compare exactly.and group — there is no implicit AND, and no $-prefixed shorthand.Guarantees and limits:
limit results. Lexical search filters a bounded over-fetch window; vector search scans the filter-eligible set exactly when it holds at most 20,000 chunk vectors, and above that over-fetches and post-filters.qmd update after upgrading; qmd status shows the pending count).JSON output (--format json), the SDK, MCP structured results, and the HTTP endpoints include each result's indexed metadata.
Filtering is only useful if you know what to filter on. Discovery reports the metadata keys, types, and value counts already in the index, so a filter can be written from what is indexed instead of guessed. It reads the same tables filtering reads: no re-indexing, and every value it reports is one an eq filter can match.
The command is one sentence: show metadata matching X for documents filtered by Y. --filter narrows documents by their metadata (same AST as search) and decides which are counted. --match narrows the metadata itself and decides which entries are reported. It takes the same AST, evaluated against each metadata entry instead of each document: a condition's field is "key" (the entry's key name) or "value" (its value). Every operator applies, including type, the text operators, caseInsensitive, and and/or/not. Only exists and all are rejected, as they have no meaning for a single entry.
--match | Question answered |
|---|---|
| Which keys exist, with a window of values each | |
{"field":"key","operator":"eq","value":"topics"} | Everything about one key |
{"field":"key","operator":"prefix","value":"mem-"} | A family of keys |
{"field":"key","operator":"in","value":["tags","topics","labels"]} | Which of these key names exist |
{"field":"value","operator":"eq","value":"docs-team"} | Which keys hold this value |
{"field":"value","operator":"prefix","value":"2025-"} | Which keys hold values shaped like this |
{"field":"value","operator":"type","value":"boolean"} | Which keys hold booleans |
and of key eq priority and value gte 3 | Values of one key above a threshold |
and of key eq priority and value type number | The numeric side of a key whose documents disagree on type |
Start wide and narrow:
# Which keys does this collection use? (`qmd collection show notes` previews the top five)
qmd collection metadata notes
# Everything about one key: coverage, distinct count, top values
qmd collection metadata notes --match '{"field":"key","operator":"eq","value":"topics"}'
# Reverse lookup: which keys hold this value
qmd collection metadata notes --match '{"field":"value","operator":"eq","value":"docs-team"}'
# Values of one key matching a pattern
qmd collection metadata notes --match '{
"operator": "and",
"operands": [
{ "field": "key", "operator": "eq", "value": "topics" },
{ "field": "value", "operator": "prefix", "value": "sql" }
]
}'
# What remains after a filter, before committing to it in a query
qmd collection metadata notes \
--match '{"field":"key","operator":"eq","value":"topics"}' \
--filter '{"field":"status","operator":"eq","value":"published"}'
# Then search with the filter you just validated
qmd query "dependency injection" -c notes --filter '{
"operator": "and",
"operands": [
{ "field": "status", "operator": "eq", "value": "published" },
{ "field": "topics", "operator": "all", "value": ["typescript"] }
]
}'
The drill-down prints one block per key, in coverage order:
qmd collection metadata notes
topics string[] 388 of 480 documents 1,204 distinct
typescript 140
sqlite 92
search 77
architecture 61
sqlite-vec 44
mcp 39
embeddings 35
agents 31
cli 28
testing 26
1,194 more values, use --value-limit <n>, --value-offset <n>, or --all-values
priority number 205 of 480 documents 5 distinct
min 1 median 3 max 5
1 (12) 2 (40) 3 (88) 4 (50) 5 (15)
reviewed boolean 480 of 480 documents
true 61 false 419
With --filter, the output opens with a filter: line stating how many documents pass, and every coverage count is measured against that population rather than the whole collection — the numbers a filtered search would see:
qmd collection metadata notes \
--match '{"field":"key","operator":"eq","value":"topics"}' \
--filter '{"field":"status","operator":"eq","value":"published"}' \
--value-limit 5
filter: 312 of 480 documents
topics string[] 260 of 312 documents 811 distinct
typescript 104
sqlite 70
search 58
architecture 40
mcp 31
806 more values, use --value-limit <n>, --value-offset <n>, or --all-values
Two windows page the key and value lists. Each windowed list ends with the exact remainder and the flags that reach it:
--key-limit <n> # Keys reported, in coverage order (default 50)
--key-offset <n> # Keys skipped before the window
--all-keys # Remove the key window
--value-limit <n> # Values reported per key and type (default 10)
--value-offset <n> # Values skipped per key and type, in --sort order
--all-values # Remove the value window
--sort count|value # Order values by document count (default) or by value
--min-count <n> # Drop values held by fewer documents
Omitting the collection name covers the default collections, exactly as an unscoped search does.
Reading the output:
topics: [a, b] contributes one to each. Coverage is "documents declaring this key", out of the documents the filter admits when there is one."42", "", "a, b") prints as a JSON string, so paste it into a filter as the JSON string it is.gt/lt threshold in one call.qmd update runs.priority: 3 in one file and priority: high in another both index, within one collection or across several. Discovery splits such a key by type, each with its own document count and (across collections) its contributing collections:qmd collection metadata --match '{"field":"key","operator":"eq","value":"priority"}'
priority number | string 1,427 of 2,100 documents
number 223 docs min 1 median 3 max 5 notes, work
string 1,204 docs high (700), medium (380), low (124) work
To report one side only, add a type condition on the value field. The filter that reaches exactly those documents is the same condition with the metadata key in field:
qmd collection metadata --match '{
"operator": "and",
"operands": [
{ "field": "key", "operator": "eq", "value": "priority" },
{ "field": "value", "operator": "type", "value": "number" }
]
}'
qmd query "release checklist" --filter '{"field":"priority","operator":"type","value":"number"}'
qmd collection list names each collection's top keys, qmd collection show <name> details the top five with a value preview, and qmd status summarizes how many keys and files carry metadata.
The CLI prints text and rejects unsupported format flags. Structured discovery is available through the SDK, MCP, and HTTP with the same options (collection, match, filter, keyLimit, keyOffset, valueLimit, valueOffset, sort, minCount) and the same result shape:
// SDK: one flat result, keys in coverage order, each split per type
const discovery = await store.listMetadata({
collection: "notes",
match: { field: "key", operator: "eq", value: "topics" },
valueLimit: 5,
})
discovery.documents // active documents in scope
discovery.totalKeys // keys with a matching entry, before the key window
discovery.remainingKeys // keys after the window
discovery.keys[0].types[0] // { type, multiValued, documents, distinctValues, values, remainingValues, range?, collections }
// Narrowed by a filter: how many documents pass, and what is left to filter on
const published = await store.listMetadata({
collection: "notes",
filter: { field: "status", operator: "eq", value: "published" },
})
published.filteredDocuments // the denominator for every coverage count in this result
// Page through a wide vocabulary
const nextPage = await store.listMetadata({ keyLimit: 50, keyOffset: 50 })
filter is a MetadataFilter and match is a MetadataMatch. Both are MetadataPredicate<Condition>, the one recursive grammar over the conditions each record admits, so a document-only condition (exists, all, or a metadata key as the field) is a type error in a match as well as a runtime one. An option outside its domain throws MetadataOptionError, and a filter and match that together bind more SQL parameters than one statement allows throw MetadataBindingBudgetError.
The MCP metadata tool takes the same options with collections spelled as on query, returns the CLI text plus the result as structuredContent, and the MCP status tool lists each collection's most covered keys so an agent's first call reveals that metadata exists. POST /metadata accepts the same body as the tool and returns the same result (400 on an invalid match, filter, or option).
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 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
# 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"
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 }
]
}
}
}
# Show index status and collections with contexts
qmd status
# Re-index all collections. If a collection has a configured update command
# (e.g. `git pull`), it runs first — set one with `qmd collection update-cmd`.
qmd update
# Diagnose the install (runtime, sqlite-vec, embedding fingerprints, GPU probe)
qmd doctor
# Initialize a project-local index in the current directory
qmd init
# 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
# Read 40 lines starting at line 120 via the :from:count suffix (works with docids)
qmd get notes/meeting.md:120:40
qmd get "#abc123:120:40"
# get / multi-get are line-numbered by default; disable with --no-line-numbers
qmd get notes/meeting.md --no-line-numbers
# 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
# Drop caches and orphans; repack the vector table when it has holes
qmd cleanup
Measure search quality across all four backends with qmd 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 collection add test/eval-docs --name eval-docs
qmd embed -c eval-docs
# Run the benchmark (table output)
qmd bench src/bench/fixtures/example.json
# JSON output for programmatic analysis
qmd bench src/bench/fixtures/example.json --json
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 vianpm/npx, write your own fixture (see below) against a collection you have already indexed:qmd 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 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 ls <collection>first.
Index stored in: ~/.cache/qmd/index.sqlite
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)
vector_collection_ids -- Integer id per collection name (the vector partition key)
vector_rows -- Vector rowid to (hash, seq, collection_id)
vectors_by_collection -- sqlite-vec vector index, one row per chunk and collection
llm_cache -- Cached LLM responses (query expansion, rerank scores)
| 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_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. |
Collection ──► Glob Pattern ──► Markdown Files ──► Parse Title ──► Hash Content
│ │ │
│ │ ▼
│ │ Generate docid
│ │ (6-char hash)
│ │ │
└──────────────────────────────────────────────────►└──► Store in SQLite
│
▼
FTS5 Index
Documents are chunked into ~900-token pieces with 15% overlap using smart boundary detection:
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
QMD 不在生硬的 token 边界处直接切割,而是使用一种评分算法来寻找 markdown 中自然的断点。这样可以将语义单元(章节、段落、代码块)保持在一起。
断点评分:
| 模式 | 分数 | 说明 |
|---|---|---|
# Heading | 100 | H1 - 主要章节 |
## Heading | 90 | H2 - 子章节 |
### Heading | 80 | H3 |
#### Heading | 70 | H4 |
##### Heading | 60 | H5 |
###### Heading | 50 | H6 |
``` | 80 | 代码块边界 |
--- / *** | 60 | 水平分隔线 |
| 空行 | 20 | 段落边界 |
- item / 1. item | 5 | 列表项 |
| 换行 | 1 | 最小断点 |
算法:
finalScore = baseScore × (1 - (distance/window)² × 0.7)这种平方距离衰减意味着 200 tokens 之前的一个标题(分数约 30)仍能胜过目标位置处的简单换行(分数 1),但距离更近的标题会胜过更远的标题。
代码围栏保护: 代码块内部的断点会被忽略——代码保持完整。如果某个代码块超过了分块大小,会尽可能将其整体保留。
AST 感知分块(代码文件):
对于受支持的代码文件,QMD 还会使用 tree-sitter 解析源码,并添加由 AST 推导出的断点,这些断点会与上面的 regex 分数合并:
| AST 节点 | 分数 | 语言 |
|---|---|---|
| Class / interface / struct / impl / trait | 100 | 全部 |
| Function / method | 90 | 全部 |
| Type alias / enum | 80 | 全部 |
| Import / use declaration | 60 | 全部 |
支持 .ts、.tsx、.js、.jsx、.py、.go 和 .rs 文件。使用 --chunk-strategy auto 启用。Markdown 及其他文件类型始终使用 regex 分块。
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
默认模型以 HuggingFace URI 的形式定义在 src/llm.ts 中:
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";
无需改动源码,即可通过 index.yml 中的 models: 块(参见 Configuring index.yml)或 QMD_EMBED_MODEL 环境变量按角色覆盖这些模型。更改嵌入模型后,请重新运行 qmd embed。
// For queries
"task: search result | query: {query}"
// For documents
"title: {title} | text: {content}"
使用 node-llama-cpp 的 createRankingContext() 和 rankAndSort() API 进行 cross-encoder 重排序。返回按相关性分数(0.0 - 1.0)排序的文档。
用于通过 LlamaChatSession 生成查询变体。
MIT