给 800 多个产品做站内 AI 搜索:向量 + 全文 + RRF,大模型只许在候选里挑

0 阅读8分钟

做一个产品导航站 Builderoom,收录了 858 个产品。站内搜索最早只有关键词匹配:输入「deepseek」,立刻就能看到 deepseek,毫秒级。

但还有另一类人,他们不知道产品名,只知道需求,比如「有没有免费的、能在浏览器里用的设计工具」。关键词搜索对这种输入基本无能为力。

截屏2026-11-08 19.02.43.png

于是首页加了一条 AI 通道。这篇记录它的管线设计,和几个上线前就撞上的坑。

第一条原则:不能把问题直接丢给大模型

最省事的做法,是把用户的问题连同一句「你是一个产品推荐助手」交给大模型。问题在于模型不知道站上收录了什么,它会编出一批我们根本没有的产品名。

站内搜索推荐一个站上不存在的东西,比返回空结果严重得多。

所以先把需求拆成两件事:

要做的事交给谁
换了说法也能找到对应的产品向量检索,不是大模型
从一批候选里挑出最合适的几个,并说明为什么大模型,而且只能在候选里挑

整体管线

用户输入(文字,或语音转出来的文字)
  ├─ ① 意图理解:按需调用大模型 → 检索文本 + 可以为空的筛选条件
  ├─ ② 混合召回:纯数据库,不碰模型
  │     ├─ 向量召回   pgvector,取 50 条
  │     ├─ 全文召回   tsvector,取 50 条
  │     └─ RRF 融合,再结合站内质量分 → 20 条候选
  └─ ③ 重排与推荐理由:一次大模型调用 → 最多 6 条,每条一句理由

第②段完全不碰模型,这一点很关键:模型超时、限流、欠费的时候,检索本身照样能用。降级不是事后打的补丁,而是这条管线本来的形状。

模型用的是阿里云百炼上的 qwen3.8-max(意图理解与重排)和 qwen3.7-text-embedding(1024 维向量),共用一个 API Key。

① 意图理解:拿不准就留空

不是每次输入都请大模型。像关键词一样的短输入直接进召回;口语化的句子才调模型;语音转写来的一律调,因为说出来的话必然是口语。

模型输出一个检索文本,外加几个可选的结构化条件:形态(网页应用、移动应用……)、价格(免费、付费)、平台。除了检索文本,其余字段全部可以为空,提示词里明确要求拿不准就留空,不许猜。

猜错一个形态,等于把用户想要的那一整批产品排除在召回之外,而用户看到的只是「没有结果」。

条件筛空了,就丢掉条件重来

这个坑是实测撞出来的。

搜「有没有免费的能在浏览器里用的设计工具」,模型抽出了 pricing = free 和 platform = web,两个都是对这句话的合理理解。但当时站上 109 个已上架产品里,只有 36 个填了平台标签,带 web 的 14 个,标 free 的 11 个。两个条件一叠加,结果是 0 条——而站上其实有一个浏览器里用的矢量编辑器。

用户看到的是「没有符合的产品」,他不会知道是被一个自己没提过的条件挡住的。

所以规则改成:抽出来的条件只在有结果时生效。一旦把召回筛成空,就去掉全部条件重跑一次,并在结果上方标明「以下是放宽条件后的结果」。向量只算一次,重跑时复用,不额外花钱。

这其实不是在给模型兜底,而是在给数据的稀疏兜底。属性标签靠作者填写,覆盖率短期内好不起来,这一层会一直需要。

② 混合召回:两路缺一不可

召回方式擅长不擅长
向量换了说法也能召回,「把设计稿变成页面」能找到设计稿转代码的工具品牌名。搜「Notion」,会把一堆笔记工具排到 Notion 前面
全文品牌名、精确词、输入到一半的词换个说法就找不到

两路结果怎么合并?最直觉的办法是加权求和,但两路分数根本不是一个量纲:余弦相似度在 0 到 1 之间,而且分布很密;ts_rank 没有上界,还会随文档长度变化。给它们配权重,等于在调一个没有稳定解的参数,换一批数据就得重调。

所以用 RRF(倒数排名融合):每一路只贡献名次,不贡献分数。一个产品在两路里都排得靠前,融合后自然靠前。

-- 示意:两路各取 50 条,按名次融合成 20 条候选
-- k = 60 是文献里的惯用值,我们也用它
WITH vec AS (
  SELECT product_id,
         row_number() OVER (ORDER BY embedding <=> $1) AS r
  FROM product_embedding
  ORDER BY embedding <=> $1
  LIMIT 50
), fts AS (
  SELECT p.id AS product_id,
         row_number() OVER (ORDER BY ts_rank(p.search_tsv, q) DESC) AS r
  FROM product p, plainto_tsquery('jiebacfg', $2) q
  WHERE p.search_tsv @@ q
  ORDER BY ts_rank(p.search_tsv, q) DESC
  LIMIT 50
)
SELECT product_id, sum(1.0 / (60 + r)) AS rrf
FROM (SELECT * FROM vec UNION ALL SELECT * FROM fts) t
GROUP BY product_id
ORDER BY rrf DESC
LIMIT 20;

中文全文检索用的是 pg_jieba,而且必须把站内的标签词表灌进用户词典,否则「文生图」会被切成「文生」和「图」。这一块单独写过一篇,这里不展开。

一个必须在第一版就配好的参数

pgvector 的 HNSW 索引扫描是「先取候选、再过滤」。过滤条件很窄的时候,候选可能被全部筛掉。

我在本地用 5 万行 × 1024 维的数据测过:同一个「2% 选择性的过滤 + 取前 50」的查询,强制走 HNSW 索引时——

hnsw.iterative_scan实际返回
off(默认值)0 条(库里符合条件的有 1000 条)
relaxed_order50 条,耗时 49.8 ms

它不报错、不告警,只是什么都搜不到。 几百个产品的时候根本不会出现,因为规划器会直接全表扫描,结果永远正确;要到几万行、过滤条件让规划器改选索引时才会浮出来,也就是上线一段时间之后。

所以:

  • 带过滤条件的向量查询,在事务里显式打开迭代扫描,不依赖默认值。用 SET LOCAL,只对本事务生效,不会污染连接池里的连接;不带过滤时不设,开了只是白扫:
if filters.any():
    await db.execute(text("SET LOCAL hnsw.iterative_scan = relaxed_order"))
    await db.execute(text("SET LOCAL hnsw.max_scan_tuples = 20000"))
  • 向量表里只放「当前能被搜到的产品」,把 status 这个最常见的过滤条件从查询里整个去掉。能踩到这个坑的,就只剩用户自己指定的那几个条件。

③ 重排与推荐理由:三条硬约束

把 20 条候选(名称、一句话介绍、功能标签、分类)交给大模型,一次调用挑出最多 6 条,每条附一句不超过 40 字的推荐理由。

约束怎么保证
只能从候选里选模型输出的 slug 必须在候选集合里。应用层逐条校验,不在集合里的直接丢掉并记日志,不做「模型说有就有」的信任
理由只能引用候选里真实存在的信息提示词里写明「不得提及候选信息中没有的功能、价格、平台」。这一条没法机械校验,只能定期抽查
没有合适的就明说没有不许凑数。返回空推荐和一句说明,前端显示「站上暂时没有符合的产品」

进入重排的候选固定 20 条,不随站上产品数增长。也就是说,重排的成本只和请求量有关,和收录了多少产品无关。

流式输出:让模型一行输出一个对象

整趟下来大约 3 秒。流式不会让它变快,但用户不用对着转圈等到最后:意图理解大约 1 秒先到,推荐结果再一条条追加进来。

难点在于,流式拿到的是一段还没闭合的文本。想从里面解析出「已经写完的那部分」,要么做增量 JSON 解析,要么让模型一行输出一个 JSON 对象(JSON Lines):推荐在前,最后一行是一句总结。后者简单得多,解析不了的行直接丢掉,一行坏数据不会让整次回答作废。

成本、限流和降级

一次 AI 搜索 = 1 次向量化 + 2 次大模型调用。向量非常便宜,成本大头是那两次大模型调用。所以 AI 通道要求登录,并按用户每天限次数;关键词搜索对游客照样完全开放,被收进登录墙的只是 AI 通道。

降级分几级,每一级都不给用户报错,页面上只是少一块内容:

故障行为
重排模型超时或失败展示融合后的候选,不带推荐理由
意图理解失败把原始输入直接当检索文本,跳过结构化过滤
向量服务不可用退回纯全文检索
总开关关闭首页不显示 AI 入口,回到纯关键词搜索

总开关这一条尤其要有。模型欠费、限流、平台抽风都是常态,止血不该需要发一个版本。

最后

这套搜索跑在 Builderoom 的首页上。Builderoom 是独立开发者、设计师与创客的作品展台,网页应用、小程序、App、浏览器扩展、AI 智能体、API / SDK、设计作品都能收录。提交也用上了大模型:贴一个链接(官网、GitHub 仓库或应用商店页),AI 会把介绍、核心功能、分类标签和截图整理好,核对一下就能提交。

用一句话描述你想找的工具,或者点麦克风直接说:

截屏2026-11-08 19.01.31.png

如果你也在做站内搜索,想听听你们是怎么处理「品牌名」和「语义召回」之间的冲突的。