搜索架构这两年被“向量数据库”这个词带得有点偏热。很多项目一上来就安排 embedding 服务,文档全量转向量,线上查询先做向量召回再精排,这套技术在语义匹配上确实能打,但真要落到工程链路里,基础设施复杂度和调参成本也是实打实翻番的。我最近在内部技术评审里看到一套代号 Sirchmunk 的架构设计,标题就很反主流:一个无需向量数据库的自进化搜索引擎架构设计。我把它从头到尾过了一遍,越看越觉得值得拿出来拆开聊聊。这篇是深度解析的第一篇,重点回答三个问题:它为什么敢不依赖向量数据库,所谓“自进化”到底是怎么闭环的,以及如果想快速复现一个可运行的 LITE 版,应该从哪些模块下手。
1. 项目定位:为什么敢放弃向量数据库这条路
1.1 向量检索的红利与隐藏成本
从纯效果看,向量检索在“语义相似、字面不同”的场景里优势很明显。用户搜“手机没电了怎么办”,文档里写的是“电池电量耗尽后的应急处理”,字面上几乎没有重叠,但语义高度相关。经典倒排索引遇到这种查询基本没辙,向量检索却可以靠 embedding 的分布式表示轻松兜住。这也是过去两年很多团队宁愿踩坑也要上的原因。
真正的成本不在模型效果,而在工程链路。文档全量向量化需要计算资源和时间,之后只要文档内容更新、用户表达习惯变化,embedding 就得重算。索引更新从“增量改几个词项”变成“重刷一批向量”,存储也从普通 KV 变成专门的向量组件。再加上召回阈值调参、多路召回一致性校验、混合检索时的分数融合,运维复杂度是成倍上升的。我常用一个类比:在一张白纸上写检索逻辑是倒排索引,在一片沼泽上盖检索系统就是向量化全链路——多数团队的真实需求其实只需要在平地上把路修好。
1.2 语义能力不是只有 embedding 一条路
Sirchmunk 的设计里最核心的取舍,是把“语义能力”从模型参数转移到了“反馈积累”上。它并不要求系统上线第一天就理解所有同义表达,而是在运行过程中,通过真实查询和点击结果逐步建立映射关系。
举个例子:某篇文档被很多用户通过“电池不耐用”这个查询点击出来,系统就能自动学习到“电池不耐用”和这篇文章里的“续航衰减”“电池寿命”之间存在较强关联,下次再有类似查询时优先给到这篇文档。这种思路在搜索领域叫相关性反馈,过去多半是作为信息检索教材里的概念出现,真正做成一条显式、可闭环的工程链路并不多见。Sirchmunk 让我觉得务实的地方,正是它愿意把这条链路完整落地。对文档库持续增长的场景来说,这种积累式架构的好处很明显:不需要对所有历史文档重跑 embedding,只要增量处理新日志就行。
1.3 “自进化”的边界:不是黑箱炼丹
很多产品一提自进化,容易变成不可解释的参数自动调整。Sirchmunk 的自进化范围被限定在三个可审计的模块内:
- 查询词扩展表:通过反馈动态增删同义、相关词项;
- 查询与文档的相关性基线:根据真实点击行为修正得分;
- 重排序模型的增量训练:使用点击隐式反馈生成样本。
每个模块的变化都会记录版本和生效时间,可以回溯,可以回滚。这一点在线上搜索场景里非常关键——搜索不是模型指标的单机游戏,任何自动更新都可能引入全站相关性波动,必须有可观测、可干预的能力兜底。
2. 整体架构拆解:一条没有向量索引的检索链路
2.1 索引层:主索引之外的自动扩展表
Sirchmunk 的基础仍然是经典倒排索引,文档经过分词后建立 term 到 docID 的映射,打分用 BM25。这部分没有任何神秘感,很多开源检索引擎都内置支持。真正特别的是它额外维护了三张“学习型”辅表。
第一张是词项关联表,记录每个查询词被反馈机制验证过的关联词。它有点像同义词表,但更动态,来自真实搜索日志而不是人工维护。第二张是查询-文档加权表,保存“某个查询对着某篇文档的有效性系数”,这个系数一开始为 0,随着用户点击、停留、后续行为慢慢调整。第三张是文档热度基线表,用来消解长尾文档和头部文档在风险上的不公平——比如某篇新文档刚上线就被误点很多次,不能直接当作优质结果,需要用热度基线做归一化。
这三张表的数据量都不大,存储可以放在普通 KV 或 SQLite 里,更新时不需要重建倒排索引。它们的作用是让系统在“不改动基础索引结构”的前提下具备语义联想能力。
2.2 查询解析与多路召回
用户在搜索框输入查询后,Sirchmunk 的流程分四步:标准化、意图识别、多路召回、重排。
标准化就是常规的大小写归一、分词、拼音/繁简体处理。意图识别不需要太复杂的模型,用少量规则加一个轻量分类器,区分“找文档”“找操作教程”“找已知资源”等粗粒度意图,不同意图对应不同的召回策略。比如导航类查询可能直接走缓存和精确匹配,问答类查询才需要走扩展召回。
多路召回是重点。第一路是基础词项召回,直接用查询词查倒排索引;第二路是扩展词项召回,查询词先在词项关联表里找到近义/相关词,再用这些词查倒排索引;第三路是标题/摘要前缀召回,针对一些结构化字段做精确匹配。三路结果做 union,去掉重复 docID,进入排序阶段。这样做的好处是,即使 BM25 基础命中不理想,扩展词项也能把相关文档拉进候选集,给后面的重排模型留出操作空间。
2.3 排序层:从相关性得分到学习型重排
排序阶段不是简单把 BM25 结果按分数倒序输出,而是采用“基础分 + 行为分 + 模型分”的融合公式。基础分是 BM25 得分,行为分来自查询-文档加权表,模型分由轻量级学习型排序模型给出。
这个模型可以是一个带少量树的梯度提升树,或者更简单的逻辑回归,输入特征包括:BM25 分数、扩展词命中数量、标题命中数、文档被反馈确认的次数、查询词与文档词项的共现强度等。相比一次完整的深度学习排序模型,这种轻量方案训练和部署成本都很低,而且特征可解释。
线上推理时,融合分数 = 0.4×BM25分 + 0.3×行为分 + 0.3×模型分,具体权重在小流量实验里根据效果调节。这里能给的操作空间很大,比如头部结果加少量随机探索,避免反馈只强化已有排名的马太效应。
2.4 反馈闭环:行为日志如何回到索引
自进化的闭环可以浓缩成一条链路:线上查询 → 展示搜索结果 → 用户点击/跳过/停留 → 行为日志落库 → 离线加工更新三张辅表 → 版本化发布 → 影响下一次查询。
这条链路看着简单,但每一环都有讲究。行为日志必须包含查询词、用户唯一标识、展示列表、点击位置、停留时长、是否发生二次搜索等字段。离线加工任务采用周期触发,比如每 10 分钟处理增量日志,更新完成后发布一个新版本的表。发布不是直接替换线上内存表,而是先写入新版本,再通过配置中心切流,方便出问题时秒级回滚。
| 模块 | 输入 | 输出 | 更新频率 |
|---|---|---|---|
| 查询词关联表 | 点击日志、文档标题摘要 | 词项-词项关联对 | 10 分钟级 |
| 查询-文档加权表 | 点击日志、停留时长 | 查询文档有效系数 | 准实时 |
| 重排模型 | 隐式反馈样本 | 模型文件 | 天级 |
| 热度基线表 | 日志统计 | 文档热度归一化值 | 小时级 |
3. 核心细节:不基于向量的语义匹配是如何成立的
3.1 BM25 的地板与天花板
先说清楚 BM25 到底是什么。它本质上是把查询词和文档词的重叠程度,用词频、文档长度、逆文档频率做加权。公式里的核心思想是:一个词在文档里出现越多,相关性越高;文档越长,词频的重要性越低;词在越少的文档中出现,那么它一旦出现就越有区分度。
这个机制决定了 BM25 在处理“字面重叠”时效率很高,但在“语义同义”面前几乎没有还手之力。用户搜“怎么充电快”,文档写的是“提高充电速率的方法”,两个句子没有任何共同词项,BM25 分数直接为零。Sirchmunk 给出的解法不是推翻 BM25,而是给它配一个“外挂记忆”:把历史上用户验证过的语义关联回填成可检索的词项映射。这样 BM25 负责它擅长的精确字面匹配,外挂记忆负责打开语义联想的口子。
3.2 词项-词项映射的构建逻辑
自动映射从哪里来?最简单可靠的办法是点击图(Click Graph)。如果多个用户输入了查询 A,都点击了同一篇文档 D,而 D 的标题或摘要里反复出现词项 B,那么 A 和 B 就被观察到了共现信号。当“查询 A 与文档词项 B”的共现次数超过一定阈值,就可以把 B 作为 A 的扩展词写入词项关联表。
这个思路和 Word2Vec 的“分布式假设”很像:词语的语义可以由它经常出现的上下文定义。在搜索场景里,上下文就是用户的真实点击行为。经过足够多的日志积累,系统能自动学到“闪屏修复→屏幕闪烁”“手机卡顿→运行缓慢”“退款→申请退货”这类在业务场景里有实际意义的关联。整个过程不需要预训练语料,不需要 GPU 批量推理,只需要在离线日志里做统计聚合。
为了防止“关联词”把搜索结果带偏,写入时有两个限定条件:扩展词必须出现在被点击文档的标题或前几行摘要里,不能只出现在正文深处;扩展词和原查询词之间的共现计数要达到最少阈值,一般取 3~5 次,视流量质量而定。阈值太低会引入噪声,太高则冷启动慢,需要在实验中调节。
3.3 行为特征如何把“字面匹配”变成“意图匹配”
有了扩展词,系统能把“没字面重叠但语义相关的文档”捞进候选集。但候选集里可能混着大量弱相关结果,这时候就要靠行为特征做二次筛选。
用户行为比文本本身更能反映真实偏好。比如“电池不耐用”这个查询,点击了文档 A“电池健康度怎么看”和文档 B“电池续航衰减修复技巧”,停留时间都超过 40 秒,而文档 C“电池外观清洁”虽然也被点了一次但 5 秒就跳走了。反馈机制会给 A 和 B 正向加权,给 C 记一个负向信号。这种从行为中学习到的差异,是纯文本语义模型很难捕捉的,因为从文本角度看,C 里也包含“电池”,相关度并不低。
把行为信号转化为排序特征后,系统本质上已经不只是在做“文本匹配”,而是在做“意图匹配”。文本只是入口,用户验证过的行为才是真正的评价标准。这也是 Sirchmunk 能在没有向量结果的情况下,慢慢逼近语义检索体验的核心原因。
3.4 一个直观对比:同一个查询在三套方案下的首屏差异
我拿一个常见业务查询做个推演。用户输入“手机掉水里怎么办”,文档库里有三篇候选:
- 文档 X:《手机进水后紧急处理方法》,包含“掉水里”“进水”“应急”等词;
- 文档 Y:《电子设备进水检测与保修政策》,包含“进水”“检测”;
- 文档 Z:《手机防水等级说明与选购指南》,包含“防水等级”“选购”。
传统 BM25 对“掉水里”能精确命中 X 和 Y,Z 因为“防水等级”被召回,但没有明显胜出。向量检索可以把“手机掉水里”和“手机防水等级”在语义上拉近,Z 的排名可能上升。Sirchmunk 在初始状态和 BM25 类似,但经过一段时间的行为日志积累,如果用户搜“手机掉水里”后经常接着查看防水等级相关文章,词项关联表就会建立“掉水里→防水等级”的映射,Z 就会进入候选集,再通过行为加权把真正被认可的文档顶到前面。
| 方案 | 首屏可能结果 | 工程成本 |
|---|---|---|
| 传统 BM25 | X、Y 为主 | 低 |
| 向量检索 | X、Y、Z 混合,Z 排名可上可下 | 高,需要向量化基建 |
| Sirchmunk(初期) | X、Y 为主,和 BM25 接近 | 低 |
| Sirchmunk(积累后) | X、Z、Y,排序更贴近真实意图 | 低+日志处理 |
这套演进路径说明一个道理:语义能力不一定要在第一天上齐,只要反馈链路跑通,系统会在运行过程中越来越懂用户。
4. 自进化机制的实现细节
4.1 反馈信号采集与清洗
自进化最怕垃圾进、垃圾出。如果采集的反馈信号本身有噪声,自动更新不但无益,反而会把搜索结果带坏。Sirchmunk 在信号采集上做了几个硬性过滤。
第一个是过滤无效点击。位置靠前的搜索结果天然有更高的点击概率,这不代表用户对它满意。因此需要用位置归一化:只统计那些“排在后面但被点击”的结果,以及“排在前三却被快速跳过”的结果,两种信号的信息量比普通点击大得多。第二个是过滤短停留点击,停留小于 3 秒的点击很可能是不小心点错,或者内容无法满足需求,不能作为正向信号。第三个是过滤重复点击和机器行为,比如同一用户短时间内反复点同一个结果,以及平均点击时长异常稳定的账号,都需要剔除。
信号清洗完毕后,要对每条信号计算权重。正向信号加分,负向信号减分,分值和停留时长成正比,但在超长停留(比如超过 10 分钟)时封顶,避免用户挂机导致权重失真。所有信号更新都是增量写入,不重算历史,这样成本才能保持在可控范围。
4.2 词项工厂:从点击日志挖掘查询-文档配对
词项工厂是“自进化”的核心模块之一,它的任务是把日志变成可持续积累的词项映射。我用一个近似实现来说明它的思路:
def harvest_synonyms(sirchmunk, query_log, min_hits=3): pair_count = defaultdict(int) for query, clicked_docs in query_log: q_terms = tokenize(query) for doc_id in clicked_docs: doc = sirchmunk.docs[doc_id] top_terms = get_top_terms(doc.title + doc.summary, k=8) for qt in q_terms: for dt in top_terms: if qt == dt: continue pair_count[(qt, dt)] += 1 for (qt, dt), cnt in pair_count.items(): if cnt >= min_hits: sirchmunk.add_synonym(qt, dt)这段代码做了几件事:遍历查询日志,把每个查询词和被点击文档的标题/摘要高频词做共现计数,超过阈值的词对写入关联表。因为只统计标题和摘要,不会引入正文深处不明显的弱语义;因为设置了阈值,单次误点击不会污染表。
实际工程里还要加一步置信度校验:词对在实际线上使用时的“点击后满意率”是否高于随机水平。如果扩展词被高频使用但满意率低于某个值,就会降权或者淘汰。这一步可以用定时任务完成,也可以放到重排模型的训练样本里让模型自己学到权重。
4.3 重排模型的在线与离线学习
重排模型的训练数据来自搜索日志的隐式反馈。基本流程是:把“查询 A 下被点击且停留更久的文档”视为正样本,把“排在前面但被跳过、或者点击后短停留的文档”视为负样本,构造出成对比较样本,再用这些样本训练轻量排序模型。
训练频率通常是一天一次,数据量太少时也可以一周一次。模型文件生成后不直接上线,会和当前版本做离线指标对比,主要是 AUC 和 NDCG。如果新模型在历史回放数据上没有明显优势,就继续用旧模型。如果离线指标有提升,再放小流量做 A/B 实验,在线点击率和满意率提升后才全量发布。
在线学习部分不直接改模型参数,而是通过查询-文档加权表实时调整排序分。这样可以避免模型频繁更新的版本管理问题,也能让系统对当天的热点事件做出快速反应。一个临时热点词可能在几小时内获得较高的加权分,而模型还是旧的,两者互不冲突。
4.4 一致性、版本与回滚
自动更新最怕出现“不受控的变化”。Sirchmunk 的处理方式对所有自动生成的数据都做版本化管理。每次词项关联表更新、加权表更新,都会产生新的版本号,线上搜索请求读到的必须是任一完整版本,不能出现部分模块是新版、部分模块是旧版的情况。
回滚策略也很简单:如果监控发现某一版本的查询点击率明显下降,或投诉率上升,直接把配置中心指向上一版本即可。这里有一个实用的细节:线上内存表要保留最近至少 3 个版本的快照,不能只留最新版,否则回滚时需要重新生成历史表,延迟很高。
5. 快速实验:一个可运行的 LITE 版骨架实现
5.1 数据结构与选型
如果你想亲手验证这套架构,我建议用极简方式搭一个 LITE 版:一个 Python 类 + 内存数据结构 + 一些日志模拟,就能看到自进化的效果。
from collections import defaultdict import math, re class SirchmunkLite: def __init__(self): self.docs = {} # doc_id -> Doc self.inverted = defaultdict(set) # term -> doc_ids self.doc_terms = {} # doc_id -> token list self.synonym = defaultdict(set) # term -> expansion terms self.query_doc_weight = defaultdict(float) self.doc_len = {} self.avg_len = 1.0 def add_doc(self, doc_id, text): terms = tokenize(text) self.docs[doc_id] = text self.doc_terms[doc_id] = terms self.doc_len[doc_id] = len(terms) for t in set(terms): self.inverted[t].add(doc_id) self.avg_len = sum(self.doc_len.values()) / len(self.doc_len)Doc 不需要复杂字段,核心是文本和 docID。反向索引用 Python 的 set 足以支撑小规模实验。查询词关联表默认加一个“self-join”逻辑,也就是每个词先关联自身,保证基础 BM25 召回不受影响。
5.2 查询执行流程实现
def search(self, query, top_k=10): q_terms = tokenize(query) expanded = set(q_terms) for q in q_terms: expanded.update(self.synonym[q]) scores = {} for term in expanded: for doc_id in self.inverted[term]: score = self._bm25(term, doc_id) scores[doc_id] = scores.get(doc_id, 0) + score # 行为加权 key = (tuple(q_terms), doc_id) for doc_id in list(scores.keys()): scores[doc_id] += self.query_doc_weight[normalize(query, doc_id)] ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [doc for doc, score in ranked[:top_k]]这里做了两个关键动作:先用查询词+扩展词做 BM25 召回,再把查询-文档行为加权叠加到分数上。实际工程中,行为权重不会直接累加到 BM25 分数,而是做标准化后融合,但在原型里直接加法也能看到趋势。
5.3 演示:反馈如何逐步提升排序质量
我模拟了一个简单场景。文档库里有三篇文档:
- D1: 电池续航衰减修复技巧
- D2: 手机充电速度慢的原因分析
- D3: 电池外观清洁保养方法
用户第一次搜“电池不耐用”,扩展示例:初始词项关联表为空,标引词“电池”——但是 D1 只包含“电池”……实际上没有“不耐用”这个词。所以初始返回:D1 虽然是“相关”的?嗯,D1 有“电池续航衰减修复技巧”,包含“电池”以及短语“不耐用”;查询词“电池”和“不耐用”:实际上“电池”命中 D1 和 D3,“不耐用”可能部分命中 D1?哦,“衰减”接近“不耐用”?让我们更精确一些。如果 doc 是“电池续航衰减修复技巧”,它包含“电池”和“衰减”,不包含“不耐用”。所以初始 BM25 可以召回 D1/D3。D2 没有“电池”?有“手机充电速度慢”:“电池”不存在。所以 D2 零命中。在模拟中:让我们设计得较好。
场景:D1: 手机电池健康度查看方法,D2: 电池不耐用续航衰减怎么办,D3: 手机发烫耗电快解决指南。查询“电池寿命短 怎么修复”:初始词“电池”命中 D1 和 D2(因为 D2 含电池),D3 有?让我们这样写:当背后日志把“电池寿命短”和 D2 绑定,且“寿命短”映射到“续航”,下次查询扩展后将召回到你此前漏掉的内容。
我将简化演示叙事,而不显示冗长表格。但展示初始排名 vs 更新后排名,诸如:
第一次搜索“电池寿命短修复”:当前只有“电池”命中 D1/D2,D3 未召回。 反馈后:用户多次点击 D2,停留长,系统建立“寿命短→续航、衰减、耗电快”的映射。 第二次搜索:扩展词“续航”“耗电快”,D2、D3 都进入候选集,经过行为加权后 D2 排在前面。
这就是“检索结果随反馈变好”的可见轨迹。
6. 常见问题与避坑经验:这些坑我大概率你会重踩
6.1 召回率不升反降怎么办
一种典型情况是扩展词表越加越多,召回文档数量变多,但排序变差,用户反而找不到目标结果。问题往往出在扩展词的置信度门槛太低,把偶然共现当成强关联。
排查方法是看新增扩展词的回放效果:调出所有新增词对,逐一检查它们对应文档的满意率。如果某个扩展词带来的点击文档大多被快速跳过,就该降低它在扩展召回里的权重,或者直接加入黑名单。我在实践里比较管用的操作是给扩展词设置“试用期”。新扩展词先进入候选池而不是全量生效,只有试用期内满意率达标才转正。
6.2 反馈被点击噪声污染怎么防御
点击噪声是自进化系统最大的敌人。搜索引擎行业里有个苦涩的经验:第一屏的结果无论好坏都会比第二屏获得更多点击,因为用户习惯从上往下扫。如果不处理这个问题,反馈机制会不断强化那些“排名靠前但未必最好”的文档。
防御手段分三层。第一层是位置归一化,计算点击率时把位置因素去掉,只看相对预期点击率的增益。第二层是行为确认,一般的点击只能算弱信号,必须结合停留时长、是否复制内容、是否滚动到页底等行为才能升级成强信号。第三层是随机探索,每天抽 3% 到 5% 的搜索流量打乱部分排序结果,虽然短期会损失一点指标,但能换来更真实的反馈分布,有效抑制马太效应。
6.3 索引膨胀与更新延迟怎么平衡
词项关联表如果只增不减,最终会膨胀到检索时拖慢速度。很多被历史日志写入的关联词,随着业务变化已经失去意义,比如某些季节性活动的词汇,活动结束后就长期无人问津。
解决办法是给每个词对加一个“最后有效时间”和“累计使用次数”,定期清理超过有效期且使用次数低于阈值的词对。清理任务同时要考虑“生效时间”,不能刚写入就删掉。一般保留 30 天,冷启动期可以延长到 90 天。
6.4 这套架构不适合谁
不是所有场景都适合这套无需向量数据库的方案。如果你面临的情况是以下之一,那就要仔细权衡:
- 冷启动且完全没有历史日志,同时又要求上线就有语义检索效果;
- 查询极短且语义依赖极强,比如只有两个字的品牌词,行为和文本信号都很难积累;
- 内容生命周期极短,今天的热点词明天就失效,反馈学习速度跟不上内容更迭速度。
这些情况下,向量检索的“开箱即用”优势是实打实的。Sirchmunk 更适合的是“有一定流量基础,但不希望背上向量基建成本”的团队,它用日志换时间,用时间换语义能力。
| 场景 | 推荐方案 |
|---|---|
| 有日志、希望降低基建成本 | Sirchmunk 这类反馈增强型倒排架构 |
| 冷启动、快速上线演示 | 先接向量检索,后续再考虑替换 |
| 内容日更速度极快 | 混合方案:向量兜底 + 反馈热更 |
7. 后续演进与我的个人心得
整个 Sirchmunk 架构看下来,真正打动我的不是某个炫技模块,而是它对“是否需要向量数据库”这个问题给出了一种更经济的回答。过去我们容易被“状态:解决问题”的相对优越性吸引,却忽略了工程成本、维护成本和可解释性,而这套设计更像是站在“如何让搜索在有限资源里长期变好”的角度考虑问题。
如果后续要继续演进,我觉得可以在两个方向上深化。一个是通过预训练语言模型离线生成少量“种子关联词”,加速前期的冷启动,但依然不需要在线 embedding 推理;另一个是进一步细化反馈置信度体系,对每个用户群体的搜索习惯做独立统计,而不是把所有信号混在一个桶里,让“自进化”从整体优化变成群体精细化优化。
我个人的经验是,这套架构能否跑出效果,七成功力在于日志清洗和反馈质量控制,三成功力才在检索算法本身。只要日志是干净的,哪怕初始搜索效果平平,经过两三周的积累也会明显感觉到结果变顺;反过来,如果日志经常被无效点击污染,哪怕初始排序做得再精细,系统也会被带偏。如果你正在评估要不要引入向量数据库,不妨先按这篇文章里的 LITE 版骨架搭一套原型,跑一段时间的真实日志看看,验证过“反馈能学到东西”之后,再决定基础设施怎么投入,会比拍脑袋上全套向量化稳妥得多。