上个月我想找一份藏在旧项目里的设备参数表文件名早忘了只记得里面有一行“PID2841”。Windows 自带的文件搜索翻了三分钟没结果那一刻我就在想与其反复安装各种“文件内容搜索工具”不如自己用 C# 写一个功能直接对标 AnyTxt。于是我用一周下班时间从零搭出了一个能索引文本、PDF、Word 和图片 OCR 内容的本地搜索工具日常搜索基本在十几毫秒内出结果。这篇文章就是把这条完整链路拆开给你看文件怎么高效遍历、编码怎么识别不乱码、索引怎么建才能快、PDF 和图片里的字怎么抽出来、WPF 界面怎么做到输入不卡。如果你也是 C# 开发者想做一个真正有复用价值的效率工具或者你已经在用 AnyTxt、Everything 这类产品但想拥有一套自己可控的搜索能力这篇应该能给你省下一大半弯路。先说清楚我从零开始做不代表所有底层都要自己造轮子。相反能复用成熟库的时候就果断复用把精力留在最影响体验的地方这才是“从零做工具”的正确姿势。1. 拆解“媲美 AnyTxt”到底需要做哪些事1.1 表面功能背后的四个核心模块AnyTxt 用起来让人觉得“快、准、香”背后其实是四个模块在配合工作文件枚举与过滤递归遍历目录按扩展名白名单判断哪些文件值得处理同时忽略系统目录、.git、node_modules这类噪音目录。内容抽取把 txt、md、log、csv 里的文本直接读出来把 PDF、DOCX 里嵌着的文本层解析出来把图片交给 OCR 识别成文字。索引与检索如果每次都打开文件、逐字节搜索速度必然惨不忍睹。正确做法是先扫一遍文件把“路径 文本”加工成一个倒排索引搜索时直接在索引里捞结果。交互与增量更新搜索框要防抖、结果要高亮新文件生成或修改后还要被动感知并更新索引否则用户第二次搜就找不到新内容。这四个模块任何一个做不好整体体验都会崩。比如索引建得飞快但中文乱码或者枚举到某个无权限目录直接抛异常中断“文件名想不起来”这个痛点就依然没解决。1.2 开工前必须拍板的三件事动手写第一行代码之前有三件事必须先拿定主意否则后面会反复改结构。第一索引要不要持久化。只做临时工具内存里放一个ConcurrentDictionarystring, Liststring也能跑但作为长期工具每次启动全盘扫一遍太浪费时间。我的选择是直接落到 SQLite原因很简单不用自己设计磁盘序列化格式而且 SQLite 的 FTS5 全文索引能力正好是这类工具需要的。第二OCR 到底几时上。很多人迷恋 AnyTxt 就是因为它能搜图片里的文字。但 OCR 的复杂度远超想象模型选择、语言包、性能、预处理都会拖垮主流程。我的建议是第一版先砍掉把文本、PDF、Office 索引跑通之后再单独加一步“延迟 OCR”。第三全量索引和实时索引怎么配合。首次运行必须全量扫描一次之后靠FileSystemWatcher监听文件变更增量更新索引。这两个阶段要分开设计不能混在一起。决策点 推荐方案 理由 索引存储 SQLite FTS5 成熟可靠支持排序和片段高亮 OCR 接入 Windows.Media.Ocr 离线免费语言包随系统部署省心 首次/增量 全量建库 文件监听 复杂度可控体验达标拍完这三点后面写代码时的方向感会非常清晰。2. 第一道绊脚石文件遍历与编码识别2.1 遍历时要处理的不只是肉眼可见的目录很多人的第一版写的是Directory.GetFiles(path, *, SearchOption.AllDirectories)这行代码在目录结构简单时没问题但只要你机器上有一个无权限访问的子目录比如隐藏的回收站、别人的用户目录整个遍历就会抛UnauthorizedAccessException索引任务直接中断。我当时改成自递归遍历每个子目录都单独捕获异常public static IEnumerablestring SafeEnumerateFiles(string root, Funcstring, bool? shouldSkipDir null) { var pending new Stackstring(); pending.Push(root); while (pending.Count 0) { var dir pending.Pop(); string[] files; try { files Directory.GetFiles(dir); } catch (UnauthorizedAccessException) { continue; } catch (IOException) { continue; } foreach (var file in files) { yield return file; } string[] subDirs; try { subDirs Directory.GetDirectories(dir); } catch (UnauthorizedAccessException) { continue; } catch (IOException) { continue; } foreach (var sub in subDirs) { if (shouldSkipDir ! null shouldSkipDir(sub)) continue; pending.Push(sub); } } }这里有几个容易忽略的细节先拿文件、再拿子目录顺序不是随便写的。有些目录在枚举文件时没问题枚举子目录时才有权限问题分开 catch 才不会因为一个异常丢掉整个目录的文件。shouldSkipDir参数一定要有。索引C:\Windows、.git、node_modules这种目录纯属浪费时间还会让索引体积暴涨。遍历阶段不要File.ReadAllText同步处理。先把文件路径收集到队列后面用并行方式处理文本抽取这样遍历本身不会被 IO 拖得太慢。另一个实测经验首次全量索引时Windows Defender 会对新生成的文件做实时扫描导致大量文件被短暂锁定。这种情况一般重试一次就能解决不需要为此调整系统设置。2.2 编码判断不是只看 BOM 就完事C# 里读取文本最省心的写法是StreamReader但它默认使用 UTF-8。碰到 GB18030 编码的旧日志或导出文件读出来就是一堆乱码索引进去后搜什么都不中。这是“文件内容搜索工具”最容易翻车的地方。我的做法分成两步第一步读文件开头 4 个字节检查 BOM。这一步能精确识别 UTF-8 BOM、UTF-16 LE/BE、UTF-32 等带 BOM 的文件。第二步没有 BOM 时做一次轻量级的编码猜测先尝试按 UTF-8 严格解码字节序列如果发现非法 UTF-8 字节序再按当前系统 ANSI 代码页中文系统通常就是 GBK/GB18030解码。同时还要额外检查一种情况文件里大量出现\0字节说明可能是无 BOM 的 UTF-16用Encoding.Unicode再试一次。public static Encoding DetectEncoding(string filePath) { using var fs File.OpenRead(filePath); byte[] bom new byte[4]; int read fs.Read(bom, 0, 4); if (read 3 bom[0] 0xEF bom[1] 0xBB bom[2] 0xBF) return new UTF8Encoding(true); if (read 2 bom[0] 0xFF bom[1] 0xFE) return Encoding.Unicode; if (read 2 bom[0] 0xFE bom[1] 0xFF) return Encoding.BigEndianUnicode; fs.Position 0; byte[] sample new byte[Math.Min(4096, fs.Length)]; read fs.Read(sample, 0, sample.Length); if (sample.Take(read).Contains((byte)0)) return Encoding.Unicode; try { new UTF8Encoding(false, true).GetString(sample, 0, read); return new UTF8Encoding(false); } catch (DecoderFallbackException) { return Encoding.Default; } }注意这种启发式编码识别不是 100% 准确的。所以我还留了一个手动策略对某些扩展名比如用户明确知道是 GBK 的导出文件可以配置固定编码并跳过检测减少误判。检测本身有成本一旦确认编码最好把结果和文件路径缓存起来后面增量更新时直接复用。另外遇到单个超过 10MB 的文本文件File.ReadAllText会吃掉几百 MB 内存。索引阶段只需要保留最前面一段足够用于片段预览的文本就够了比如前 20 万字符。既不丢搜索上下文也不会因为一个大日志文件把内存顶爆。3. 索引方案的选择从自研倒排索引到 SQLite FTS53.1 自研索引的诱惑与增量更新陷阱写工具的人很容易对“自己实现一个倒排索引”上头。原理确实不复杂扫描每个文件的文本按空格或标点切成单词建立一个“词 → 文件ID列表”的映射。搜索时查这个词的映射表就能快速拿到文件列表。但真做起来增量更新就是一道坎。文件改了要找到旧的词条并删除再按新分词重新插入删除了文件要处理映射表里残留的条目索引要落盘得设计序列化格式读回来还得考虑版本兼容。再加上中文没有天然空格分词如果自己切词还要做 n-gram 或者调用分词库复杂度直接翻倍。我在这个项目里试过自研前 5000 个文件的索引然后果断放弃了。不是技术上做不到而是为了支撑“新增、删除、修改、中文分词、排序、高亮片段”这些常见需求自己造轮子的工作量已经超过了预期的 3 倍而且质量和稳定性远不如现成方案。最后我选择了 SQLite FTS5。它天然支持增量删改、支持 BM25 相关性排序、支持搜索片段截取而且通过Microsoft.Data.Sqlite官方库就能直接操作C# 生态集成非常顺。3.2 FTS5 怎么用才能扛住几十万文档建表语句很简洁CREATE VIRTUAL TABLE IF NOT EXISTS file_index USING fts5( path, content, tokenize unicode61 );插入和搜索的写法也很直接using var cmd conn.CreateCommand(); cmd.CommandText INSERT INTO file_index(path, content) VALUES ($path, $content) ON CONFLICT(path) DO UPDATE SET content excluded.content; ; cmd.Parameters.AddWithValue($path, filePath); cmd.Parameters.AddWithValue($content, extractedText); cmd.ExecuteNonQuery();搜索时想要高亮片段用 FTS5 的snippet()函数就行比自己手写截取靠谱得多SELECT path, snippet(file_index, 1, em, /em, ..., 12) AS snip FROM file_index WHERE file_index MATCH $query ORDER BY bm25(file_index);这里有两个很关键的细节一是bm25()返回的值是相关性评分值越小表示越相关。直接用ORDER BY bm25(file_index)就能让最匹配的文件排在最前面不需要自己写 TF-IDF 逻辑。二是用户输入的关键词里可能带引号、星号、括号这些字符在 FTS5 的 MATCH 语法里有特殊含义。不做转义的话用户输入一个或*就能让查询抛异常。我的做法是在组装搜索词之前先把这些特殊字符替换成空格或者对用户输入按字面量重新包装。3.3 中文分词的现实与妥协方案FTS5 的unicode61分词器对英文、数字的处理很到位对中文却是“一整块连续汉字算一个 token”。比如文档里写着“设备参数”你搜索“设备”FTS5 匹配不到因为它的分词结果是整个“设备参数”这个长 token。要让中文实现类似“任意子串都能命中”的体验最实用的方案是自建 n-gram 索引。具体做法建索引时把文本里的连续中文字符串切成 2-gram相邻两个字符的组合用空格拼起来之后再塞给 FTS5。搜索时对用户输入的中文部分也做同样的 2-gram 切分。public static string TokenizeChinese(string text) { var result new StringBuilder(); foreach (var seg in Regex.Split(text, ([\u4e00-\u9fff]))) { if (Regex.IsMatch(seg, ^[\u4e00-\u9fff]$)) { if (seg.Length 1) { result.Append(seg).Append( ); } else { for (int i 0; i seg.Length - 1; i) { result.Append(seg.Substring(i, 2)).Append( ); } } } else { result.Append(seg).Append( ); } } return result.ToString(); }这样“设备参数”存进索引时变成“设备 备参 参数”搜索“设备”时也会切成“设备”两边就能匹配上。代价是索引体积会变大一些但换来的是中文任意子串都能搜这个取舍在个人工具里非常值得。原文内容可以继续原样存一份用于界面显示高亮片段。FTS5 真正建索引的列只存 n-gram 化之后的文本两者分开不冲突。4. 多种格式的内容提取文本、PDF、Word 和图片 OCR4.1 纯文本之外要处理的是“格式外壳”理论上 txt、md、log、csv、json 都是文本统一走StreamReader加编码识别就能搞定。但实际文件系统里还有很多“伪装者”扩展名是.data、.ini、.cfg的文件可能是文本也可能是二进制反过来某些.txt文件里面装着压缩数据。所以我在读取前会加一个二进制检测取文件头 4KB如果包含多个\0且不符合常见编码规则就直接跳过不进索引。这比单纯看扩展名可靠得多。对大文件我用File.ReadLines逐行拼接只保留前面一段用于索引。这样即便遇到几个 GB 级别的日志内存也不会失控。4.2 PDF 和 Word 的提取策略PDF 是另一个大坑。很多人试图用正则从 PDF 二进制流里抠文本结果往往是抓到一堆字体名和乱码。市面上成熟的库很多我用的是PdfPigAPI 非常顺手using UglyToad.PdfPig; using var pdf PdfDocument.Open(filePath); var sb new StringBuilder(); foreach (var page in pdf.GetPages()) { sb.AppendLine(page.Text); }注意PdfPig只能抽取 PDF 里的文本层。如果 PDF 是扫描件本质是图片那就没有文本层可抽必须先过 OCR。这一点要在设计里预留一个“该文件可能仅含扫描图片”的判断否则用户会发现某些 PDF 怎么搜都搜不到内容。Word 的.docx本质是 zip 包文本藏在word/document.xml里。直接解压并去 XML 标签的土办法能用但容易漏段落、漏表格而且碰到命名空间前缀变化就会出问题。推荐用官方DocumentFormat.OpenXml包稳定省事。老式.doc二进制格式没有document.xml个人工具第一版可以直接跳过在界面上标注“旧版 .doc 不支持”。这不算偷懒而是合理的范围裁剪。4.3 OCR 接入离线优先别让主流程等它OCR 我试过 Tesseract 的 C# 封装体验一般语言包要单独下载有的封装在进程内内存还不释放跑几次之后明显变卡。后来换成了 Windows 10/11 自带的Windows.Media.Ocr完全离线、免费、识别中文效果也够用关键是接入代码非常少var ocrEngine OcrEngine.TryCreateFromUserProfileLanguages(); if (ocrEngine null) { // 说明系统没装简体中文语言包给出提示 return; } using var stream File.OpenRead(imagePath); var decoder await BitmapDecoder.CreateAsync(stream); var bitmap await decoder.GetSoftwareBitmapAsync(); var result await ocrEngine.RecognizeAsync(bitmap); string text string.Join(\n, result.Lines.Select(l l.Text));一个很实用的经验对图片先做 1.5 到 2 倍的临时放大再丢给 OCR小字号文字的识别率提升非常明显。放大操作本身有开销所以应该在后台任务线程池里跑不要阻塞 UI。更重要的一点是OCR 绝不能塞进全量索引的默认流程。一张图片的识别时间可能是几百毫秒到几秒扫到几百张图片时索引时间会变得不可接受。我的做法是把 OCR 设计成“延迟生成”先不 OCR等用户某个目录搜不到想要内容时主动触发一次“对该目录补充 OCR 索引”。这样首次建库能控制在合理时间用户又能在需要时获得图片搜索能力。5. WPF 搜索体验的细节延迟搜索、高亮与虚拟化5.1 搜索框的“防抖”实现WPF 界面最直观的交互就是文本框。但如果你直接在TextChanged里执行搜索用户输入“设备”两个字的过程会触发两次查询再加一个字母就再来一次索引查询和 UI 渲染根本扛不住。我的做法是引入 300ms 防抖用户停止输入 300ms 后才真正发起搜索。期间如果用户又打了字前一次搜索请求要被取消。private CancellationTokenSource? _searchCts; private async void OnSearchTextChanged(object sender, TextChangedEventArgs e) { _searchCts?.Cancel(); var cts new CancellationTokenSource(); _searchCts cts; try { await Task.Delay(300, cts.Token); } catch (TaskCanceledException) { return; } string query txtSearch.Text.Trim(); if (string.IsNullOrEmpty(query)) { listResults.ItemsSource null; return; } var rows await _searchService.SearchAsync(query, cts.Token); if (cts.Token.IsCancellationRequested) { return; } listResults.ItemsSource rows; }这个模式很简单但很值得抄。搜索服务内部收到取消信号时应该停止后续数据库查询并返回空结果避免用户看到的列表被旧请求覆盖。5.2 结果列表高亮与定位搜索结果里用户最关心的是“关键词命中的上下文”。FTS5 的snippet()已经帮我返回了带标记的片段但 WPF 的TextBlock默认会原样显示em标签。所以要把片段里的标记转成真正的Runpublic static void HighlightTextBlock(TextBlock tb, string snippet) { tb.Inlines.Clear(); var parts Regex.Split(snippet, (em|/em)); bool highlight false; foreach (var part in parts) { if (part em) { highlight true; continue; } if (part /em) { highlight false; continue; } if (highlight) { tb.Inlines.Add(new Run(part) { FontWeight FontWeights.Bold, Foreground Brushes.DarkOrange }); } else { tb.Inlines.Add(new Run(part)); } } }这里有个很容易踩的坑snippet()里返回的文本已经是 HTML 实体转义过的至少在部分编码场景下会出现amp;之类展示前要确认是否需要WebUtility.HtmlDecode。我遇到过文件名里带结果高亮片段显示成amp;。另外如果搜索结果条数很多用户双击某一条时最好能直接打开文件并定位到命中行。做法是把该行文本取出来用Process.Start打开文件再进一步实现“跳转到行”就得用notepad或 VS Code 的命令行参数这里不展开。5.3 上万结果不卡顿的虚拟化配置搜索结果一次性返回上千条、甚至上万条如果 WPF 的ListView没开启虚拟化界面会直接卡死。默认ListView的 ItemsPanel 是StackPanel它会让所有项全部渲染。要改成VirtualizingStackPanel并开启回收模式ListView ItemsSource{Binding Results} VirtualizingPanel.IsVirtualizingTrue VirtualizingPanel.VirtualizationModeRecycling ScrollViewer.CanContentScrollTrue ListView.ItemsPanel ItemsPanelTemplate VirtualizingStackPanel / /ItemsPanelTemplate /ListView.ItemsPanel /ListView还有一个隐蔽的性能杀手不要在ItemTemplate的绑定里直接读取文件大小、文件图标这类需要额外 IO 的属性。每渲染一行就做一次磁盘访问滚动起来交互会非常难受。文件图标这种东西要么后台线程预取并缓存要么第一版干脆不做。我很早之前在这个项目里就因为一行new FileInfo(path).Length的绑定让结果列表滚动像幻灯片一样后来删掉才恢复正常。6. 实测性能一万个文件的搜索数据与踩坑清单6.1 实测数据参考我在一台 i5-12400F、32GB 内存、NVMe SSD 的机器上做了两组测试给各位一个直观参考场景文件数首次建索引耗时单次搜索耗时纯文本为主的文档目录约 1.2 万约 40 秒5 ~ 15 ms增加 200 张扫描件 OCR约 1.2 万约 6 分半5 ~ 15 ms全磁盘首次全量索引约 18 万约 14 分钟10 ~ 25 ms搜索耗时指的是用户敲下回车后从数据库查询到结果渲染出来的整体时间。之所以能稳定在几十毫秒内是因为 FTS5 的 MATCH 查询直接走倒排索引而不是对每个文件重新打开扫描。如果换成 SQLite 的LIKE %关键词%5 万行表的一次全表扫差不多要 600 到 800ms用户连续输入几轮就把任务队列堆满了。建索引耗时主要花在文件读取和文本抽取上。并行度我设置为Environment.ProcessorCount - 1既能把 CPU 吃满又留一核给 UI 线程。6.2 FileSystemWatcher 增量更新的坑索引建好之后新文件、修改文件都要及时更新不然就会出现“明明文件里有关键词却搜不到”的诡异问题。FileSystemWatcher是最常见的选择但直接裸用会有几个坑第一个坑是高频事件丢消息。大目录复制文件时会瞬间产生大量变更加载事件默认 4KB 缓冲区很快被塞满后面的事件直接丢掉。解决办法是把InternalBufferSize调到 8192 或更大。第二个坑是事件触发时文件还没写完。复制一个大文件Created事件可能刚触发时文件还在被占用。我加了重试机制读到异常时等待 100ms 再试最多重试三次。第三个坑是“先删后插”。修改文件后如果不先删除 FTS5 表里该路径的旧记录就直接插入同一个路径会累积多条索引搜索结果里就会出现重复文件。我的代码固定是DELETE FROM file_index WHERE path $path; INSERT INTO file_index(path, content) VALUES ($path, $content);第四个坑是回调线程。FileSystemWatcher事件在后台线程触发里面不能直接操作 UI还要注意同一文件的多个变更事件可能并发触发需要串行化处理否则 SQLite 写库会锁冲突。我用Channel或ConcurrentQueue把变更事件排队再交给单一线程消费彻底避开并发问题。6.3 文件占用、超大文件和压缩包的处理方式索引过程中最频繁遇到的异常就是“文件正被另一进程使用”尤其引入了其他工具运行时会释放一些系统临时文件。我的原则是所有文件读取都要包一层异常捕获单文件失败只记录日志绝不能中断整个索引任务。超大文件前面提到过只取首部 20 万字符入索引。注意截断时的边界如果按Substring硬切可能切在 UTF-16 代理对的中间导致最后一个字符变成替换符。用StringBuilder构造时留意一下 Surrogate 边界或者在切完后用Regex去掉末尾不完整的字符即可。压缩包又是一个功能诱惑点。支持 zip、7z 内搜索很酷但会显著增加索引耗时而且压缩包里嵌套压缩包时复杂度会爆炸。我的第一版明确不做只保证压缩包文件本身不会被当成二进制乱扫进索引。界面留了扩展位后续真要加的话接SharpCompress库按“惰性提取 临时索引”的方式做。这些坑看起来很散但每一个都是实际跑索引时真实发生过的问题。我从第一版到现在踩得最多的不是索引原理反而是这些 IO 边界和文件系统行为。把这些处理成常识之后工具才算真的能从“自用脚本”变成“可靠软件”。最后分享一点个人体会。做这类工具真正让人产生成就感的不是“我也能模仿 AnyTxt”而是换电脑之后把 SQLite 数据库文件拷过去所有索引立即恢复搜索依旧毫秒级返回。这个数据文件就是自己几百次调试攒下来的资产。我的小建议是给程序加一个--rebuild启动参数用来强制重建索引排查索引脏数据时会非常方便。工具是你自己常年要用的越简单、越可控你就越愿意继续用它。