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 换成 Tantivy(Rust 写的全文索引库)或者 Meilisearch。但原型阶段,SQLite 是最快能跑起来的。
4.2 索引构建与动态权重表设计
先建两张表:一张是 FTS5 的虚拟表存文档内容,一张是普通表存动态权重。
-- 文档索引表 CREATE VIRTUAL TABLE docs_fts USING fts5( title, body, path, tokenize='porter 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_k=20): terms = preprocess(query) # 词干化、扩展 candidates = fts_search(query, limit=100) # 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(key=lambda x: x[1], reverse=True) 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 表备份一份,出问题时可以快速回滚。我吃过这个亏,有一次权重跑偏了,花了整整一天才手动恢复,如果有快照,五分钟就能搞定。
最后分享一个小技巧:如果你不确定自进化机制是否适合你的场景,可以先做一个最简单的版本——只记录点击反馈,只调整文档级别的权重,不碰词项权重。这个版本实现成本极低,但效果往往已经很明显了。等验证有效后,再逐步引入词项级别的动态调整。