🤖
AI审核中

一个“搜不到”的问题,重构了整个搜索链路

Java 23分钟 118浏览 0评论

在博客系统里,搜索通常被视为一个很简单的功能:用户输入关键词,后端执行查询,再把结果展示出来。

但“能搜”与“搜得可靠”并不是一回事。

在这个项目中,我遇到过一个很有代表性的现象:博客首页明明存在多篇标题包含“AI”的文章,搜索 AI 后却进入了空结果页。更容易误导用户的是,页面显示的还是“页面不见啦”,看起来像文章被删除或者地址错误,而不是一次正常但无匹配项的搜索。

这个问题表面上只是两字母关键词失效,背后却同时涉及:

  • MySQL 全文检索的分词规则
  • 两套检索条件错误叠加
  • URL 路径与特殊字符编码
  • 查询总数和分页列表的一致性
  • 缓存键隔离
  • 搜索空状态的产品语义
  • 旧地址兼容与回归测试

本文以项目中的真实重构为例,说明如何把一个“偶尔搜不到”的搜索框,改造成一条行为明确、结果稳定、可以被自动化验证的搜索链路。

一、问题现象:HTTP 200 不代表搜索正常

最初排查时,请求 /search/AI 返回的是 HTTP 200,控制器和模板也都正常执行,没有 404,也没有数据库异常。

然而页面结果为空。

这类问题比直接报错更难发现,因为监控系统通常只会看到“请求成功”。如果测试只验证状态码,也会得到一个错误的结论:搜索功能正常。

真正需要验证的是用户可见结果:

  1. 标题、摘要或正文包含关键词的已发布文章是否被返回;
  2. 大小写不同是否影响结果;
  3. %_ 等字符是否会意外变成 SQL 通配符;
  4. 翻页后是否仍然保持同一个关键词;
  5. 无结果时是否显示正确的搜索提示,而不是 404 文案。

这说明搜索测试不能停留在“接口可访问”,而要覆盖从输入到最终 HTML 的完整链路。

二、根因:把全文检索和模糊匹配做成了交集

原来的查询思路可以简化为:

WHERE MATCH(article_title, article_content)
      AGAINST (#{keywords} IN NATURAL LANGUAGE MODE)
AND (
    article_title LIKE CONCAT('%', #{keywords}, '%')
    OR article_summary LIKE CONCAT('%', #{keywords}, '%')
    OR article_content LIKE CONCAT('%', #{keywords}, '%')
)

它看起来像是“双保险”:先使用全文索引,再用 LIKE 确认文章中确实包含关键词。

实际上,这里得到的是两个条件的交集,而不是兜底关系。

flowchart LR
    A[用户输入 AI] --> B[全文检索 MATCH]
    A --> C[可见文本包含判断]
    B --> D{两者同时成立}
    C --> D
    D -->|是| E[返回文章]
    D -->|否| F[空结果]

全文检索并不是简单的字符串包含判断。它会受到存储引擎、分词器、停用词、最小 token 长度和相关度计算等因素影响。像 AI 这样的短关键词,有可能没有进入有效的全文检索 token 集合。

一旦 MATCH 返回不匹配,即使标题里的确包含 AI,后面的 LIKE 条件也没有机会“救回”这篇文章,因为 SQL 要求两组条件同时为真。

因此,真正的问题不是 LIKE 失效,而是系统把一种可能丢弃短词的检索算法放在了强制过滤位置。

这次重构最重要的设计判断是:

用户能否搜到文章,应由可解释的文本包含规则决定;全文检索只负责辅助排序,不能决定文章是否有资格进入结果集。

三、先定义搜索契约,而不是先改 SQL

如果没有明确契约,修复很容易退化为针对 AI 写一个特殊分支,例如:关键词长度小于 3 时用 LIKE,其他情况继续全文检索。

这种方案本质上仍然依赖数据库当前的分词配置,也会制造新的边界:为什么是 3?中文、英文、数字和符号是否采用同一规则?配置变化后代码是否仍然成立?

因此,本次改造没有为 AI 设置任何硬编码特例,而是先统一搜索行为:

  • 搜索范围:文章标题、摘要和正文可见文本;
  • 数据范围:只返回已发布的正式文章;
  • 匹配方式:不区分英文字母大小写;
  • 特殊字符:按用户输入的普通字符处理;
  • 排序优先级:标题完全匹配、标题包含、摘要包含、正文包含;
  • 次级排序:全文相关度和发布时间;
  • 空白输入:不访问数据库,直接返回空分页;
  • 越界页码:自动归一到有效范围;
  • 旧搜索地址:继续可用,避免历史链接失效。

契约明确后,控制器、服务层、Mapper、模板和测试才能围绕同一个结果工作。

四、入口治理:关键词应该放在查询参数里

旧地址采用路径变量:

/search/AI
/search/AI/2

路径变量适合资源标识,但搜索词是用户输入,可能包含空格、斜杠、问号、百分号、中文和其他特殊字符。把它直接拼进路径会增加路由歧义,也更容易出现编码不一致。

新的主入口改成了标准查询参数:

/search?q=AI&page=2

控制器同时保留旧路由,并统一委托给同一个渲染方法:

@GetMapping("search")
public String searchQuery(
        Model model,
        @RequestParam(name = "q", defaultValue = "") String keywords,
        @RequestParam(name = "page", defaultValue = "1") Integer page,
        HttpServletRequest request) {
    return renderSearch(model, keywords, page, request);
}

@GetMapping("search/{keywords}")
public String search(Model model,
                     @PathVariable String keywords,
                     HttpServletRequest request) {
    return renderSearch(model, keywords, 1, request);
}

浏览器端不能直接拼接原始输入,而要统一编码:

window.location.href = '/search?q=' + encodeURIComponent(keyword);

这不是单纯的 URL 美化。统一入口可以避免页头搜索、页脚搜索、移动端搜索和分页分别实现不同的编码规则。

五、服务层治理:归一化、分页和缓存必须协同

用户输入往往带有不可见差异,例如:

AI 评论
  AI   评论  
AI\t评论

从搜索意图看,它们应当是同一个关键词。因此服务层会先去除首尾空白,再把连续空白压缩为一个空格:

private String normalizeSearchKeyword(String keywords) {
    if (keywords == null) {
        return "";
    }
    return WHITESPACE_PATTERN
            .matcher(keywords.trim())
            .replaceAll(" ");
}

归一化后再处理分页边界:

int safePage = page == null || page < 1 ? 1 : page;
int safeLimit = limit == null || limit < 1 ? 10 : limit;

if (keyword.length() == 0) {
    return buildArticleSearchPage(
            Collections.emptyList(), safePage, safeLimit, 0);
}

空关键词直接返回,既避免一次没有意义的全表扫描,也防止空字符串被解释为“匹配全部文章”。

查询时先获取总数。总数为零,就不再执行列表查询:

Long totalResult = articleMapperCustom.countSearchByVisibleText(
        keyword,
        PostType.POST_TYPE_POST.getValue(),
        ArticleStatus.PUBLISH.getStatus()
);

long total = totalResult == null ? 0 : totalResult;
if (total == 0) {
    return buildArticleSearchPage(
            Collections.emptyList(), 1, safeLimit, 0);
}

如果请求页码超过最后一页,则落到最后一个有效页:

int pages = (int) ((total + safeLimit - 1) / safeLimit);
int pageNum = Math.min(safePage, pages);
int offset = (pageNum - 1) * safeLimit;

缓存键也需要包含关键词、页码和每页数量:

@Cacheable(
    value = "articles",
    key = "'visibleSearch:'+#keywords+':'+#page+':'+#limit"
)

否则,不同关键词或不同页码可能错误复用同一份结果。更换缓存键版本还能隔离修复前已经写入的错误缓存,避免代码上线后仍短暂展示旧结果。

六、SQL 重构:字面匹配负责召回,全文检索负责排序

新的查询使用 LOCATE 判断关键词是否出现在标题、摘要或正文中:

WHERE (
    LOCATE(
        LOWER(#{keywords}),
        LOWER(COALESCE(article_title, ''))
    ) > 0
    OR LOCATE(
        LOWER(#{keywords}),
        LOWER(COALESCE(article_summary, ''))
    ) > 0
    OR LOCATE(
        LOWER(#{keywords}),
        LOWER(REGEXP_REPLACE(
            COALESCE(article_content, ''),
            '<[^>]+>',
            ''
        ))
    ) > 0
)
AND article_post = #{post}
AND article_status = #{status}

这里有四个细节。

1. LOWER 统一大小写

搜索 AIai 会得到一致结果,不依赖数据库当前列的排序规则是否区分大小写。

2. COALESCE 处理空字段

标题、摘要或正文为 NULL 时,将其转为空字符串,避免整个表达式变成 NULL

3. 正文先去除 HTML 标签

文章正文存储的是 HTML。搜索面向用户可见内容,因此先使用 REGEXP_REPLACE 去除标签,再执行包含判断,避免标签名本身干扰结果。

4. 使用 LOCATE 而不是动态 LIKE

如果使用:

LIKE CONCAT('%', #{keywords}, '%')

用户输入中的 %_ 会天然拥有 SQL 通配语义。除非额外编写转义规则,否则用户搜索的就不再是原始字面值。

LOCATE(needle, haystack) 直接查找字符串位置,%_ 都只是普通字符,搜索契约更容易解释,也更容易测试。

七、排序不能只看“匹配了没有”

可靠搜索不仅要召回正确结果,也要把更相关的文章放在前面。

本次重构使用分层排序:

ORDER BY
    CASE
        WHEN LOWER(COALESCE(article_title, ''))
             = LOWER(#{keywords}) THEN 0
        WHEN LOCATE(
             LOWER(#{keywords}),
             LOWER(COALESCE(article_title, ''))
        ) > 0 THEN 1
        WHEN LOCATE(
             LOWER(#{keywords}),
             LOWER(COALESCE(article_summary, ''))
        ) > 0 THEN 2
        ELSE 3
    END,
    MATCH(article_title, article_content)
        AGAINST (#{keywords} IN NATURAL LANGUAGE MODE) DESC,
    article_newstime DESC

这里保留了全文检索,但它只出现在 ORDER BY 中。

即使全文检索无法识别短词,它也只会失去一项相关度分值,不会把本应命中的文章从结果集中删除。最终的发布时间排序还能保证相关度相同时结果稳定。

这就是“全文检索辅助排序”和“全文检索强制过滤”的本质区别。

八、总数查询与列表查询必须使用同一套条件

分页经常出现一种隐蔽错误:列表查询已经改了,COUNT(*) 仍沿用旧条件。

这会造成:

  • 页面显示有 10 页,但后面几页没有内容;
  • 列表有结果,总数却是 0;
  • 上一页、下一页状态错误;
  • 请求越界后 offset 计算不稳定。

因此,计数查询同样使用标题、摘要和正文的 LOCATE 条件,并使用完全相同的文章类型和发布状态过滤。

一个实用原则是:

除了 SELECTORDER BYLIMIT,分页列表与计数查询的过滤语义必须一致。

九、空结果不是 404,页面文案也是搜索契约的一部分

搜索不到文章是一种正常业务状态,而 404 表示目标资源不存在。两者不能共用同一套提示。

旧页面显示:

哎呀,页面不见啦!
我们找不到您要访问的内容

这会让用户误以为链接错误或文章被删除。

新的搜索空状态改为:

没有找到相关文章
当前关键词没有匹配到已发布文章

同时提供可执行建议:

  • 检查关键词是否输入错误;
  • 尝试标题、摘要或正文中的其他表达。

分类页仍然保留原来的内容不存在提示,通过页面类型进行语义区分,而不是复制两套模板。

分页链接也针对搜索使用查询参数:

<a th:href="${type == '搜索'}
    ? @{/search(q=${keywords},page=${i})}
    : @{/{url}/{page}(url=${url},page=${i})}">
</a>

这样既保持搜索词的正确编码,也不影响分类和标签页面原有路由。

十、测试策略:从方法正确到用户真的能搜到

这类问题不能只写一条 Mapper 测试。最终采用了四层验证。

第一层:服务层单元测试

使用 Mockito 验证:

  • 连续空白会被归一化;
  • 空关键词不会访问数据库;
  • %_ 会原样传给字面匹配查询;
  • 超出范围的页码会落到最后一页;
  • Mapper 返回空总数时不会继续查询列表;
  • 手工构造的 PageInfo 总数、页数和前后页状态正确。

第二层:SQL 与模板契约测试

契约测试直接检查 Mapper XML 和模板资源,防止未来维护时重新引入旧问题:

  • WHERE 中不能再次出现强制 MATCH
  • 计数查询不能使用不同的匹配逻辑;
  • 搜索入口必须使用 encodeURIComponent
  • 新旧搜索路由必须同时存在;
  • 空结果页必须使用搜索文案;
  • 搜索分页必须继续携带 q 参数。

第三层:Blog 全量回归测试

搜索页面复用了文章卡片、分类模板、公共页头和页脚,因此不能只跑新增测试。最终 Blog 模块执行了 168 项测试,评论、后台管理、文章 Mermaid、移动端交互和其他页面契约全部通过。

第四层:真实应用与数据库验证

在 8091 启动真实应用后,验证最终 HTML,而不是只看 Java 方法返回值:

场景 预期结果
AI 返回标题或正文包含 AI 的文章
ai AI 结果一致
AI 评论 归一化后正常搜索
%_ 按普通字符处理,不发生 SQL 通配
不存在的长关键词 HTTP 200,显示搜索空状态
/search/AI 旧地址继续可用
page=0 自动归一到第一页
page=2 返回第二页并保留关键词

这一步非常关键,因为只有真实数据库才能证明短关键词问题确实被解决,只有最终 HTML 才能证明 Thymeleaf 表达式和用户文案确实生效。

十一、性能取舍:可靠性优先,但要知道下一步往哪里走

LOCATE + LOWER + REGEXP_REPLACE 的优势是行为稳定、容易解释,并且不依赖全文分词器是否收录短词。代价是数据库较难直接利用普通索引,文章规模很大时会出现扫描成本。

对于个人博客或中小规模内容库,这是可以接受的正确性优先方案。如果数据继续增长,可以按以下方向演进:

  1. 在写入文章时生成纯文本正文,避免每次查询执行 HTML 正则清理;
  2. 增加规范化标题、摘要和正文搜索列;
  3. 根据实际语言选择 MySQL ngram 全文解析器;
  4. 为搜索建立独立倒排索引,而不是在业务表上实时计算;
  5. 数据量和查询复杂度进一步增加时,再评估 Elasticsearch 或 OpenSearch;
  6. 使用真实查询日志建立关键词回放集,持续检查召回率和排序质量。

需要注意的是,升级搜索引擎不能替代搜索契约。无论底层使用 MySQL 还是专用搜索服务,都必须先回答:什么算匹配、什么内容可见、特殊字符如何处理、结果如何排序、无结果如何反馈。

十二、总结

这次故障最有价值的地方,不是修好了 AI 两个字,而是暴露了一个常见工程误区:把多个看似合理的搜索条件叠加,并不一定更准确,有时只会让召回范围越来越窄。

最终方案没有针对某个关键词设置特殊分支,而是重新划分职责:

  • 查询参数负责安全传递用户输入;
  • 服务层负责归一化、缓存和分页边界;
  • 字面包含规则负责稳定召回;
  • 全文检索负责辅助排序;
  • 模板负责区分搜索空状态和资源不存在;
  • 自动化测试与真实运行验证共同证明结果。

一个可靠的搜索功能,不只是“一条能运行的 SQL”。它是一份从输入、召回、排序、分页到页面反馈都保持一致的系统契约。

当用户再次搜索 AI 时,他不需要知道全文索引、分词器或分页对象的存在。他只需要看到本来就应该被找到的文章——这正是搜索功能最基本,也最重要的工程目标。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论