QMD 深度解析:构建本地化混合搜索引擎的完整指南

3551 字
18 分钟
QMD 深度解析:构建本地化混合搜索引擎的完整指南

QMD 深度解析:构建本地化混合搜索引擎的完整指南#

🔍 Search Engine

🤖 Local AI

⚡ BM25 + Vector

✨ 核心亮点

QMD (Query Markup Documents) 是一个完全在本地设备运行的混合搜索引擎,它结合了:

  • 100% 本地运行 - 无需云端 API,隐私安全
  • 混合搜索架构 - BM25 关键词 × 向量语义
  • LLM 智能增强 - 查询扩展 + Re-ranking
  • AST 感知分块 - Tree-sitter 代码理解
  • 完整 MCP 支持 - 无缝集成 AI Agent
📊 快速对比
特性QMDElasticsearchGoogle
部署方式🏠 单机本地☁️ 分布式集群🌐 SaaS
隐私保护✅ 100% 本地⚠️ 取决于部署❌ 云端处理
语义理解✅ LLM 加持⚠️ 需插件✅ 最强
设置复杂度✅ 简单❌ 复杂✅ 开箱即用
成本💰 免费💵 硬件成本💳 按量付费
延迟⚡ 毫秒级⚡ 毫秒级🌍 网络依赖

🎯 引言:为什么需要本地化混合搜索?#

在数据爆炸的时代,如何高效地检索和组织我们自己的知识资产成为了一个重要课题。Query Markup Documents (QMD) 提供了一个强大的解决方案——一个完全运行在本地设备上的混合搜索引擎

当前挑战
  1. 隐私担忧:云端搜索需要将数据发送到第三方服务器
  2. 成本压力:企业级搜索解决方案价格昂贵
  3. 语义鸿沟:传统关键词搜索无法理解用户真实意图
  4. 信息孤岛:分散的文档难以统一管理检索

🏗️ QMD 架构概览#

QMD 的核心设计理念:“多种搜索技术融合 + 智能重排序 = 最佳搜索结果”

🔄 QMD 混合搜索架构流程

📝 用户查询 Query

⬇️

🤖 LLM 查询扩展 (Query Expansion)

⬇️

📊 BM25 全文搜索

🧠 向量语义搜索

💡 HYDE 假设文档

⬇️

🔀 RRF 融合 (Reciprocal Rank Fusion)

⬇️

📋 Top 30 候选 → LLM Re-ranking

⬇️

✅ 位置感知融合 → 最终结果

这个架构体现了现代信息检索领域的三大前沿技术:

  1. 经典 IR 的精华:BM25 算法经过几十年验证,对精确匹配依然不可替代
  2. 深度学习的影响:向量表示捕捉语义相似性
  3. 大模型的推理能力:Re-ranking 做出最终的语义判断

🤖 三剑客模型#

QMD 使用三个轻量化的 GGUF 模型,全部运行在本地:

模型用途大小功能
embeddinggemma-300M向量嵌入~300MB将文本转换为向量
qwen3-reranker-0.6B重排序~640MBLLM 评分文档相关性
qmd-query-expansion-1.7B查询扩展~1.1GB生成查询变体

⚙️ 核心技术详解#

2.1 BM25 算法:经典永不过时#

BM25(Best Matching 25)是信息检索领域最经典的算法之一。QMD 通过 SQLite 的 FTS5 扩展实现了高效的 BM25 搜索。

BM25 核心公式

score(D,Q)=i=1nIDF(qi)f(qi,D)(k1+1)f(qi,D)+k1(1b+bDavgdl)\text{score}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot (1 - b + b \cdot \frac{|D|}{\text{avgdl}})}

其中关键参数:

  • ( k_1 ): 控制词频饱和度(通常 1.2-2.0)
  • ( b ): 控制文档长度归一化(通常 0.75)
  • IDF:逆文档频率,衡量词项的重要性
优势与局限

优势:对精确关键词匹配极其敏感,计算速度快,解释性强

局限:无法处理同义词,对拼写错误敏感,难以捕捉深层语义关系

2.2 向量语义搜索:理解意图#

向量搜索通过将文本转换为高维空间中的向量来捕捉语义信息。

嵌入格式化源码#

以下是 QMD 中 嵌入格式化 的核心实现,来自 src/llm.ts

// src/llm.ts - 嵌入格式化核心逻辑
/** 检测是否为 Qwen3-Embedding 模型 */
export function isQwen3EmbeddingModel(modelUri: string): boolean {
return /qwen.*embed/i.test(modelUri) || /embed.*qwen/i.test(modelUri);
}
/**
* 格式化查询文本用于嵌入
* - embeddinggemma 使用 nomic 风格的 task prefix
* - Qwen3-Embedding 使用 Instruct 格式
*/
export function formatQueryForEmbedding(query: string, modelUri?: string): string {
const uri = modelUri ?? resolveEmbedModel();
if (isQwen3EmbeddingModel(uri)) {
return `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`;
}
return `task: search result | query: ${query}`;
}
/**
* 格式化文档文本用于嵌入
* - embeddinggemma 使用 title + text 格式
* - Qwen3-Embedding 直接使用原始文本
*/
export function formatDocForEmbedding(text: string, title?: string, modelUri?: string): string {
const uri = modelUri ?? resolveEmbedModel();
if (isQwen3EmbeddingModel(uri)) {
return title ? `${title}\n${text}` : text;
}
return `title: ${title || "none"} | text: ${text}`;
}
设计巧思

通过不同的 prompt 前缀,让同一个模型既能处理查询又能处理文档,保持查询-文档向量空间的对齐性。这使得余弦相似度计算更加准确。

余弦相似度计算

similarity(A,B)=cos(θ)=ABAB\text{similarity}(A, B) = \cos(\theta) = \frac{A \cdot B}{\|A\| \|B\|}
// QMD 中的距离-分数转换
distance = 1 / (1 + cosine_similarity)
score = 1 / (1 + distance) // 范围:0.0 到 1.0

2.3 HYDE:假设文档嵌入#

💡 HYDE 核心理念

让模型先生成一篇可能回答查询的假设文档,然后用这篇文档的向量进行搜索。

关键洞察:文档-文档相似度 > 查询-文档相似度

🔄 HYDE 工作流程

用户: “如何部署应用?”
LLM 生成假设文档
编码为向量
搜索相似真实文档

三大优势

  1. 缩小语义差距 - 文档-文档相似度远高于查询-文档相似度
  2. 提供上下文 - 生成的假设内容可作为重排序的参考
  3. 提高召回率 - 找到更多相关内容,即使措辞不同

2.4 RRF 融合:兼顾多样性和准确性#

RRF 核心公式

RRF_score(d)=i=1n1k+ranki(d)\text{RRF\_score}(d) = \sum_{i=1}^{n} \frac{1}{k + \text{rank}_i(d)}

其中 ( k ) 通常是 60(经验值)。

RRF Top-Rank 奖励源码#

以下是 QMD 中 RRF Top-Rank 奖励机制 的实际实现,来自 src/store.ts

// src/store.ts - RRF 融合中的 Top-Rank 奖励机制
// Top-rank bonus: 在某个子查询中排名靠前的文档获得额外加分
for (const entry of scores.values()) {
if (entry.topRank === 0) {
entry.rrfScore += 0.05; // Rank 1: +0.05 bonus
} else if (entry.topRank <= 2) {
entry.rrfScore += 0.02; // Rank 2-3: +0.02 bonus
}
}
设计意图

原始查询加权 ×2:原始查询通常最能准确表达用户意图,展开变体可能偏离。

前几名奖励:确保在某个子查询中排名第一的文档不会被忽视,即使它在其他查询中表现一般。

2.5 LLM Re-ranking:位置感知融合#

经过 RRF 选出 Top 30 候选后,QMD 调用 qwen3-reranker 进行精细评分。

位置感知融合源码#

以下是 位置感知融合策略 的核心实现:

// src/store.ts - Position-Aware Blending
// 根据 RRF 排名自适应调整 RRF 与 Reranker 的融合比例
const blended = reranked.map(r => {
const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
let rrfWeight: number;
// 位置感知权重:排名越高,越信任传统搜索结果
if (rrfRank <= 3) rrfWeight = 0.75; // Top 3: 75% RRF / 25% reranker
else if (rrfRank <= 10) rrfWeight = 0.60; // 4-10: 60% RRF / 40% reranker
else rrfWeight = 0.40; // 11+: 40% RRF / 60% reranker
const rrfScore = 1 / rrfRank;
const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
// ...
});
排名区间RRF 权重Reranker 权重设计理由
Rank 1-375%25%保留精确匹配的权威结果
Rank 4-1060%40%平衡传统搜索与语义理解
Rank 11+40%60%更相信 LLM 的深度理解

数学表达

final_score(d)=wrrf1rank+wrerankrerank_score(d)\text{final\_score}(d) = w_{\text{rrf}} \cdot \frac{1}{\text{rank}} + w_{\text{rerank}} \cdot \text{rerank\_score}(d)

💾 数据存储与索引#

3.1 SQLite 数据库设计#

QMD 使用 SQLite 作为单一的数据存储后端:

-- 集合管理:索引目录与 glob 模式
CREATE TABLE collections(
name TEXT PRIMARY KEY,
pwd TEXT NOT NULL,
glob_pattern TEXT,
ignore_patterns TEXT,
update_command TEXT,
include_by_default BOOLEAN
);
-- 文档元数据:每个文档的唯一标识
CREATE TABLE documents(
docid TEXT PRIMARY KEY, -- 6 字符哈希 ID
collection TEXT,
path TEXT NOT NULL,
title TEXT,
content_hash TEXT, -- 用于检测变更
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- BM25 全文索引:FTS5 虚拟表
CREATE VIRTUAL TABLE documents_fts USING fts5(
content, -- 可搜索的文本内容
docid UNINDEXED
);
-- 向量存储:嵌入向量块
CREATE TABLE content_vectors(
hash_seq TEXT PRIMARY KEY, -- docid:chunk_index
hash TEXT, -- document docid
seq INTEGER, -- chunk 序列号
pos INTEGER, -- 字符偏移量
content TEXT -- chunk 内容
);

3.2 智能分块算法#

这是 QMD 最有创意的设计之一。文档被分割成约 900 个 token 的块,但绝不是简单地切割。

分块配置源码#

// src/store.ts - 智能分块核心配置
// Chunking: 900 tokens per chunk with 15% overlap
export const CHUNK_SIZE_TOKENS = 900;
export const CHUNK_OVERLAP_TOKENS = Math.floor(CHUNK_SIZE_TOKENS * 0.15); // 135 tokens
// Fallback char-based approximation (~4 chars per token)
export const CHUNK_SIZE_CHARS = CHUNK_SIZE_TOKENS * 4; // 3600 chars
export const CHUNK_OVERLAP_CHARS = CHUNK_OVERLAP_TOKENS * 4; // 540 chars
// Search window for finding optimal break points
export const CHUNK_WINDOW_TOKENS = 200;
export const CHUNK_WINDOW_CHARS = CHUNK_WINDOW_TOKENS * 4; // 800 chars

断点评分系统源码#

以下是 QMD 的断点检测模式定义,来自 src/store.ts

// src/store.ts - Break Point Pattern Definitions
// 为不同类型的 Markdown 元素分配断点分数,分数越高表示越适合在此切割
export const BREAK_PATTERNS: [RegExp, number, string][] = [
[/\n#{1}(?!#)/g, 100, 'h1'], // # 最高级别章节
[/\n#{2}(?!#)/g, 90, 'h2'], // ## 主要子章节
[/\n#{3}(?!#)/g, 80, 'h3'], // ### 等同代码块边界
[/\n```/g, 80, 'codeblock'], // 代码块边界(同 h3 优先级)
[/\n(?:---|\*\*\*|___)\s*\n/g, 60, 'hr'], // 分隔线
[/\n\n+/g, 20, 'blank'], // 段落边界
[/\n[-*]\s/g, 5, 'list'], // 列表项
[/\n\d+\.\s/g, 5, 'numlist'], // 有序列表
[/\n/g, 1, 'newline'], // 最小断点
];

距离衰减函数源码#

当接近目标切点时,QMD 在窗口内寻找最佳断点,使用平方距离衰减

// src/store.ts - findBestCutoff 核心算法
// 在窗口范围内寻找质量最高的切割点
export function findBestCutoff(
breakPoints: BreakPoint[],
targetCharPos: number,
windowChars: number = CHUNK_WINDOW_CHARS,
decayFactor: number = 0.7,
codeFences: CodeFenceRegion[] = []
): number {
const windowStart = targetCharPos - windowChars;
let bestScore = -1;
let bestPos = targetCharPos;
for (const bp of breakPoints) {
if (bp.pos < windowStart) continue;
if (bp.pos > targetCharPos) break; // sorted, can stop
// 跳过代码围栏内部的断点
if (isInsideCodeFence(bp.pos, codeFences)) continue;
const distance = targetCharPos - bp.pos;
// 平方距离衰减:近处温和,远处陡峭
const normalizedDist = distance / windowChars;
const multiplier = 1.0 - (normalizedDist * normalizedDist) * decayFactor;
const finalScore = bp.score * multiplier;
if (finalScore > bestScore) {
bestScore = finalScore;
bestPos = bp.pos;
}
}
return bestPos;
}
平方距离衰减的精妙之处
finalScore=baseScore×(1(distancewindow)2×0.7)\text{finalScore} = \text{baseScore} \times \left(1 - \left(\frac{\text{distance}}{\text{window}}\right)^2 \times 0.7\right)
  • 距离 25%: multiplier = 0.956(几乎无损)
  • 距离 50%: multiplier = 0.825(轻微衰减)
  • 距离 75%: multiplier = 0.606(明显衰减)
  • 距离 100%: multiplier = 0.300(大幅衰减)

效果:远处的 H2 标题(score=90, final≈58.8)仍然能胜过紧邻的空行(score=20, final≈19.9)!

核心分块算法源码#

// src/store.ts - chunkDocumentWithBreakPoints 完整实现
export function chunkDocumentWithBreakPoints(
content: string,
breakPoints: BreakPoint[],
codeFences: CodeFenceRegion[],
maxChars: number = CHUNK_SIZE_CHARS,
overlapChars: number = CHUNK_OVERLAP_CHARS,
windowChars: number = CHUNK_WINDOW_CHARS
): { text: string; pos: number }[] {
// 短文档直接返回
if (content.length <= maxChars) {
return [{ text: content, pos: 0 }];
}
const chunks: { text: string; pos: number }[] = [];
let charPos = 0;
while (charPos < content.length) {
const targetEndPos = Math.min(charPos + maxChars, content.length);
let endPos = targetEndPos;
// 在窗口内寻找最佳切割点
if (endPos < content.length) {
const bestCutoff = findBestCutoff(
breakPoints, targetEndPos, windowChars, 0.7, codeFences
);
if (bestCutoff > charPos && bestCutoff <= targetEndPos) {
endPos = bestCutoff;
}
}
if (endPos <= charPos) {
endPos = Math.min(charPos + maxChars, content.length);
}
chunks.push({ text: content.slice(charPos, endPos), pos: charPos });
if (endPos >= content.length) break;
// 15% 重叠确保跨块语义连续性
charPos = endPos - overlapChars;
const lastChunkPos = chunks.at(-1)!.pos;
if (charPos <= lastChunkPos) {
charPos = endPos;
}
}
return chunks;
}

AST 感知的代码分块#

🌳 AST 感知分块流程

源代码
Tree-sitter 解析
识别 AST 节点
合并正则断点
统一分块

AST 节点优先级

节点类型分数说明
Class / Interface / Trait / Impl100类级别边界
Function / Method90函数级别完整性
Type Alias / Enum80类型定义边界
Import / Use60导入语句分隔

这使得代码块保持函数级别的完整性,极大提升代码检索效果。

🔧 高级特性#

4.1 查询语法:结构化搜索的力量#

QMD 支持一种强大的结构化查询语法:

Terminal window
# 单一查询(自动展开)
qmd query "error handling best practices"
# 结构化查询文档
qmd query "
intent: 寻找关于异步错误处理的实践
lex: try-catch async await promise rejection
vec: how should we handle errors in async JavaScript functions
hyde: This guide covers error handling patterns including try-catch blocks...
"
类型方法适用场景
lexBM25精确术语、专有名词、代码符号
vec向量自然语言问题、概念性问题
hyde向量生成假设答案,提升语义匹配
intent辅助澄清歧义,引导重排序
核心建议

不要完全依赖自动查询展开。你应该亲自编写 intent: 字段,因为你拥有模型不具备的领域知识和上下文。

4.2 MCP 协议集成#

QMD 完整实现了 Model Context Protocol,可以与 Claude Desktop、Claude Code 等工具无缝集成:

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

暴露的工具query(执行搜索)、get(获取文档)、multi_get(批量获取)、status(健康检查)

⚡ 性能优势

支持长驻服务器模式,避免每次请求都重新加载模型:

Terminal window
# 启动 HTTP 服务器
qmd mcp --http --port 8080

📊 实战应用#

🚀 快速开始

安装 → 创建集合 → 嵌入 → 搜索

Terminal window
# 安装
npm install -g @tobilu/qmd
# 创建集合
qmd collection add ~/notes --name personal-notes
# 添加上下文
qmd context add qmd://personal-notes "个人学习笔记和技术文档"
# 生成向量嵌入
qmd embed
# 开始搜索
qmd query "机器学习算法"
💻 性能调优

内存控制(针对大型语料库):

Terminal window
qmd embed --max-docs-per-batch 50
qmd embed --max-batch-mb 64

GPU 加速

Terminal window
export QMD_LLAMA_GPU=cuda # NVIDIA CUDA
export QMD_LLAMA_GPU=vulkan # 跨平台 Vulkan
export QMD_LLAMA_GPU=metal # macOS Apple Silicon

🔬 技术对比分析#

vs 纯向量数据库#

特性QMDPure Vector DB
混合搜索✅ BM25 + 向量❌ 仅向量
精确匹配✅ 优秀⚠️ 较差
可扩展性⚠️ 单机为主✅ 分布式
成本💰 开源免费💳 云服务收费

评分解读#

QMD 的最终分数范围是 0.0 到 1.0:

分数范围相关性标记
0.8 - 1.0高度相关🟢
0.5 - 0.8中度相关🟡
0.2 - 0.5一般相关
0.0 - 0.2低相关

使用 --explain 可查看每个结果的详细评分分解。

🚀 未来展望#

QMD 代表了个人化 AI 应用的一个趋势:本地化、隐私友好、高性能。可能的演进方向:

  1. 增量索引 - 全量 → 实时增量更新
  2. 分布式索引 - 单设备 → 多设备协同
  3. 多语言模型 - 引入更多轻量化多语言嵌入模型
  4. GUI 界面 - 纯命令行 → 可视化前端
  5. 插件系统 - 自定义分块策略、重排序器

📚 参考文献
  1. Robertson, S., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond.
  2. Lin, H. et al. (2023). HyDE: Hypothetical Document Embeddings.
  3. Karpukhin, V. et al. (2020). Dense Passage Retrieval for Open-Domain Question Answering.
  4. Nogueira, R. et al. (2022). Document Chunking for Enhanced Retrieval-Augmented Generation.

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

QMD 深度解析:构建本地化混合搜索引擎的完整指南
https://rushzb-blog.pages.dev/posts/qmd/
作者
rushzb
发布于
2026-07-24
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
rushzb
初级 Vibe Coder
公告
欢迎浏览我的学习报告~
分类
标签
最新动态
站点统计
文章
6
动态
1
分类
1
标签
25
总字数
56,556
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.14.2
文章许可
CC BY-NC-SA 4.0