做站内搜索,第一反应是不是就该上向量数据库?我之前也这么想,直到亲手折腾了一个项目,代号叫 Sirchmunk,才意识到“语义搜索”和“向量数据库”之间并不能直接画等号。这项目的核心思路很反直觉:完全不依赖向量数据库,靠一套自进化的稀疏检索架构,就拿到了接近向量检索效果的搜索质量。这篇文章是这个深度解析系列的第一篇,我先把这套搜索引擎架构设计的骨架、设计取舍和自进化机制讲清楚,后面几篇再逐个拆学习器和排序细节。
这个项目适合谁参考?我觉得最对口的是这几类人:被向量数据库的资源开销和调参折磨过的同学;手头是十万到百万级文档、想控制成本和运维复杂度的团队;以及对“可解释、可干预”有执念、不想把排序结果完全交给黑盒模型的开发者。当然,如果你只是对搜索技术感兴趣,把它当一个另类架构案例看,也能收获不少有意思的思路。
1. 项目背景与设计取舍:为什么非要“去向量数据库”
1.1 先聊聊向量数据库的三个痛点
很多人一说语义搜索就直奔向量数据库,但真到生产环境,你会发现这条路有几个绕不开的问题。
首先是资源开销。向量检索的核心是按向量相似度做近邻搜索,这意味着每条文档都要过一遍嵌入模型,产出一个几百维的稠密向量。假设你有一百万篇文档,每篇切分成二十个片段,每个片段生成一个 768 维的向量,光存储就是一百万乘二十乘七百六十八个浮点数,换算下来轻轻松松几个 GB 甚至十几个 GB。索引构建的成本更夸张,普通 CPU 机器跑嵌入模型,一百万个片段可能要跑十几个小时。这还没算上向量索引本身的额外内存占用,像 HNSW 这种图索引,内存开销通常是原始向量的三到五倍。对一个小团队来说,这笔账很容易把预算吃掉一大块。
其次是新鲜度问题。向量检索的前提是文档已经被嵌入模型变成向量,那么新文档要能被搜到,就必须先跑模型、再写索引。如果你的内容更新频繁,比如每分钟都有新文章、新问答进来,向量索引的更新就成了瓶颈。要缩短“发布到可搜”的延迟,要么上 GPU 加速嵌入,要么引入复杂的增量更新机制,这都属于额外的工程债务。
最后是可控性差。向量检索的结果来自模型计算出的语义相似度,维度太高,很难推理“为什么这篇排在前面”,也就很难做针对性的干预。用户搜“便宜又好用的手机”,你发现结果里有“二手手机回收”,你明明知道这是页面的分类标签惹的祸,但你很难在向量空间里精准按下这个犯错源头。这种不可控感在生产环境会非常难受。
1.2 Sirchmunk的应对思路与适用边界
Sirchmunk 的应对思路,一句话概括就是:把“语义理解”这件事从“模型预计算”搬到“线上行为学习”。它不生成任何稠密向量,索引结构就是传统的倒排索引,匹配靠的还是词级别的稀疏检索。但它跟传统搜索引擎的区别在于,系统会持续从用户行为日志里学习词的权重、词与词之间的关系、文档的热度,然后把这些信号实时注入到排序公式里,让一个原本“只认字面”的检索引擎慢慢长出“理解语义”的能力。
这听起来有点复古,但它的收益非常实在。索引存储直接落盘为倒排列表,构建过程不需要任何模型推理,CPU 机器就能扛住百万级文档的建索引任务。新文档入库走常规的文档解析和倒排写入,延迟可以做到秒级内可搜。排序公式里的每一个因子都有明确业务含义,想干预哪个字段、哪个词、哪类文档,改配置就行。
当然,这个方案不是万能的。它有一个重要前提:必须有用户行为数据。冷启动阶段没有点击日志,系统的语义能力只能靠同义词表、人工规则兜底,搜索效果跟传统 BM25 没什么本质区别。所以它最适合的是那些有一定用户量、搜索行为能形成反馈循环的场景,比如站内搜索、电商类目检索、垂直领域资料库。反过来,如果你的系统从零开始、完全没有行为数据,或者文档量跑到千万级以上且排序精度要求极高,那该用向量检索还得用。
2. 整体架构拆解:从查询到反馈的一条完整链路
2.1 六大核心模块各自的作用
Sirchmunk 的架构非常朴素,它没有复杂的微服务拓扑,核心模块一共六个,彼此边界很清楚。
- 采集与解析模块:负责接入多格式文档(HTML、PDF、Markdown、纯文本),提取标题、正文、标签、发布时间、作者等结构化字段,统一转成内部文档模型。
- 词典管理模块:负责分词、停用词过滤、新词识别、同义词表维护。这是自进化机制的“记忆中枢”,词权重表、同义词表都挂在它下面。
- 索引存储模块:负责倒排索引的写入、合并、快照,支持增量追加和段合并,是全文检索的物理基座。
- 检索执行模块:负责把查询词拆成检索单元,从倒排索引里拉取候选文档,完成初步评分和截断,产出 Top K 候选集合。
- 排序融合模块:负责把多路分数(基础相关性、点击热度、新鲜度、业务加权)融合成最终排序分,这是搜索结果质量最直接的战场。
- 反馈学习模块:负责消费用户行为日志,产出词权重更新、同义词挖掘、点击热度统计等学习信号,是整个“自进化”的引擎。
这些模块之间通过非常简单的数据依赖协作:采集解析产出文档,文档进入索引存储;查询进来走检索执行和排序融合;用户行为回传反馈学习,学习结果反哺词典管理和排序融合。没有消息队列,没有分布式协调,这样的设计有它的道理——把复杂度压到最低,才能让小团队轻松掌控全链路。
2.2 一次查询在Sirchmunk里的完整流转
我拿一个具体查询来走一遍流程。用户输入“怎么挑选入门级相机”,这条请求会经历这么一串步骤。
第一步,查询解析。检索执行模块收到查询串后,先做分词,得到“怎么”“挑选”“入门级”“相机”几个词,然后去停用词表里把“怎么”过滤掉,剩余三个有效检索词。紧接着查词典管理模块,看“入门级”有没有关联同义词,如果学习器已经沉淀了“新手”“初学者”与它的关系,这步会把查询扩展成“入门级 OR 新手 OR 初学者”。
第二步,候选召回。三个检索词分别去倒排索引里拉文档列表,求并集得到候选集合。这里有个细节,Sirchmunk 对每个词设置了一个候选上限,比如单个词最多取两千篇,避免某个超高频词拖垮整次查询。
第三步,排序打分。排序融合模块会对候选集合里每篇文档计算三路分数:改进版 BM25 基础分、点击热度分、新鲜度分,再加业务规则加权(比如标题命中加权、类目加权),最终算出总分排序,截断返回前五十篇。
第四步,结果日志回传。用户看到结果后会有点击、跳过、停留等行为,前端把这些事件埋点回传,反馈学习模块异步消费,更新词权重表和同义词表。这一步在用户感知之外,但恰恰是整条链路里最值得花时间设计的部分。
3. 自进化机制:没有模型训练,索引如何自己变聪明
3.1 信号采集:学习器依赖的原始数据
自进化的前提,是先定义清楚“进化信号”长什么样。Sirchmunk 采集的原始事件表结构大概是这样的:
CREATE TABLE search_events ( event_id BIGINT, query_text VARCHAR(255), result_id BIGINT, result_url VARCHAR(512), position INT, clicked TINYINT, dwell_time_ms INT, is_skipped TINYINT, occur_time DATETIME );这张表记录每一次搜索请求的结果展示和用户交互。position 是结果所在位次,clicked 标记是否点击,dwell_time_ms 是点击后的停留时长,is_skipped 表示用户是否快速跳过这条结果。这些字段的价值是不同的:点击行为直接反映“用户觉得这条结果靠谱”,停留时长进一步区分“真看了”和“点错了”,跳过行为则反映“用户对这条结果不满意”。
理论上讲,采集这些字段是每个个性化系统的基本功,但真正难的是怎么把原始日志变成可以驱动排序更新的学习信号。Sirchmunk 的做法是先把一天内的原始事件聚合成三类统计量:每个查询词与点击文档标题词的共现次数、每个文档在最近三十天的点击次数和平均停留时长、每个查询词在最近七天内的结果点击分布。这三类统计量分别对应词权重学习、文档热度学习、排序质量监控,下面几条会展开讲。
3.2 全局词权重自调节:把点击共现变成检索先验
传统 BM25 里,每个词的重要性主要由 IDF 决定,即“词越罕见越重要”。但 IDF 是静态的,它无法感知用户的行为偏好。Sirchmunk 的核心创新之一,就是引入了一个全局词权重表,把用户行为中学到的词重要性叠加到 IDF 上。
词权重的学习思路是这样:如果某个查询词出现了,用户点击的文档标题或正文里频繁出现另一个词,说明这两个词在当前业务场景下是语义相关的。反过来,如果某个词出现在查询里,但用户总是跳过包含它的结果,说明这个词的检索价值在下降。基于这个直觉,学习器每天会跑一次词共现统计,按以下规则更新词权重:
def update_word_weight(word, cooccur_count, expected_count, skip_count): delta = alpha * (cooccur_count - expected_count) - beta * skip_count current = word_weights.get(word, 1.0) new_weight = current + delta word_weights[word] = max(0.1, min(5.0, new_weight))这里 alpha 和 beta 是学习率参数,控制词权重变化的速度。expected_count 是该词在随机共现条件下应有的期望次数,用来剔除偶然共现;skip_count 是负反馈信号,防止一个词因为某些热门文档的带动而虚高。每个词的权重被限制在 0.1 到 5.0 之间,避免个别词权重爆炸。
这个机制的实际效果很直观。例如在一个技术文档站里,“部署”这个词的初始权重与 IDF 一致,但用户在搜“上线”时经常点击标题里带“部署”的文档,系统学到两者相关性后,“部署”作为查询扩展词的权重就会上升,后续搜“上线”时,含“部署”的文档排名会自动提高。
实操心得:词权重更新的频率不建议太高,每天一次足够。实时更新容易让排序结果剧烈抖动,用户还没形成固定认知,反而损害体验。我试过每十五分钟跑一次更新,结果上午刚调优的排序,下午就变得面目全非,后来改成每日凌晨批量更新才稳定下来。
3.3 同义词沉淀:从查询与点击文档的共现中长出来
词权重解决的是“同一个词在不同场景下重要性不同”的问题,但检索质量更大的提升来自“能搜出字面上不包含查询词的文档”。这就要靠同义词表。
Sirchmunk 的同义词挖掘非常简单粗暴,却不失有效。它统计每条查询词和该查询点击文档标题词之间的共现矩阵,然后计算一个置信度分数:
conf(w1, w2) = count(query 含 w1 且点击文档含 w2) / count(query 含 w1)
只有当共现次数超过二十次,且置信度大于零点六时,系统才把 w2 加入 w1 的同义词候选。同时,为了避免把“红色”和“蓝色”这类在同一条导航页面里高频共现的词误判为同义词,系统还会计算两个词的互信息(MI),要求 MI 值超过一个最低阈值。
我曾经在这个环节翻过车。某电商项目的早期版本里,“降噪”和“耳机”被系统牢牢绑定成了同义词,原因是用户搜“降噪”时几乎都点了标题含“耳机”的详情页。听上去合理,但问题在于,系统后来把“降噪耳机”这个复合词也拆成了“降噪”和“耳机”,再经同义词扩展,“降噪”能扩展出“耳机”,“耳机”也能扩展出“降噪”,结果搜“耳机”时所有含“降噪”的奇怪结果全跑出来了。后来加了一条硬性约束:复合词优先原词整体匹配,同义词扩展只对单字词和双字词生效,才把这个乱象压住。
3.4 文档热度与新鲜度加权:让时间参与排序
如果说词权重和同义词解决的是“词”层面的理解,那文档热度解决的就是“文档”层面的价值感知。一个简单的事实是:用户喜欢点、停留时间长、近期不断被访问的文档,大概率是好文档。
Sirchmunk 给每篇文档维护两个动态分数。第一个是热度分,基于近三十天的点击次数和平均停留时长,计算公式是:
popularity_score = log1p(clicks_30d) * min(1.0, avg_dwell_ms / 30000)log1p 做了压缩处理,避免爆款文档一骑绝尘,让其他优质文档也有出头机会。avg_dwell_ms 除以三万毫秒再截断到 1.0,表示“停留超过三十秒就算深度阅读”,超出部分不再加分。
第二个是新鲜度分,基于文档年龄计算:
recency_score = 1.0 / (1.0 + log1p(age_hours / 24))这篇文档发布一天内,新鲜度分接近 1.0;发布七天,降到零点七左右;发布三十天,降到零点五以下。这个衰减公式比线性衰减更符合信息消费的直觉——新闻类内容发布几小时后就迅速贬值,但资料类长文衰减缓慢。如果你的业务里文档价值偏长期,可以把分母里的二十四改成七十二,让衰减曲线更平缓。
注意:热度分和新鲜度分是动态变化的,如果直接把它们参与排序,会导致索引没法静态化。Sirchmunk 的做法是把这两个分数存成独立的文档级元数据字段,每次排序时从内存缓存读取附加到得分上,倒排索引本身不参与动态更新。这样既保证排序新鲜度高,又避免了索引频繁重建。
4. 检索算法与排序细节:不靠向量,靠什么搞定语义排序
4.1 改进版BM25:全局词权重如何嵌入经典公式
Sirchmunk 的基础检索模型仍然是 BM25,但它对 BM25 做了两处关键改造。第一处,是把全局词权重 g(t) 乘到 IDF 前面;第二处,是把同义词扩展后的查询词用 OR 语义扩展到候选集合里。
改造后的打分公式大致长这样:
score(t, d, q) = g(t) * IDF(t) * TF_part(t, d) IDF(t) = ln(1 + (N - n_t + 0.5) / (n_t + 0.5)) TF_part(t, d) = tf(t, d) * (k1 + 1) / (tf(t, d) + k1 * (1 - b + b * dl / avgdl))g(t) 是上一节说的全局词权重,来自用户行为学习;IDF(t) 是经典逆文档频率;TF_part 是词频归一化部分,k1 通常取 1.2 到 2.0,b 取 0.75。dl 是文档长度,avgdl 是语料库平均文档长度。
这里为什么要保留 BM25 的 TF-IDF 骨架,而不是直接用词权重排序?原因在于:词权重是从行为数据中学来的先验,它描述的是“这个查询词在用户预期中更接近哪些词”,但它无法替代词频和文档长度这些基础相关性信号。同义词扩展结果如果直接按 OR 拉回来,必然带来大量主题泛化的噪音文档,只有保留 BM25 的相关性压制,才能让真正贴题的文档排在前面。
4.2 查询扩展与查询改写:轻量级的语义理解
查询扩展是 Sirchmunk 对抗“词不匹配”问题的主要武器。它分三个层次:
- 同义词替换:词典里命中同义词表的词,直接生成 OR 扩展项。比如“笔记本”扩展出“笔记本电脑”“便携电脑”。
- 子串/前缀匹配:中文场景下,用户经常输入简写或前缀,系统对长度大于二的查询词启用前缀匹配,把“入门级相”也拉到和“入门级相机”相关的候选。
- 纠错与拼音归一:针对明显的拼音输入错误或同音字问题,维护一个轻量级纠错词典,比如“xiangji”归一为“相机”。
这里有个非常关键的设计原则:查询扩展生成的多个查询项之间不是“AND”关系,而是“OR 取并集召回、AND 加权的排序”。也就是说,扩展词参与候选召回时,原始查询词仍然拥有最高的基础权重,扩展词只能辅助拉回候选,不能喧宾夺主。一旦扩展词和原始词在倒排索引里都属于高频词,召回集合会急剧膨胀,所以每个扩展词同样要遵循单词候选上限逻辑。
4.3 多路分数融合:三因子排序模型
最终排序不是单一 BM25 分,而是三路分数的线性融合:
final_score = α * bm25_score_norm + β * popularity_score + γ * recency_score其中 α、β、γ 的默认值分别是 0.7、0.2、0.1。这个默认值来自我自己的线上实验:基础相关性必须占主导,否则整个搜索会退化成“热门榜”;但完全没有热度分和新鲜度分,新文档的曝光率又太低。
三路分数在融合之前必须做归一化,否则量纲不统一会让融合失去意义。Sirchmunk 用的是百分位秩(percentile rank)归一化:对候选集合计算每路分数的排名百分比,把原始分数映射到 0 到 1 之间。这样即使某篇文档的 BM25 原始分高达五十,另一篇只有两分,融合时也能合理对比相对位置。
融合系数不是拍脑袋定的,可以基于用户行为做启发式调整:如果发现某类查询用户更爱点新鲜内容,系统会自动把 γ 调高一点;如果发现点击集中在头部热门结果,β 就得降下来。这个调整同样走每日批量更新的学习链路。
4.4 索引一致性与近实时更新策略
索引一致性是搜索引擎工程里最容易翻车的环节。Sirchmunk 的方案很务实:主索引采用“基线段 + 增量段”的结构,基线段定期全量构建,增量段是近实时写入的小段,检索时同时查基线段和所有增量段再合并结果。这样新文档入库后只要写进增量段,就能被搜到,延迟通常能控制在几秒以内。
为了控制增量段数量对查询性能的影响,系统设定了段合并策略。当增量段数量超过三个,或者增量段总大小超过基础段的百分之十,后台就会触发一次段合并。合并期间旧段和新段同时存在,查询会有短暂的重复计算,但因为合并过程是异步的,对用户无感。
5. 工程实现要点:存储、内存与部署形态
5.1 倒排列表的追加式写入与段合并
倒排索引的物理存储用追加式写入,不原地修改。每篇文档解析完,系统把该文档对应的词项和文档 ID 追加到当前增量段的倒排列表末尾,同时维护一个跳跃表结构记录每个词项在段内的偏移位置。这样做的好处是写入效率高,避免随机 IO;坏处是旧文档更新时,倒排列表里会同时存在旧版本和新版本的条目,需要靠文档级元数据里的版本号做冲突裁决。
段合并的时机不能全靠定时任务,我实践中发现更好的方式是设置触发阈值,比如“增量段数量大于等于四”或“增量段总文档数超过十万”。这种触发式合并能在写入峰谷自动调节,最大程度上避免查询毛刺。
5.2 内存词典与跳表索引:检索性能靠什么扛
查询性能的决定性因素不在倒排列表本身,而在于词典查找和倒排列表跳跃。Sirchmunk 把词项字典整体加载到内存,用哈希表加排序数组的双层结构:哈希表负责精确查找词语对应的段位置,排序数组负责任意前缀的定位。倒排列表在磁盘上按文档 ID 升序存储,每个块末尾存了一个跳表指针,候选文档按块跳跃读取,避免把整个倒排列表读进内存。
针对高频词,系统有一个单独的缓存层,把最热门的一万个词项的倒排列表预加载到内存里。这看起来是个小优化,效果却非常显著,比如搜索“价格”“怎么样”这类高频词时,基本不用碰磁盘。
5.3 部署形态:一台机器也能跑起自进化搜索
如果工程复杂度可以用“单机可运行”作为衡量标尺,Sirchmunk 算得上极度克制。整个系统打包成单个二进制,所有模块在同一个进程里通信,文档量低于一百万时,一台四核八 GB 内存的普通云主机就能跑得很稳。索引文件落在本地磁盘,行为日志落到同一个磁盘的日志分区,学习器每天凌晨定时消费日志并更新词权重表。
当文档量翻倍到两三百万,再考虑把索引存储和检索执行拆开部署,用共享文件系统或对象存储做索引快照同步。这个路线从单机到小集群再到分布式,演进路径很平滑,前期完全没有必要设计过度复杂的微服务架构。
6. 踩坑实录与性能复盘:真实运行中的教训
6.1 三个最典型的线上问题
第一个坑是词权重崩坏。某个业务线的热门词被搜索引擎爬虫高频触发点击,词权重被一路推高到 5.0 上限,导致所有包含该词的内容名次暴涨。排查后发现根源是行为日志里混入了异常流量。修复方案有两步:统计点击共现时增加时间窗口,只统计最近九十天的数据;给词权重更新加限速,单日变化幅度不得超过 0.2,超出的部分平滑到后续几天消化。
第二个坑是同义词误吸收。前面提过的“降噪”和“耳机”事件就是典型例子。它暴露出的深层问题是:共现统计只看了词对之间的相关性,忽略了词对在文档分布上的独立性。后来我引入了互信息(MI)过滤,公式是:
MI(w1, w2) = p(w1, w2) / (p(w1) * p(w2))
MI 值低于阈值三的词对被直接丢弃,因为它们在语料库中几乎总是伴随出现,不具备独立语义。这个过滤上线后,同义词表的错误率下降了大概六成。
第三个坑是段合并造成的查询毛刺。增量段数量太多时,一次查询要在几十个小段上分别做倒排拉取再合并去重,P99 延迟从三十八毫秒直接飙到两百多毫秒。触发式合并上线后,段数量被控制住,毛刺才消失。如果你也在做类似架构,建议给段合并加一个监控指标,时刻留意“段数量”和“平均段大小”这两个数的比值。
6.2 性能对比:无向量方案的实测数据
我用一份百万级文档集做了一次对比实验,数据集是某垂直领域的资料库,包含标题、正文、标签。Sirchmunk 部署在一台二核四 GB 内存的云主机上,对照组是一个常见的向量检索方案,嵌入了同样数量级的数据。数据不是绝对精确的基准测试,但趋势很有参考价值。
| 指标 | Sirchmunk(无向量方案) | 向量检索方案 |
|---|---|---|
| 索引大小 | 约 1.8 GB | 约 6.2 GB(含向量和索引结构) |
| 构建耗时 | 约 25 分钟(纯 CPU) | 约 2.5 小时(含嵌入模型推理) |
| 峰值内存 | 约 600 MB | 约 2.8 GB |
| 查询 P99 | 38 ms | 120 ms |
| 新文档可搜延迟 | 秒级 | 分钟级(受嵌入管线影响) |
| 语义召回能力 | 依赖行为日志累积 | 开箱即用 |
你能清楚看到,无向量方案在资源开销和新鲜度上有碾压性优势,代价是冷启动阶段语义能力不足。实验里让我意外的是查询 P99 的差距,向量方案在同样数据集上居然比稀疏检索慢了这么多。细想也合理,向量距离计算虽然单条很快,但候选集规模一大,近邻搜索的图遍历成本立刻显现,而稀疏检索走的是倒排列表加跳表,数据紧凑且访问局部性好。
6.3 这个架构后续还可以往哪走
做了这么多,Sirchmunk 这套架构的边界在哪,我心里已经很有数。它解决的是“中规模文档库、行为数据充沛、资源有限”的场景,如果硬拿来跟顶级的稠密检索系统比离线评估指标,它不一定赢,但它换来了可控、可解释、能持续自我调整的运维体验,这在实际生产里比任何离线指标都重要。
我个人在实际操作中的体会是,这套方案的重心已经不在检索引擎本身,而在反馈学习器的设计。同一个“无向量”架构,学习器参数调得好,搜索质量能在一个月内肉眼可见地提升;调得不好,可能每天都在引入新的排序噪音。下一期我打算专门拆学习器这块,把词权重更新的收敛性问题、同义词表的衰减机制、还有多路融合系数的自动调节讲透,顺便给出一套能直接抄作业的参数配置。到时候见。