在RAG系统的实际落地中,检索链路“零命中”(0 hits)和“不可解释”简直是两大噩梦。问题往往出在细节上:格式写法的差异能让全文检索(FTS)直接漏掉关键文档,而自然语言里的中文日期更是让结构化召回几乎成了摆设。这次,我们围绕这两个核心痛点,搞了一套组合拳,核心思路就是“可观测”加“兜底”。
简单来说,这次修复的目标很明确:一是让RAG检索不再出现莫名其妙的“零命中”,出现问题也得有迹可循;二是针对“格式写法差异导致FTS漏命中”和“自然语言日期无法命中文档”这两个典型问题,分别落地了B2和B1两套机制,并准备了能通过验收的最小语料。下面,我们来拆解一下具体是怎么做的。
改动摘要:问题、机制与落点
整个改动可以看作一个“问题→机制→落点”的闭环流程,我们一个一个来看。
1) RAG 0 hits / 不可解释
这个问题最让人头疼,因为它像个黑盒子。我们的解法是在检索链路里打上“观测点”,并加上多路兜底。
- 机制: 在检索链路中增加可观测字段,同时引入兜底策略,包括RPC重试、对原始和改写后的query进行双路关键词(keyword)融合,并输出详细的事件统计。
- 落点: 最终在Unified Chat接口的
rag.retrieve输出中,你会看到一系列清晰的指标:vector_hits(向量命中数)、keyword_hits_raw(原始关键词命中数)、keyword_hits_rewrite(改写后关键词命中数)、structured_hits(结构化命中数)、retry_count(重试次数),甚至embedding_error(向量化错误标记)。这下,问题出在哪一目了然。
2) 日期/格式写法差异导致FTS漏命中(B2)
“0-1-0”和“0.1.0”、“v0.1.0”、“2026-4-14”和“2026-04-14”,在人类眼里它们是一回事,但在FTS引擎里,可能就是完全不同的token。B2就是来解决这个“同一实体多写法”的问题。
- 机制: 在索引层增强
fts_tokens,我们不动原始documents.content和documents.embedding,而是通过一个别名机制,让同一个实体的多种写法都能被稳定命中。 - 落点: 具体分几个版本落地:
- B2 v1: 针对日期别名,将
2026-4-14和2026-04-14这类写法归一,包括分隔符和空格的变体。 - B2 v2: 处理版本号和分隔符归一,并对CamelCase(驼峰命名)做轻量级拆分,所有规则都有上限,防止token膨胀。
- B2 v2.1: 更有趣的一招,是针对
0-1-0这种FTS天生不友好的query,在查询端做扩展。
- B2 v1: 针对日期别名,将
3) 自然语言日期(中文数字)无法触发结构化召回(B1)
用户说“二零二六年四月十四号”,系统却一脸懵。B1就是为此而生,它通过解析日期,再用结构化元数据(metadata)召回,保证像查文件一样稳定命中文档。
- 机制: B1通过解析日期,利用metadata做结构化召回。这种“像查文件一样”的方式,稳定性极高。
- 落点: 结构化召回现在要能支持:
二零二六年四月十四号(标准中文数字)贰零贰陆年肆月拾肆号(财务大写数字)四月十四号(缺少年份时,尝试当年与上一年)
4) 缺少可验收语料(RunnableWithMessageHistory)
没有语料,一切优化都成了空中楼阁。我们补充了最小语料,专门用来验收CamelCase和标识符的检索链路。这个语料会通过前端内容仓库新增,然后入库后端的 documents 表,这样才能被检索到。
- 落点: 新增文件
ai-ink-brain/content/learning/2026-04-23/runnable-with-message-history.md。
数据库结构变化
所有的机制最后都要落到数据库上。这次改动主要涉及 public.documents 表和相关的函数。
表:public.documents
这个表大家很熟悉了,核心字段不变:id、content、metadata(里面包含relativePath/slug/filename/chunk_index/category等)、embedding向量、fts_tokens全文向量。
- B1新增/补齐: 在metadata中新增或补全
date_norm和slug_norm字段,方便结构化召回。 - B2相关:
fts_tokens由触发器维护,在生成时会注入别名文本alias_text。
索引
documents_fts_tokens_gin:一个GIN索引,用于加速fts_tokens的查询。
触发器与函数
public.documents_fts_tokens_update():触发器函数,在数据写入或更新时,自动重新计算fts_tokens。public.rag_fts_alias_text(content):这是B2的核心函数,负责生成别名文本,包括日期、版本号、分隔符、标识符的归一化。
RPC函数
public.match_documents(query_embedding, match_count, match_threshold):向量召回。public.keyword_documents(query_text, match_count):FTS召回,内部使用websearch_to_tsquery('simple', ...)。public.refresh_documents_fts_tokens_for_paths(relative_paths text[]):一个非常实用的函数,可以按路径批量刷新fts_tokens,方便在修改语料后快速重建索引。
关键学习点:可复用的经验
这次折腾下来,有几个心得非常值得记录,以后肯定还能用到。
- 不要把别名塞进content/embedding: 这是大忌。它会彻底污染语义,导致后续如果需要修改,还得重新生成全部embedding,成本极高。正确的做法是优先在索引层(
fts_tokens)和结构化层(metadata)上做文章。 - FTS存在天生盲区: 像
0-1-0这样的短横线连接数字,在FTS中很可能被拆分成了单个字符或数字,导致永远0命中。因此,必须要有查询端(query-side)的归一化兜底策略。 - 企业落地是“可观测 + 回归集 + 迭代增强”: 别指望一次性把别名规则做到完美。正确的路线是:遇到失败的检索模式 → 分析原因,增加相应的别名规则 → 回填回归测试集 → 验收通过。这是一个不断迭代增强的过程。
Mermaid图表说明
为了让大家更直观地理解,我们准备了几个流程图,清晰地展示了检索链路和架构设计。

(此处依据原文,应嵌入三张Mermaid图表的描述性文本。由于无法实际渲染,我们将其核心逻辑文字化如下:)
图表一:Unified Chat(RAG)检索链路
这张序列图展示了从用户发起查询到最终生成回答的完整流程。核心点在于,系统会并行或有序地执行三条路径:结构化召回(B1)、向量召回(match_documents)和基于原始/改写query的FTS关键词召回(B2)。所有路径的命中结果和错误信息都会被记录下来,最终通过RRF(倒数排序融合)算法融合,作为上下文提供给LLM生成最终答案。整个过程完全可观测。
图表二:Postgres FTS结构(B2:fts_tokens 生成方式)
这张流程图展示了B2别名层的核心逻辑。原始 content 文本和函数 rag_fts_alias_text 生成的 alias_text 被一同输入 to_tsvector 函数,最终生成增强后的 fts_tokens。整个流程由触发器自动驱动,也支持通过RPC按路径手动刷新。
图表三:B1 vs B2 边界(为何两者都需要)
这张图清晰地说明了B1和B2的职责范围。B1结构化召回用于“确定性定位文档”,比如根据日期、文件名精确查找;B2的FTS别名则负责“同一实体多写法命中”,解决的是格式变体问题。再加上语义相似的向量召回,三者相互补充,共同构成一个健壮的候选命中集合。
