给旧系统加上智能搜索:从本地向量试验到 AI 协同检索
- AI Writing
- 11小时前
- 3热度
- 0评论
一、起点:让一个旧系统听懂问题
最近给一个已经运行多年的 Web 系统补上了智能搜索。最初的想法很朴素:原来的搜索擅长匹配字面,但用户不一定记得准确名称,也不一定知道资料存放在哪个模块。如果能够直接输入一句问题,让系统找到相关记录,再给出有出处的归纳,应该会方便很多。
真正做下来才发现,向量化只是其中相对容易的一步。更难的是理解用户在问什么、限制资料所属范围、组合不同模块的信息、尊重实时权限,以及决定为了一个答案值得让模型读多少资料。
这篇文章记录一次实际升级中的技术选择和反复调整。系统名称、业务用途、真实人员及资料名称均不展开,服务器地址、接口路径和部署位置也全部省略。文中的问法是抽象示例;小规模测试、真实运行数据与尚未实施的方案会分别说明。
二、先做本地试验:能运行,不代表适合在线搜索
测试环境是一台 4 核、8GB 内存的 Windows Server 2012 R2,网站由 IIS 托管,主体仍是 PHP。正式环境的内存更充裕,但同样不适合承担大型模型推理。另有独立的模型机器,其中用于理解与总结的一台配备 RTX 5090、32GB 显存。
最开始并没有直接选择远程推理。另一台高配置电脑上已经用 Node.js 和 QMD 跑通过检索,于是先验证这条路能否搬到旧服务器上。Node.js 可以启动,QMD 也能安装,SQLite 和向量扩展的诊断正常;然而真正生成向量时,进程突然退出,退出码对应非法指令。
进一步检查发现,自动选择的 CPU 后端报告启用了 AVX-512,而操作系统没有启用相应的寄存器状态。随后复制了一套隔离运行环境,只保留适用的 AVX2 后端,再跑同样的试验,推理成功完成。这个结果说明绕开该后端解决了当时的崩溃;没有抓取原生崩溃转储,因此不能把它说成精确定位到了某一条机器指令。
真正决定架构的是接下来的速度对比。使用六条人为编写的中文资料、三个不同查询,服务器本地 CPU 推理的暖查询中位数约为 8.33 秒,模型重载后的查询约为 13.38 秒,采样进程峰值内存约为 1.36GiB。它能工作,内存也不算离谱,但每输入一句话都等六到九秒,不适合作为频繁使用的搜索入口。
换成远程 Embedding、本地 SQLite 检索后,三个查询重复三轮,九次暖查询的端到端中位数约为 304 毫秒,范围约为 300—337 毫秒;本地查找部分约为 4 毫秒。两种工作流的暖查询中位数相差约 27.4 倍。首次文档向量请求仍用了约 19 秒,包含模型加载等开销,所以不能只拿暖查询表现描述所有情况。
这些数字只证明了六条合成资料上的可行性。它们不是全站准确率、多人并发测试或生产环境承诺;所谓九次命中,也只是三个问题各重复三次。测试的价值在于把“老服务器究竟慢在哪里”分清楚:慢的是现场生成向量,并不是保存和查找向量。
三、最终结构:网站管业务,模型管推理,向量留在本地
当前结构分成四层。第一层是原有网站:PHP 继续负责身份、权限、业务数据和页面。第二层是在网站服务器上常驻的 Node.js 搜索服务,负责连接模型、查询向量、管理总结任务与进度。第三层是同机的 SQLite 向量索引。第四层是远程 Ollama,按职责分成 Embedding 与理解总结两类服务。
Embedding 使用 Qwen3-Embedding 的 0.6B 模型。理解和总结使用一个 27B 级 Qwen 模型,当前实例为 Q4_K_M 量化。两个角色可以放在不同机器上:小模型负责把文字转成向量,大模型负责处理语言、证据和结论。
网站后端通过 HTTPS 和认证信息调用模型服务,浏览器不直接连接模型机器,也不接触访问凭据。向量化请求发送的是文字,返回的是一组数字;每个向量有 1024 个分量,归一化后保存在网站服务器。
这里最容易误会的一点是:远程 Embedding 不等于远程搜索整库。用户提问时,远端通常只接收一句问题或几条检索短语;返回查询向量后,在网站服务器上与已有向量比较。不需要把整个向量数据库来回传输。只有理解、补查和总结阶段,才会发送相应的资料片段。
QMD 在这一阶段退出了正式运行链路。现在独立使用 better-sqlite3 操作 SQLite,使用 sqlite-vec 计算向量距离,再由自建程序组织业务流程。没有继续依赖 QMD 的模型管理和查询封装,也不在网站服务器上加载本地大模型。
当前采用的是授权候选范围内的向量距离计算,并非额外部署了大型向量数据库或近似检索集群。以目前的数据规模,轻量方案已经能满足需求。是否需要更复杂的索引,应由后续规模和实测延迟决定。
四、全库向量化:全量检查,增量计算,完整发布
这里的“全库”指可检索的业务正文,不是把数据库文件、所有表和所有字段都送给模型。程序先整理有效记录、标题、所属范围、层级节点和正文,排除账号凭据、令牌、系统日志等无关内容。
长正文按 Unicode 字符切分:每段约 600 个字符,相邻片段重叠 80 个字符,并附上所属范围和标题,前缀另有限长。重叠用于减轻语句被切断的影响;标题和层级信息则帮助区分那些正文相似、归属却不同的记录。节点名称不是无关装饰,它往往本身就是检索线索。
每个输入片段都会计算内容指纹。缓存同时绑定模型的内容指纹与文本指纹:文字没变、模型没变,就复用旧向量;新增和修改的片段才送去计算。建库时每次最多发送 16 个片段。模型名相同不代表权重相同,模型内容变化不能继续混用旧向量。
更新由 Windows 任务计划在每天凌晨 3 点触发。程序完整检查当前资料,复用可用向量,生成一份新索引;通过数量和完整性检查后,再切换当前索引指针。失败时继续使用旧索引,避免用户查询到一份只建了一半的库。
一次实际运行很能说明增量缓存的作用:正式环境约有 3.99 万条记录,切成约 4.26 万个片段,当前索引约 188MiB。当晚只重新计算了 155 个片段,复用了 42,401 个片段,整个更新约 62 秒;测试环境没有内容变化,全部复用,约 23 秒完成。
这不是首次全库向量化的耗时。首次需要计算全部片段,之后才有缓存收益。新内容通常要等索引刷新后才能进入向量检索;而删除与权限收回,会在查询时实时核验,不必等待下一次建库。
还有一个边界必须写清楚:目前没有把附件文件内部的文字全部解析出来,也没有做 OCR。正文里的附件名称可能被找到,但“能搜索到一个文件名”不能宣称为“已经理解文件全部内容”。
五、最先碰壁的不是模型能力,而是检索范围
最初的语义搜索容易给人惊喜:换一种说法,也能找到字面不同的资料。但继续使用后,另一个问题很快出现。问的是对象 A 的某个细节,结果却混入对象 B、C、D 的同类内容。它们在语义上很像,却不是问题要求的对象。
如果把这些资料一并交给总结模型,模型会花很长时间解释哪些不属于对象 A,最后得到一篇冗长的排除说明。即使结论没有明显编造,体验也很差。相关性不能只理解为“这两段话谈的是同一个专业词”。
因此,检索前增加了对象解析和范围约束:识别完整名称、简称、阶段、子范围和层级节点,再在确定的范围内检索主题。全站搜索可以解析用户明确提到的对象;对象内搜索则始终携带当前对象范围。多个独立模块之间,也不能仅凭名称相似就认定它们是同一条业务记录。
随后加入可调整的相似度门槛。当前普通语义入口的阈值为 0.40,用户明确扩大相关性时为 0.25。调高通常会减少语义候选,也可能漏掉改写较大、表达隐晦的真正相关资料;调低有利于召回,但会带来更多噪声和后续分析成本。
这两个数字不是正确概率。0.40 不是“有 40% 的把握”,也不是可直接照搬到其他模型的通用标准。明确的登记关系、直接文字命中、必要上下文,还有各自的准入方式,并非所有记录一律由这一条阈值决定去留。
对于“某地区+某类服务+推荐”这样的组合,仅仅提高全局阈值也不够。需要分别检查地区线索、服务概念及同义表达是否成立,再看正文能否提供推荐依据。多个条件之间应按问题含义组合,而不是其中任意一个词沾边就通过。
六、从穷举规则,转向让 AI 制定受控检索计划
早期处理组合查询时,采用过比较直接的办法:识别人名、时间、状态、对象和动作,再把它们映射到固定查询条件。对于“某人负责的对象有哪些”这类登记关系,效果不错,因为数据库里本来就有准确字段。
问题在于,自然语言问法远比字段组合丰富。“某人最近在做什么”可能涉及他发布的正文、操作记录、评论及另一个模块里的活动;“当时这个方案是谁做的”可能只能从一次讨论或后续回复里找到线索,并不等于查询当前登记负责人。
只要解析器不认识某种组合就拒绝,用户就得不断改写问题,迎合程序能理解的语法。继续堆规则,最终会变成一场没有尽头的补丁竞赛。规则仍然需要,但它们更适合约束边界,不适合穷举人的所有表达。
调整后的流程先做本地轻量判断。纯关键词走关键词与语义检索;自然语言问题或明确组合条件,通常先交给大模型理解。大模型将其区分为关键词查询、明确登记关系查询,或需要正文证据的查询。
对于明确关系,程序执行受控的只读业务查询,数量等确定性统计也由程序计算。对于证据问题,模型生成少量检索表达、概念与同义词,再通过向量检索、文字约束和上下文读取寻找线索。模型不自由编写 SQL,不获得任意工具权限,也不决定用户能访问什么。
初次证据检索通常产生一到三个检索表达。拿到结果后,再给模型少量资料预览,让它判断是否值得补查。它可以提出新的检索表达,或者要求查看已经出现的资料附近的上下文,比如正文与回复、主记录与后续更新。次数、候选量和输入都有预算,不会无限搜索。
不同检索表达的结果会合并、去重,结合排名与直接证据重新组织;也要避免同一条记录及其大量回复挤满结果列表。模型在后续提取中还会区分“直接相关”“只提供部分线索”和“无关”,无关资料的排除说明不再进入最终答案凑篇幅。
对于归属和角色,必须保留克制:发布人不等于实际执行人,参加过讨论不等于负责,记录更新时间不等于事实发生时间,“这次没找到”也不等于“系统里不存在”。这些都是比润色句子更重要的质量约束。
七、权限不能交给模型,也不能只在建库时检查
向量索引保存资料标识、来源版本和权限范围。查询时,网站先计算当前用户可访问的范围,再让本地检索在该范围内运行;结果取回后,重新读取业务记录并核验权限和版本。
翻页、继续分析、取引用资料,以及模型调用前后,也要检查当前状态。某用户搜索时有权限,过一会儿权限被收回,旧任务不能继续把缓存中的私密内容展示出来。系统管理员则沿用原有最高权限规则,不为了搜索另造一套相互矛盾的权限体系。
后台建库由受信任服务处理授权纳入索引的正文,可能包含私密记录;面向用户的检索与总结,只能使用该用户有权访问的资料。索引文件也要放在网站公开目录之外。
过程中还遇到过另一类问题:资料和权限没有真正变化,查询计划的重新计算却改变了范围指纹,导致用户反复收到“范围已变化,请重新搜索”。正确方向是区分真实数据变化与不稳定的派生状态,让已确认计划在任务中保持一致,而不是简单取消权限和版本校验。
八、20 条、全部结果、30 条一批:为什么反复调整
最初的结果列表只取前 20 条,总结却只读其中前 8 条。这个安排很快暴露出问题:用户看到的证据与 AI 实际读取的证据不一致。于是总结范围扩展到 20 条。
接着又出现新的矛盾:明明匹配到近百条,只总结第一页,仍可能漏掉重要信息。于是实现了结果分页和分段分析,希望覆盖全部匹配记录,并在达到预算时允许继续。
然而,“全部匹配”并不等于“全部值得读”。语义召回可能包含数百、数千条弱相关候选。如果把这些候选一概纳入总结,既慢又嘈杂。问题往往不是模型窗口不够大,而是检索范围、相关性与用户真正需要的证据数量没有分清。
现在将三个概念分开:单页显示数量、用户可见的结果窗口、当前总结批次。当前每页 20 条;默认总结批次为 30 条;默认结果窗口是总结批次的五倍,也就是最多先显示 150 条。
第一次总结处理前 30 条。用户需要更多证据时,再分析 30 条,逐次扩展到 60、90、120、150 条;在默认窗口内最多追加四次。已有分析尽量复用,不每次全部重来。继续扩展结果窗口,与下一页是不同操作;扩大窗口也不等于自动降低相关度门槛。
这种设计承认一个现实:较靠前的证据通常更值得优先读,但排序并不能保证后面没有重要信息。因此,系统既不默认分析几千条,也不悄悄抹掉其余可能性,而是把当前范围、已分析数量和扩展入口交给用户。
进度条也随之调整。前 30 条已经处理完,就应该显示本批完成,不能因为后面还有几千条候选就画出一条似乎永远没走完的进度条。按钮应该说“再分析 30 条”,不应该让用户误以为下一步就要分析所有剩余内容。
九、关键词不必每次动用总结模型
用户输入一个简单名词,可能只是想找资料;输入一句“最近有哪些变化”或“给我推荐一个”,才更像希望得到回答。把所有搜索都自动总结,既增加等待,也占用模型资源。
所以当前保留两种体验:纯关键词默认展示传统匹配和语义结果,保留手动总结入口;问题和明确的组合条件则自动总结。这里不能只认问号,“帮我找一下”“推荐一个”也可能是明确请求。文档标题即使很长,也不应该仅凭长度就被当作复杂问题。
本地判断承担快速分流,模型参与处理难以确定的自然语言意图。不同模块的搜索入口采用一致策略,用户不必学习两套交互。
界面也属于质量的一部分。顶部入口保持单行,回车提交,避免搜索升级把整站页头撑高;结果页允许输入较复杂的问题。语义结果与传统匹配分区显示,AI 总结有明显标识,引用采用简短编号,并尽量直达原始页面,保留附件和上下文入口。
这期间还修复了手机端横向溢出、Markdown 标记直接露出、引用控件过长导致标点单独换行等问题。答案内容正确,却让用户无法顺畅阅读或核对,也不能算一个好搜索。
十、上下文窗口很大,为什么总结仍然慢
当前总结不是把 30 条资料一次扔给模型就结束。程序会把提供的证据切成分析片段,先提取与问题有关的事实,再视长度做中间合并,最后生成带引用的回答。这样有利于控制覆盖范围,却增加了模型调用次数,也会产生多轮压缩的信息损失,需要用实际案例检验。
几个容易混淆的数字需要分开看:建库片段约 600 字符;总结分析片段最多 650 字符;建库远程向量化每批最多 16 个片段;当前总结调用每次最多 8 个分析片段。它们属于不同环节,不能把“30 条资料”直接理解成“30 个片段”或“30 次调用”。
当前主要总结流程的上下文配置为 32,768 tokens,输入另有 18,000 个 UTF-8 字节的上限,输出最多 3,000 tokens。还有累计输入和处理时间预算。累计预算中的十万 token,绝不是一次请求把十万 token 全塞进去。
18,000 字节如果全是常见汉字,大约只有 6,000 字,而且其中还包含提示词、问题和元数据。对于配备 32GB 显存、运行 27B 量化模型的机器,这个实际输入限制显得保守。但模型文件大小也不等于运行显存占用,还要考虑上下文缓存与计算缓冲区。
本文写作时,只讨论了下一步试验,尚未修改上线参数:先保持 32K 上下文,把输入上限提高到约 48,000 字节,同时将分析片段上限从 8 个提高到最多 24 个,再协调输出空间、超时及 token 估算。仅改字节限制,仍会被八片段上限挡住。
举一个只按片段数估算的例子:30 条证据各约 1,000 字,拆成约 60 个分析片段。八片段一批,至少需要八次提取再加最终总结;若能容纳二十四片段一批,可能缩减为三次提取再加最终总结。实际是否装得下、是否还需合并,以及是否更快,都必须测量,不能把调用次数的比例当成加速比例。
更进一步的设想是,短资料在预算内直接综合回答,长资料和复杂矛盾证据继续分批提取。验证时应使用同一批资料,比较总耗时、实际输入输出 token、排队、显存、遗漏和引用准确性;不能只挑一条跑得快的查询宣布优化成功。
十一、排队、备用服务与真实世界的失败
两个人使用同一个模型,并不意味着只有一个请求。理解、补查和总结都可能产生各自的调用。模型服务端已有调度层,限制同时运行的模型并照顾本地任务;网站作为请求方,也需要主动控制占用,不能不断堆积请求。
当前网站侧会对理解、补查和总结任务排队,在步骤之间留出调度机会,并向页面反馈等待或处理状态。测试站与正式站各有自己的队列,跨站和其他应用的总体协调仍由模型服务侧完成。
把批次做大可以减少往返和重复提示词,但一个巨大的请求也会让别人更久拿不到执行机会。因此优化目标应是适中的批次和可接受的整体等待,不是追求最少的请求数量。
服务配置支持多个候选地址,并分别提供语义检索和 AI 总结开关。备用切换也有边界:向量模型必须与索引对应;一次推理如果已发出却超时,不能不加判断就向另一台机器重复提交,造成双份工作。
当前所有的 ollama 都基于上一篇文章提到的 OSS 服务。但系统本身还要同时要兼容普通官方 Ollama。额外调度信息可用时,显示更具体的等待提示;拿不到时,只提供通用状态,不应因为缺少某个自建反馈字段而报错。等待提示的目的,是让用户知道任务处于什么状态,不是虚构一个无法测量的剩余秒数。
十二、这次升级真正改变了什么
回头看,技术路线经历了几次变化:从旧服务器本地 QMD 推理,到远程 Embedding 加本地向量索引;从向量匹配直接总结,到对象范围与业务关系约束;从穷举组合规则,到 AI 制定受控检索计划;从只读第一页,到可分页扩展,再到优先处理明确的一批证据。
这些变化不是不断把模型换大,而是逐步弄清楚哪些工作应该由程序确定,哪些工作适合交给模型。权限、身份、精确统计和查询边界要有确定规则;语言理解、证据归纳和有限补查可以借助模型;最终判断还应允许用户沿引用回到原始资料。
质量与效率也不是简单的对立。先去掉错误对象和无关候选,通常既能更快,也能更准确;但一味提高阈值、缩小窗口,又会漏掉真正有用的线索。比较合理的做法,是把召回、筛选、分析和展示分层,把预算和覆盖范围说清楚,并保留有边界的扩展能力。
目前已经能运行的部分,不代表所有问法都已覆盖。后续需要持续使用包含同名对象、历史称谓、隐含角色、跨模块线索、权限变化和多用户等待的回归样本。下一轮批次优化也应在测试环境验证之后再同步,而不是看到显存还有余量,就把所有限制一起放开。
我最想保留的一个认识是:做出一次漂亮回答很容易,让一个长期存在的系统在不同用户、不同资料和不同表达下,持续给出可核查、等得起的答案,才是这次升级真正要解决的问题。
