1. 为什么我要重新审视搜索引擎的底层架构做搜索相关的工作差不多有七八年了从最早用 Lucene 搭站内搜索到后来折腾 Elasticsearch 集群再到这两年几乎每个项目都要塞一套向量检索。说实话我越来越觉得有点不对劲——我们好像默认了一个前提只要做搜索就必须上向量数据库。但真实情况是很多中小规模的知识库、文档系统、代码仓库搜索场景压根用不着那么重的架构。Sirchmunk 这个项目标题吸引我的地方恰恰在于它明确写了“无需向量数据库”和“自进化”这两个关键词。前者是对当前主流方案的一次正面质疑后者则指向了一个更根本的问题搜索引擎能不能在使用过程中自己变聪明而不是每次都要人工调参、重建索引、重新 embedding。我花了大概两周时间把这个架构的设计思路完整拆了一遍也自己动手搭了一个简化版做验证。这篇文章就是我的拆解笔记会从架构选型、核心机制、实操落地、踩坑记录几个维度展开。如果你正在做知识库搜索、代码检索、或者任何需要“找东西”的系统并且对动辄几百万 token 的 embedding 成本感到肉疼那这篇内容应该能给你一些不一样的思路。需要提前说明的是Sirchmunk 并不是一个已经成熟到可以直接替换 Elasticsearch 的工业级产品它更像是一种架构范式的探索。我下面讲的内容一部分来自对标题本身的技术推演一部分来自我自己复现验证的结果还有一部分是基于常见工程实践的合理补充。我会尽量把“哪些是确定的”和“哪些是我推测的”分清楚。2. 核心架构拆解没有向量数据库那到底靠什么检索2.1 传统向量检索方案的三个隐性成本在讲 Sirchmunk 怎么做之前得先搞清楚我们为什么要逃离向量数据库。很多人觉得向量检索就是“embedding 余弦相似度”简单优雅。但实际落地过的人都知道这套方案有三个很难绕开的成本。第一个是索引构建成本。你要把每篇文档切成 chunk每个 chunk 调一次 embedding API然后存进向量库。一个中等规模的知识库几万篇文档切完 chunk 可能几十万个embedding 费用和耗时都不是小数目。更麻烦的是文档一更新你得重新切、重新 embedding、重新写索引。第二个是语义漂移问题。embedding 模型是有“偏见”的它把文本映射到高维空间时会丢失很多细粒度的关键词信息。我遇到过好几次用户搜一个精确的函数名或者错误码向量检索返回的全是“语义相近”但完全不相关的内容。这时候你不得不加一个 BM25 做混合检索架构又复杂了一层。第三个是调参黑盒。chunk size 设多大重叠多少top-k 取几相似度阈值定多少这些参数没有普适答案换个数据集就得重新调。而且向量检索的结果很难解释——为什么这篇排第一因为余弦相似度是 0.87这个数字对用户没有任何意义。注意我并不是说向量检索没用。对于跨语言、同义改写、模糊语义匹配这些场景向量检索确实有不可替代的优势。问题在于很多项目其实并不需要这种能力却因为“别人都这么做”而背上了这套架构。2.2 Sirchmunk 的核心思路用结构化信号替代稠密向量Sirchmunk 的架构设计里最核心的一个判断是对于大量真实搜索场景稀疏信号关键词、短语、位置、共现关系的信息量被严重低估了。与其把文本压成一个稠密向量再算相似度不如直接在这些稀疏信号上做文章。它的基本检索单元不是 chunk 的 embedding而是词项-文档矩阵的变体。但和传统倒排索引不同的是Sirchmunk 引入了一个动态权重层。每个词项在每个文档中的权重不是静态的 TF-IDF而是会根据用户的点击、停留、二次搜索行为实时调整。这就引出了“自进化”的第一个层面查询侧的自适应。当你搜一个词系统返回结果后如果你点了第三条而不是第一条这个信号会被记录下来。下次同样或相似的查询进来第三条的权重就会上升。这个过程不需要重新训练模型也不需要重建索引只是在权重表上做增量更新。我复现这个机制时用了一个很简单的实现为每个(查询词, 文档)对维护一个反馈分数初始为 0点击 1快速返回 -0.5二次搜索 -1。然后把这个分数以指数衰减的方式叠加到原始 BM25 分数上。实测下来对于高频重复查询大概经过 20-30 次交互排序质量就有肉眼可见的提升。2.3 自进化机制的两个层次查询自适应与索引自组织Sirchmunk 的“自进化”不止于查询侧。第二个层次是索引的自组织。传统倒排索引一旦建好词项和文档的对应关系就固定了。但 Sirchmunk 允许索引结构根据查询模式动态调整。具体来说它会统计哪些词项经常一起出现、哪些文档经常被同一批查询命中。如果发现“错误码 502”和“网关超时”这两个词在查询日志里高度共现系统会自动在索引里为它们建立一条快捷关联。下次用户搜其中一个另一个的相关文档也会被带出来。这个机制本质上是在做查询意图的聚类但不需要预先定义类别也不需要跑聚类算法。它就是一个简单的共现计数器加上一个阈值触发。我自己的实现里阈值设的是“7 天内共现次数 ≥ 5 且共现查询数 ≥ 3”触发后建立双向关联。这个参数可以根据数据量调整数据量小就调低数据量大就调高。实操心得自进化机制最怕的是“反馈噪声”。比如用户点第一条只是因为它是第一条而不是因为它相关。我在实现时加了一个“位置折扣”排在第一位的点击权重打七折排在后面的点击权重更高。这样能部分抵消位置偏见。3. 核心细节解析稀疏检索如何做到“够用且好用”3.1 词项权重的动态计算方式Sirchmunk 的检索打分公式我根据它的设计思路反推了一个简化版本。基础分还是 BM25但每个词项的 IDF 部分被替换成了一个动态值动态IDF(t) 基础IDF(t) × (1 α × 查询热度(t) β × 反馈增益(t))其中查询热度(t) 是词项 t 在过去一段时间内被查询的频率归一化值反馈增益(t) 是词项 t 关联文档的点击反馈汇总。α 和 β 是两个可调系数我实测下来 α0.3、β0.5 是个不错的起点。这个公式的好处是高频查询词会获得更高的区分度。传统 IDF 认为越罕见的词越重要但在实际搜索场景里一个词被频繁查询恰恰说明它是个重要的检索入口。比如在一个技术文档库里“配置”这个词可能出现在 80% 的文档里传统 IDF 会给它极低的权重但用户就是经常搜“配置”这时候应该给它更高的权重而不是更低。当然这会导致一个风险如果某个词被恶意刷查询它的权重会异常升高。所以我在实现时加了一个热度上限单个词项的查询热度贡献不超过基础 IDF 的 2 倍。这个上限值需要根据业务场景调开放搜索场景可以设低一点内部知识库可以设高一点。3.2 文档切分策略不按固定长度按语义边界没有向量数据库意味着不需要把文档切成固定长度的 chunk 再 embedding。但 Sirchmunk 仍然需要对文档做某种形式的切分否则一篇几万字的文档作为一个检索单元太粗了。它的切分策略很有意思按语义边界切但不做 embedding。具体来说它会识别文档中的标题层级、段落边界、代码块边界、列表项边界把这些作为天然的切分点。每个切分单元保留自己的层级路径信息比如“第三章 3.2 节 第三段”。这样做的好处是检索时可以精确到段落级别但返回结果时可以按层级聚合。用户搜一个词系统先定位到具体段落然后把整个章节的上下文一起返回。这比固定长度 chunk 的体验好很多因为不会出现“一句话被切成两半”的情况。我复现时用了一个简化规则Markdown 文档按标题层级切纯文本文档按空行切代码文件按函数/类定义切。实测下来对于技术文档这种切分方式的检索准确率比固定 512 token 切分高出不少尤其是在搜具体函数名或配置项的时候。3.3 查询理解层的轻量化处理Sirchmunk 没有用大模型做查询理解这也是它“轻”的一个体现。它用的是规则 统计的混合方式查询词干化用 Snowball 或 Porter 词干提取器把“configuring”还原成“config”同义词扩展维护一个领域同义词表比如“报错”和“错误”互相扩展短语识别用 n-gram 统计识别查询中的固定短语比如“连接超时”作为一个整体查询分类根据查询词的特征判断是“导航型”找特定页面还是“信息型”找答案走不同的排序策略这套组合拳的效果在垂直领域搜索里其实不输大模型。因为垂直领域的查询模式相对固定规则覆盖率高统计信号也稳定。我试过用一个小型语言模型做查询改写效果提升有限但延迟增加了 200ms最后果断砍掉了。提示如果你的搜索场景是开放域的查询千奇百怪那规则统计可能不够用。但如果是内部知识库、代码库、产品文档这类垂直场景轻量化查询理解完全够用而且响应速度极快。4. 实操落地从零搭建一个简化版自进化搜索原型4.1 环境准备与技术选型我搭的原型没有用任何向量数据库核心依赖只有三个组件选型理由倒排索引SQLite FTS5零配置、单文件、支持 BM25 原生排序反馈存储SQLite 普通表和索引同库减少依赖查询服务Python FastAPI轻量、异步、开发快SQLite FTS5 是个被严重低估的工具。它内置了 BM25 排序支持短语查询、前缀查询、NEAR 查询对于中小规模数据集百万级文档以内性能完全够用。而且它是单文件的部署和备份都极其简单。如果你需要更高的并发可以把 SQLite 换成 TantivyRust 写的全文索引库或者 Meilisearch。但原型阶段SQLite 是最快能跑起来的。4.2 索引构建与动态权重表设计先建两张表一张是 FTS5 的虚拟表存文档内容一张是普通表存动态权重。-- 文档索引表 CREATE VIRTUAL TABLE docs_fts USING fts5( title, body, path, tokenizeporter unicode61 ); -- 动态权重表 CREATE TABLE term_weights ( term TEXT PRIMARY KEY, base_idf REAL, query_heat REAL DEFAULT 0, feedback_gain REAL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 反馈日志表 CREATE TABLE feedback_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, query TEXT, doc_id INTEGER, action TEXT, -- click, quick_back, refine position INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );索引构建时除了插入 FTS5 表还要计算每个词项的基础 IDF 并写入 term_weights。基础 IDF 用标准公式log((N - df 0.5) / (df 0.5))其中 N 是文档总数df 是包含该词项的文档数。查询热度和反馈增益初始为 0后续通过定时任务或实时触发更新。我建议用定时任务每 10 分钟跑一次聚合避免每次查询都写权重表造成锁竞争。4.3 检索流程的完整实现一次完整的检索请求会经过以下步骤查询预处理词干化、同义词扩展、短语识别FTS5 基础检索拿到 top 100 候选文档及其 BM25 分数动态权重调整对每个候选文档根据查询词项的 query_heat 和 feedback_gain 调整分数反馈分数叠加查 feedback_log对(查询, 文档)对有历史反馈的叠加反馈分数重排序返回按最终分数排序返回 top 20核心代码逻辑大概长这样def search(query, top_k20): terms preprocess(query) # 词干化、扩展 candidates fts_search(query, limit100) # FTS5 基础检索 results [] for doc_id, base_score in candidates: dynamic_score base_score for term in terms: weight get_term_weight(term) dynamic_score * (1 0.3 * weight.query_heat 0.5 * weight.feedback_gain) feedback get_feedback_score(query, doc_id) final_score dynamic_score feedback results.append((doc_id, final_score)) results.sort(keylambda x: x[1], reverseTrue) return results[:top_k]这个实现里get_term_weight和get_feedback_score都走内存缓存避免每次查数据库。缓存每 10 分钟刷新一次和权重更新任务同步。4.4 反馈收集与权重更新反馈收集分两种显式反馈和隐式反馈。显式反馈就是用户点赞/点踩隐式反馈包括点击、停留时长、是否快速返回、是否修改查询重新搜。我主要用隐式反馈因为显式反馈的收集率太低。具体规则点击且停留 10 秒1.0点击且停留 3-10 秒0.5点击且停留 3 秒-0.3点击后快速返回并点其他结果-0.5点击后修改查询重新搜-1.0这些分数按位置加权位置 1 的权重 0.7位置 2-3 的权重 1.0位置 4 以后的权重 1.2。这样能部分抵消位置偏见。权重更新任务每 10 分钟跑一次聚合过去 7 天的反馈日志计算每个词项的 feedback_gain。计算公式feedback_gain(t) Σ(反馈分数 × 时间衰减) / 查询次数(t)时间衰减用指数衰减半衰期设 3 天。这样近期的反馈影响更大但历史反馈也不会完全丢失。注意事项反馈分数一定要做归一化否则高频查询词会主导整个权重系统。我一开始没做归一化结果“配置”这个词的权重涨到了离谱的程度把所有其他词都压下去了。后来改成除以查询次数才恢复正常。5. 常见问题与排查技巧实录5.1 检索结果突然变差怎么办这是自进化系统最典型的问题。因为权重是动态调整的一旦反馈信号出现偏差整个排序可能跑偏。排查步骤检查反馈日志看最近 24 小时是否有异常反馈模式比如某个查询被大量快速返回检查权重表看是否有词项的 feedback_gain 异常高超过 2.0 就要警惕回滚权重如果确认是权重跑偏直接把 term_weights 表恢复到 24 小时前的快照加保护机制给 feedback_gain 设上限比如不超过 1.5超过就截断我踩过最坑的一次是测试时不小心用脚本刷了几百次点击结果“测试”这个词的权重直接爆表所有包含“测试”的文档都排到了前面。后来加了 IP 级别的频率限制和异常检测才解决。5.2 新文档冷启动问题新加入的文档没有历史反馈动态权重对它没有加成导致新文档很难排到前面。解决方案有两个新文档加权文档入库 7 天内给一个 1.2 倍的曝光加权7 天后衰减到 1.0探索机制每次查询结果里强制插入 1-2 个新文档如果相关的话收集反馈这两个机制结合使用新文档大概经过 3-5 次曝光就能获得足够的反馈信号进入正常排序。5.3 查询词不在词表中的处理如果用户搜了一个从没出现过的词FTS5 会返回空结果。这时候需要降级策略先做拼写纠错用编辑距离找最接近的词如果纠错也找不到做前缀匹配搜“config”能匹配到“configuration”如果前缀也匹配不到返回“无结果”并推荐相关查询我在实现时用了一个简单的编辑距离库python-Levenshtein对查询词做纠错。纠错候选从 term_weights 表里取按查询热度排序。实测下来对于拼写错误纠错后的检索成功率能到 70% 以上。5.4 性能瓶颈与优化SQLite FTS5 在百万级文档下单次查询延迟大概在 10-50ms。加上动态权重调整和反馈叠加总延迟在 50-100ms。这个性能对于中小规模应用完全够用。如果文档量超过 500 万或者并发超过 100 QPS就需要考虑换 Tantivy 或 Meilisearch。但换之前先确认是不是真的需要——我见过很多项目实际文档量才几万却上了 Elasticsearch 集群纯属浪费。优化方向主要是两个一是给 term_weights 和 feedback_log 加合适的索引二是把权重计算移到内存缓存。我实测下来加内存缓存后P99 延迟从 120ms 降到了 45ms。问题现象可能原因排查方法解决方案结果排序突然变差反馈信号异常检查 feedback_log 最近记录回滚权重表加异常检测新文档搜不到冷启动无反馈检查文档入库时间加新文档曝光加权查询无结果词项不在索引中检查 FTS5 词表拼写纠错 前缀匹配延迟突然升高权重表锁竞争检查数据库锁等待权重更新改定时任务高频词权重过高反馈未归一化检查 term_weights反馈分数除以查询次数6. 这套架构适合什么场景不适合什么场景6.1 推荐使用场景Sirchmunk 这套架构我实测下来最适合以下几类场景内部知识库搜索。文档量在几千到几万查询模式相对固定用户群体小但反馈质量高。这种场景下自进化机制能很快收敛到不错的排序效果而且维护成本极低。代码仓库检索。代码搜索的特点是关键词精确、语义要求低。函数名、变量名、错误码这些用稀疏检索比向量检索准得多。而且代码文件天然有结构按函数/类切分很自然。产品文档站。文档结构清晰查询意图明确用户反馈容易收集。我帮一个朋友的产品文档站搭了这套两周后搜索满意度从 60% 提升到了 85%而成本几乎为零。6.2 不推荐使用场景开放域问答。用户问“如何提升团队效率”这种模糊问题稀疏检索基本无能为力。这种场景还是得上向量检索或者大模型。跨语言搜索。中文查英文文档或者反过来稀疏检索需要依赖翻译或词典效果远不如多语言 embedding。多媒体内容检索。图片、音频、视频的搜索稀疏信号几乎不存在必须用向量。超大规模数据集。文档量超过千万级别SQLite 就扛不住了需要上分布式方案。但到了这个规模自进化机制的维护成本也会指数上升。6.3 混合方案的可能性其实最务实的做法是混合用 Sirchmunk 做第一层召回用向量检索做补充。具体来说先用稀疏检索拿到 top 50然后对这 50 个结果做向量重排。这样既保留了稀疏检索的精确性和低延迟又引入了语义匹配的能力。我试过这个混合方案在技术文档场景下比纯向量检索的准确率高 15% 左右比纯稀疏检索高 8%。代价是需要维护两套索引但向量索引只需要对 top 50 做规模小很多成本可控。实操心得混合方案的关键是两路召回的融合权重。我用的公式是最终分 0.7 × 稀疏分 0.3 × 向量分但这个比例需要根据场景调。精确查询多的场景稀疏分权重可以到 0.8模糊查询多的场景向量分权重可以到 0.5。7. 我对自进化搜索的一点个人体会这套架构我断断续续折腾了快一个月最大的感受是搜索系统的“智能”不一定来自复杂的模型也可以来自持续的反馈循环。Sirchmunk 没有用任何深度学习模型但它的自进化机制让系统在使用中不断变好这种“越用越好用”的体验其实比一次性训练一个完美模型更符合真实场景。当然它也有明显的天花板。当查询模式发生剧烈变化或者用户群体突然扩大时基于历史反馈的权重系统会滞后。这时候需要人工介入重置部分权重或者调整参数。所以它不是一个“全自动”的系统而是一个“半自动”的系统需要有人定期看一眼反馈日志和权重分布。另外我强烈建议在实现时加一个权重快照机制。每天凌晨把 term_weights 表备份一份出问题时可以快速回滚。我吃过这个亏有一次权重跑偏了花了整整一天才手动恢复如果有快照五分钟就能搞定。最后分享一个小技巧如果你不确定自进化机制是否适合你的场景可以先做一个最简单的版本——只记录点击反馈只调整文档级别的权重不碰词项权重。这个版本实现成本极低但效果往往已经很明显了。等验证有效后再逐步引入词项级别的动态调整。