ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Tolaria 关键词搜索架构:移除 QMD 语义索引,用 walkdir 实现零依赖的 Keyword-only Search

Tolaria 关键词搜索架构:移除 QMD 语义索引,用 walkdir 实现零依赖的 Keyword-only Search Tolaria 关键词搜索架构移除 QMD 语义索引用 walkdir 实现零依赖的 Keyword-only Search【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria导读Tolaria基于 Tauri React Rust 的 Markdown 知识库桌面应用在早期版本中曾通过 QMDGo 二进制实现语义向量索引搜索但最终在 ADR-0009 中做出明确决策彻底移除语义索引只保留基于关键字的搜索。本文以 docs/adr/0009-keyword-only-search.md 为骨架结合 Rust 后端、React 前端与 MCP 服务器的实际源码完整讲解这一决策的背景、备选方案、search_vault的完整实现链路walkdir 扫描 → 标题/内容匹配 → 相关性打分 → 摘要提取以及 AI Agent 通过 MCPsearch_notes工具承担的探索性查询替代路径。读完你将掌握 Tolaria 搜索功能从命令行到 UI 的完整数据流并理解零依赖、即时结果搜索在大型笔记库中的适用边界。背景QMD 语义索引带来的运维负担在 ADR-0009 之前的版本中Tolaria 使用QMD一个 Go 二进制进行语义向量索引以实现基于相似度的搜索。按照 ADR 的上下文描述这套方案引入了显著的复杂度需要打包一个 Go 二进制文件并且要经过代码签名code-signing流程Vault 打开时需要执行一次索引步骤indexing step on vault open带来启动延迟界面需要状态栏进度跟踪status bar progress tracking来反映索引进度需要自动安装逻辑auto-install logic来部署 QMD 二进制项目中还维护着一个独立的tools/qmd/目录。也就是说语义搜索只是锦上添花的能力却拖累了一条完整的分发链路。ADR 中给出的关键判断是语义搜索的质量并不足以证明其运维成本是合理的The semantic search quality did not justify the operational burden尤其是当 AI Agent 通过 MCP vault 工具如search_notes、get_vault_context成为更自然的探索式查询入口之后应用内置的语义搜索就显得更加冗余。从当前仓库看qmd字样仅残留在设计稿 design/search-bundle-qmd.pen 与本文档中源码里已无任何 QMD 实现残留印证了该二进制已被彻底清除。决策Keyword-only Search walkdir 全量扫描ADR-0009 的决策非常明确Remove QMD semantic indexing entirely and keep only keyword-based search. Search useswalkdirto scan all.mdfiles, matching against titles and content with case-insensitive substring matching and relevance scoring.中文直译即彻底移除 QMD 语义索引只保留基于关键字的搜索。搜索使用walkdir扫描所有.md文件对标题和内容进行大小写不敏感的子串匹配并结合相关性打分排序。该决策的落点可以在src-tauri/Cargo.toml中得到印证项目仅引入walkdir 2这一个轻量依赖src-tauri/Cargo.toml没有引入任何向量数据库、嵌入模型或外部搜索二进制。备选方案对比为什么选择零依赖扫描ADR-0009 记录了三个备选方案其取舍逻辑清晰体现了用简单方案解决 80% 需求的工程哲学方案内容优点缺点Option A选定Keyword-only search viawalkdir零依赖、无索引步骤、结果即时返回、无需签名/打包二进制无模糊匹配与语义匹配能力Option B保留 QMD 语义搜索搜索结果更丰富支持相似度匹配需打包 Go 二进制、代码签名、索引延迟、维护负担大Option C用 Rust 原生 embedding 库替换 QMD不再依赖外部二进制模型文件体积大、冷启动时间长、仍然需要索引最终选择 Option A 的理由集中在运维复杂度与收益的不匹配上对于一个以文件为事实来源filesystem source of truth的 Markdown 知识库应用全量扫描.md文件的开销完全在可接受范围内而省去二进制签名、自动安装、索引状态跟踪等一整套基础设施收益是立竿见影的。源码级实现search_vault 的完整链路关键词搜索的核心实现在 src-tauri/src/search.rs通过 Tauri 命令search_vault暴露给前端。整条链路分四步扫描 → 过滤 → 匹配打分 → 摘要提取。1. Tauri 命令入口与异步边界命令入口位于 src-tauri/src/commands/vault/scan_cmds.rs#[tauri::command] pub async fn search_vault( vault_path: String, query: String, limit: Optionusize, exclude_frontmatter: Optionbool, ) - ResultSearchResponse, String { let vault_path expand_tilde(vault_path).into_owned(); let limit limit.unwrap_or(20); let exclude_frontmatter exclude_frontmatter.unwrap_or(false); tokio::task::spawn_blocking(move || { search::search_vault_with_options(search::SearchOptions { vault_path, query, mode: keyword.to_string(), limit, hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(), exclude_frontmatter, }) }) .await .map_err(|e| format!(Search task failed: {}, e))? }关键细节参数默认值limit缺省为 20exclude_frontmatter缺省为false阻塞任务隔离文件扫描是 CPU/IO 密集型操作通过tokio::task::spawn_blocking放入阻塞线程池执行避免阻塞 Tauri 的事件循环gitignore 联动hide_gitignored_files直接读取应用设置crate::settings::hide_gitignored_files_enabled()与全局隐藏 gitignore 文件偏好保持一致模式固定mode字段被硬编码为keywordSearchResponse中的mode字段src-tauri/src/search.rs主要供前端/测试断言使用。对应地src-tauri/src/search.rs中的单元测试search_vault_command_uses_default_limit_and_returns_results与search_vault_command_honors_explicit_limit位于 src-tauri/src/commands/vault/scan_cmds.rs验证了默认 limit 生效与显式 limit 截断两个行为。2. walkdir 扫描与文件过滤扫描逻辑在collect_markdown_pathssrc-tauri/src/search.rsfn collect_markdown_paths(vault_dir: Path, hide_gitignored_files: bool) - VecPathBuf { let paths WalkDir::new(vault_dir) .into_iter() .filter_map(|entry| entry.ok()) .map(|entry| entry.into_path()) .filter(|path| is_markdown_search_candidate(vault_dir, path)) .collect::Vec_(); crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files) }is_markdown_search_candidatesrc-tauri/src/search.rs定义了参与搜索的文件标准扩展名必须是.md相对 Vault 根目录的路径中任何一层组件都不能以点开头即跳过.git、.obsidian等隐藏目录。随后filter_gitignored_paths定义于 src-tauri/src/vault/ignored.rs根据设置决定是否过滤 gitignore 命中的文件。测试test_search_vault_hides_gitignored_notes_when_enabledsrc-tauri/src/search.rs用一个临时的 git 仓库验证当hide_gitignored_files true时ignored/hidden.md不会出现在结果中关闭该选项时两个文件都会命中。3. 标题提取与大小写不敏感匹配对每个候选文件SearchContext::result_for_pathsrc-tauri/src/search.rs负责判定是否命中let content std::fs::read_to_string(path).ok()?; let searchable searchable_content(content, self.exclude_frontmatter); let content_lower searchable.to_lowercase(); let filename path.file_name()...; let title crate::vault::derive_markdown_title_from_content(content, filename); let title_lower title.to_lowercase(); if !title_lower.contains(self.query_lower) !content_lower.contains(self.query_lower) { return None; }要点标题来源derive_markdown_title_from_content定义于 src-tauri/src/vault/mod.rs从 Markdown 内容中推导标题优先取 H1符合项目以 H1 作为标题唯一来源的设计参见 docs/adr/0044-h1-as-title-primary-source.md文件名仅作回退。测试test_search_vault_uses_h1_for_result_titlesrc-tauri/src/search.rs验证即使文件名是legacy-name.md只要内容里写了# Updated Display Title结果标题就是 H1匹配语义查询与标题、内容都被to_lowercase()归一化后做contains子串判断即大小写不敏感的子串匹配与 ADR 描述完全一致但这意味着它不支持模糊匹配、分词/词干化或语义近义匹配Frontmatter 可选排除exclude_frontmatter为true时strip_frontmattersrc-tauri/src/search.rs会把---包裹的 frontmatter 区块从内容中剥离避免 frontmatter 中的内部属性值污染内容匹配。对应测试test_search_vault_can_exclude_frontmatter_from_content_matchessrc-tauri/src/search.rs。4. 相关性打分算法命中后的排序依据是MatchScoreRequest::scoresrc-tauri/src/search.rs一个非常朴素但有效的加权模型let title_exact self.title_lower.contains(self.query_lower); let title_word self.title_lower .split_whitespace() .any(|word| word self.query_lower); let content_count self.content_lower.matches(self.query_lower).count(); let mut score 0.0; if title_word { score 10.0; } else if title_exact { score 5.0; } score (content_count as f64).min(20.0) * 0.5;打分规则可以概括为标题单词完全命中10 分最高优先级对应 ADR 中 exact title matches rank highest标题子串命中5 分正文命中按命中次数加分content_count封顶 20 次每次计 0.5 分即正文部分最多贡献 10 分。该设计与 ADR 后果一节中Title matches rank higher than content-only matches; exact title matches rank highest的表述完全吻合。测试test_score_match_title_word与test_score_match_content_onlysrc-tauri/src/search.rs分别断言了标题命中 ≥ 10 分、纯正文命中位于 010 分区间。最终排序在search_vault_with_optionssrc-tauri/src/search.rs中完成按score降序排列随后results.truncate(options.limit)截断并记录elapsed_ms毫秒级耗时随响应返回供前端展示。5. 摘要提取与 UTF-8 边界安全snippet由SnippetRequest::extractsrc-tauri/src/search.rs生成规则是找到内容中第一个命中位置向回找到行首或前 60 字符向前找到行尾或 120 字符超过 200 字符时截断并追加…。这里有一个值得注意的工程细节Utf8Boundary结构体src-tauri/src/search.rs专门处理多字节字符边界问题。由于 Rust 的字符串切片必须落在 UTF-8 字符边界上而下标计算基于to_lowercase()之后的字符串to_lowercase可能改变字符长度例如土耳其语İ小写化后字节数不同直接切片会产生 panic。Utf8Boundary::floor与lower_to_source负责把索引回退到合法边界。测试test_extract_snippet_multibyte_truncation、test_extract_snippet_maps_expanded_lowercase_to_source_boundarysrc-tauri/src/search.rs分别用韩文字符与土耳其语字符验证了摘要不会在非法边界截断。这也解释了为何测试中对摘要长度的断言是 203200 字符 3 字节的省略号。前端集成防抖搜索与笔记列表过滤搜索能力在 React 前端有两个消费方1搜索面板useUnifiedSearchsrc/hooks/useUnifiedSearch.ts封装了完整的搜索生命周期300ms 防抖DEBOUNCE_MS 300输入停顿后才发起请求避免每次击键都扫描磁盘搜索代数generation守卫通过searchGenRef递增计数器丢弃过期响应防止快速输入时旧结果覆盖新结果支持多 Vault 并发搜索vaultPath可以是字符串数组Promise.all并行发起各 Vault 的查询后合并结果再截断到 20 条通过searchCall抽象src/hooks/useUnifiedSearch.ts在 Tauri 环境调用invoke(search_vault, ...)非 Tauri 环境浏览器/测试走mockInvoke结果过滤时剔除noteType Config的条目并按 path 去重监听GITIGNORED_VISIBILITY_CHANGED_EVENT用户切换 gitignore 可见性后自动重跑搜索保证结果与当前可见性设置一致。2笔记列表全文本搜索useNoteListFullTextSearchsrc/components/note-list/noteListFullTextSearch.ts在列表过滤场景下调用同一个search_vault命令但显式传入excludeFrontmatter: truesrc/components/note-list/noteListFullTextSearch.ts只返回命中的路径集合用于在列表视图中高亮/过滤匹配笔记其 limit 按Math.max(entries.length * 2, 50)动态放大确保大列表中不至于因默认 limit 漏掉结果。AI 时代的替代路径MCP search_notes 工具ADR-0009 明确指出语义/探索式查询的能力由 AI Agent 接管。在 MCP 服务器 mcp-server/index.js 中注册了search_notes工具mcp-server/index.js{ name: search_notes, description: Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets., annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: object, properties: { query: { type: string, description: Search query string }, limit: { type: number, description: Maximum number of results (default: 10) }, }, required: [query], }, }该工具默认返回 10 条结果被标记为只读工具。WS 桥接层 mcp-server/ws-bridge.js 也映射了同名工具[search_notes, searchNotesTool]保证桌面端与桥接两种接入方式行为一致。search_notes与get_vault_context、list_vaults等只读工具共同构成 Agent 的 Vault 探索能力Agent 可以先search_notes定位相关笔记再读取具体文件从而完成语义级的联想检索——这正是 ADR 所说的AI agent (with MCP vault tools) became a more natural way to do exploratory queries。后果评估与再触发条件ADR-0009 记录的决策后果已在源码中得到验证无外部搜索二进制无需打包、签名或安装 QMDVault 打开零索引步骤搜索即时可用search_vault直接扫描文件系统阻塞任务隔离搜索在 Tokiospawn_blocking任务中运行不阻塞 UI 线程标题优先排序标题单词命中 标题子串命中 正文命中次数加权AI 兜底MCPsearch_notes等工具承担探索性/语义查询。ADR 同时给出了再评估触发器re-evaluation trigger如果用户报告在大型 Vault9000 条笔记上关键词搜索不够用就需要重新评估方案。这是本文唯一一条基于 ADR 原文的规模参考值实际性能应以自身 Vault 规模实测为准——从elapsed_ms字段的设计看Tolaria 已经为这种观测预留了通道每次搜索响应都携带耗时前端可据此评估是否需要升级方案。小结Tolaria 的 Keyword-only Search 是一个减法设计的典型案例用 walkdir 全量扫描 大小写不敏感子串匹配 简单加权打分换取零依赖、免签名、免索引的即时搜索体验并把语义查询的职责明确让渡给 AI Agent。理解 docs/adr/0009-keyword-only-search.md 这篇 ADR就能同时把握 Tolaria 搜索功能的行为契约匹配规则、打分权重、limit 默认值与实现边界无模糊匹配、无索引、gitignore 联动、UTF-8 安全摘要并能在自己的项目中复用这套简单方案 明确触发条件的决策模式。【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表