上周,某团队的客服系统收到一条查询:"ERR_BLOCKED_BY_CLIENT"。搜索返回零结果。但数据库里明明躺着包含这串字符的文档——它被嵌入模型索引,被余弦相似度计算,最终排在第47位。前5名被"浏览器错误"之类的泛泛内容占据,真正的答案从未抵达大模型。
这是纯向量检索(向量检索)的典型翻车场景。嵌入模型擅长理解语义,却把标识符、版本号、SKU、堆栈跟踪这些精确token"抹平"进周围的语义邻居里。好消息是:不用二选一。让词法搜索和语义搜索并行运行,融合两份排名,彼此补上盲区。100行Python+Postgres就能实现,零新增基础设施。
![]()
一张表,双索引
核心设计极简。单张chunks表存三样东西:文档原文、用于BM25全文检索的tsvector、以及1536维的OpenAI嵌入向量。两列各自独立建索引。
tsvector列是存储生成列(stored generated column),Postgres在content变更时自动重算,无需触发器或应用层维护。embedding列则在数据入库时由应用层填充。
两个索引分工明确:GIN索引加速词法检索,HNSW索引加速近似最近邻搜索。m=16和ef_construction=64是1536维嵌入的常用默认参数;若离线批量构建索引且追求更高查询召回率,可将ef_construction提至200。
并行检索:两条SQL,一次融合
查询阶段拆成两步。先用psycopg连接数据库,注册pgvector适配器。然后对同一查询文本,分别执行词法检索和语义检索。
词法侧用tsvector的ts_rank_cd打分,语义侧用向量余弦相似度。两者返回各自的文档ID列表与分数,但分值范围完全不同——BM25分数无上限,余弦相似度落在0到1之间。直接比较毫无意义。
这里引入倒数排名融合(倒数排名融合,Reciprocal Rank Fusion)。核心思想:只关心排名,不关心绝对分数。某文档在A列表排第3、在B列表排第10,它的RRF得分就是1/(3+60) + 1/(10+60),常数k=60用于平滑尾部排名的波动。最终按RRF总分重排,取Top-K。
为什么100行就够了
代码量压缩的关键在于Postgres的原生能力。tsvector和pgvector作为扩展,把重型计算下沉到数据库层。Python层只负责:调OpenAI接口做嵌入、拼两条SQL、做RRF的加权求和。没有服务编排,没有向量数据库集群,没有网络跳转。
嵌入模型选用text-embedding-3-small,1536维,成本约为-large的五分之一。作者的建议很直接:除非评估数据证明-large更好,否则别默认付费升级。
谁该警惕这种失败模式
这个方案瞄准的是已经踩过坑的团队。如果你正在用RAG(检索增强生成)做客服搜索、内部知识库或开发者文档,且发现用户 increasingly 用错误代码、版本号、配置键查询却得不到精确匹配——这就是你的场景。
不需要推倒重来。现有Postgres实例加两个扩展,存量数据原地迁移,查询逻辑增量改造。词法索引对短标识符的精确匹配能力,补上了向量索引的结构性盲区。
实用指向:下一步该做什么
检查你的RAG系统是否出现过"明明存在却搜不到"的案例,尤其是包含代码、数字、专有名词的查询。如果有,打开Postgres的查询日志,对比同一查询词在tsvector和向量索引下的返回排名。差距越大,RRF的收益越明显。
改造成本可控:一个工程师半天到一天的原型验证。如果验证通过,生产部署的核心工作是调整k值和Top-K截断点,让两种检索的贡献比例匹配你的数据分布。没有银弹,但100行代码买一个不再漏掉错误代码的搜索系统,这笔账多数团队算得过来。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.