首页 / 资讯中心 / 文章详情

自进化搜索架构:无需向量数据库的语义匹配与工程实践

自进化搜索架构:无需向量数据库的语义匹配与工程实践 ★ FEATURED ARTICLE
搜索架构这两年被“向量数据库”这个词带得有点偏热。很多项目一上来就安排 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 批量推理只需要在离线日志里做统计聚合。为了防止“关联词”把搜索结果带偏写入时有两个限定条件扩展词必须出现在被点击文档的标题或前几行摘要里不能只出现在正文深处扩展词和原查询词之间的共现计数要达到最少阈值一般取 35 次视流量质量而定。阈值太低会引入噪声太高则冷启动慢需要在实验中调节。3.3 行为特征如何把“字面匹配”变成“意图匹配”有了扩展词系统能把“没字面重叠但语义相关的文档”捞进候选集。但候选集里可能混着大量弱相关结果这时候就要靠行为特征做二次筛选。用户行为比文本本身更能反映真实偏好。比如“电池不耐用”这个查询点击了文档 A“电池健康度怎么看”和文档 B“电池续航衰减修复技巧”停留时间都超过 40 秒而文档 C“电池外观清洁”虽然也被点了一次但 5 秒就跳走了。反馈机制会给 A 和 B 正向加权给 C 记一个负向信号。这种从行为中学习到的差异是纯文本语义模型很难捕捉的因为从文本角度看C 里也包含“电池”相关度并不低。把行为信号转化为排序特征后系统本质上已经不只是在做“文本匹配”而是在做“意图匹配”。文本只是入口用户验证过的行为才是真正的评价标准。这也是 Sirchmunk 能在没有向量结果的情况下慢慢逼近语义检索体验的核心原因。3.4 一个直观对比同一个查询在三套方案下的首屏差异我拿一个常见业务查询做个推演。用户输入“手机掉水里怎么办”文档库里有三篇候选文档 X《手机进水后紧急处理方法》包含“掉水里”“进水”“应急”等词文档 Y《电子设备进水检测与保修政策》包含“进水”“检测”文档 Z《手机防水等级说明与选购指南》包含“防水等级”“选购”。传统 BM25 对“掉水里”能精确命中 X 和 YZ 因为“防水等级”被召回但没有明显胜出。向量检索可以把“手机掉水里”和“手机防水等级”在语义上拉近Z 的排名可能上升。Sirchmunk 在初始状态和 BM25 类似但经过一段时间的行为日志积累如果用户搜“手机掉水里”后经常接着查看防水等级相关文章词项关联表就会建立“掉水里→防水等级”的映射Z 就会进入候选集再通过行为加权把真正被认可的文档顶到前面。方案首屏可能结果工程成本传统 BM25X、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_hits3): 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, k8) 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_k10): 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(), keylambda x: x[1], reverseTrue) 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/D2D3 未召回。 反馈后用户多次点击 D2停留长系统建立“寿命短→续航、衰减、耗电快”的映射。 第二次搜索扩展词“续航”“耗电快”D2、D3 都进入候选集经过行为加权后 D2 排在前面。这就是“检索结果随反馈变好”的可见轨迹。6. 常见问题与避坑经验这些坑我大概率你会重踩6.1 召回率不升反降怎么办一种典型情况是扩展词表越加越多召回文档数量变多但排序变差用户反而找不到目标结果。问题往往出在扩展词的置信度门槛太低把偶然共现当成强关联。排查方法是看新增扩展词的回放效果调出所有新增词对逐一检查它们对应文档的满意率。如果某个扩展词带来的点击文档大多被快速跳过就该降低它在扩展召回里的权重或者直接加入黑名单。我在实践里比较管用的操作是给扩展词设置“试用期”。新扩展词先进入候选池而不是全量生效只有试用期内满意率达标才转正。6.2 反馈被点击噪声污染怎么防御点击噪声是自进化系统最大的敌人。搜索引擎行业里有个苦涩的经验第一屏的结果无论好坏都会比第二屏获得更多点击因为用户习惯从上往下扫。如果不处理这个问题反馈机制会不断强化那些“排名靠前但未必最好”的文档。防御手段分三层。第一层是位置归一化计算点击率时把位置因素去掉只看相对预期点击率的增益。第二层是行为确认一般的点击只能算弱信号必须结合停留时长、是否复制内容、是否滚动到页底等行为才能升级成强信号。第三层是随机探索每天抽 3% 到 5% 的搜索流量打乱部分排序结果虽然短期会损失一点指标但能换来更真实的反馈分布有效抑制马太效应。6.3 索引膨胀与更新延迟怎么平衡词项关联表如果只增不减最终会膨胀到检索时拖慢速度。很多被历史日志写入的关联词随着业务变化已经失去意义比如某些季节性活动的词汇活动结束后就长期无人问津。解决办法是给每个词对加一个“最后有效时间”和“累计使用次数”定期清理超过有效期且使用次数低于阈值的词对。清理任务同时要考虑“生效时间”不能刚写入就删掉。一般保留 30 天冷启动期可以延长到 90 天。6.4 这套架构不适合谁不是所有场景都适合这套无需向量数据库的方案。如果你面临的情况是以下之一那就要仔细权衡冷启动且完全没有历史日志同时又要求上线就有语义检索效果查询极短且语义依赖极强比如只有两个字的品牌词行为和文本信号都很难积累内容生命周期极短今天的热点词明天就失效反馈学习速度跟不上内容更迭速度。这些情况下向量检索的“开箱即用”优势是实打实的。Sirchmunk 更适合的是“有一定流量基础但不希望背上向量基建成本”的团队它用日志换时间用时间换语义能力。场景推荐方案有日志、希望降低基建成本Sirchmunk 这类反馈增强型倒排架构冷启动、快速上线演示先接向量检索后续再考虑替换内容日更速度极快混合方案向量兜底 反馈热更7. 后续演进与我的个人心得整个 Sirchmunk 架构看下来真正打动我的不是某个炫技模块而是它对“是否需要向量数据库”这个问题给出了一种更经济的回答。过去我们容易被“状态解决问题”的相对优越性吸引却忽略了工程成本、维护成本和可解释性而这套设计更像是站在“如何让搜索在有限资源里长期变好”的角度考虑问题。如果后续要继续演进我觉得可以在两个方向上深化。一个是通过预训练语言模型离线生成少量“种子关联词”加速前期的冷启动但依然不需要在线 embedding 推理另一个是进一步细化反馈置信度体系对每个用户群体的搜索习惯做独立统计而不是把所有信号混在一个桶里让“自进化”从整体优化变成群体精细化优化。我个人的经验是这套架构能否跑出效果七成功力在于日志清洗和反馈质量控制三成功力才在检索算法本身。只要日志是干净的哪怕初始搜索效果平平经过两三周的积累也会明显感觉到结果变顺反过来如果日志经常被无效点击污染哪怕初始排序做得再精细系统也会被带偏。如果你正在评估要不要引入向量数据库不妨先按这篇文章里的 LITE 版骨架搭一套原型跑一段时间的真实日志看看验证过“反馈能学到东西”之后再决定基础设施怎么投入会比拍脑袋上全套向量化稳妥得多。
阅读完成 · 觉得有帮助?
咨询建站