Solr父子文档在文档检索中的实战应用

  • 作者: 凯哥Java(公众号:凯哥Java)
  • Solr
  • 时间:2026-02-13 11:52
  • 43人已阅读
简介 Solr父子文档在文档检索中的实战应用从需求到实现,解决文档级全文检索的五个关键问题大家好,我是负责公司知识库搜索模块的后端开发。最近我们重构了文档检索功能,目标是让用户搜索“文档”本身,而不是零散的内容片段。今天就把这套基于Solr父子文档+EDisMax的方案拆开揉碎,分享给正在做类似需求的朋友。一、业务场景:搜的是“文档”,不是“段落”我们的文档库包含大量技术规程、作业指导书,每篇文档有标题

🔔🔔好消息!好消息!🔔🔔

有需要的朋友👉:微信号 kaigejava2022

Solr父子文档在文档检索中的实战应用

从需求到实现,解决文档级全文检索的五个关键问题

大家好,我是负责公司知识库搜索模块的后端开发。最近我们重构了文档检索功能,目标是让用户搜索“文档”本身,而不是零散的内容片段。今天就把这套基于 Solr 父子文档 + EDisMax 的方案拆开揉碎,分享给正在做类似需求的朋友。


一、业务场景:搜的是“文档”,不是“段落”

我们的文档库包含大量技术规程、作业指导书,每篇文档有标题、标签、目录结构,正文被切分为多个内容块(如章节、表格)。用户输入一段话,期望的是:

  • 按文档维度返回列表:同一文档里命中 10 处,只算 1 个文档,不能重复刷屏;

  • 全字段检索:标题、标签、目录标题、正文、表头、页脚,哪儿命中都行;

  • 分页按文档数算:total 是文档总数,翻页在文档列表上翻;

  • 中文长短语友好:“10kV 带电作业安全距离”这种长词,不能因为分词太碎而召回 0 条。

传统方案里,如果直接对内容块建索引,搜出来全是段落 ID,前端还得按文档聚合、手动分页,既麻烦又容易出错。所以我们重新设计了索引结构——Solr 父子文档


二、整体架构:一条清晰的数据流水线

关系库 → 同步工具 → Solr(父子文档) → 查询接口 → 前端展示
  • 数据源:MySQL,三张核心表:文档表、目录表、内容块表。

  • 索引层:Solr 8.11,父文档存文档级信息,子文档存每个内容块。

  • 同步层:Java 定时任务,将三张表 JOIN 后灌入 Solr。

  • 查询层:接口接收关键词、分页参数,构造 {!parent which=...} 查询,强制返回父文档。

  • 展示层:列表显示文档卡片,点击进入详情再高亮具体内容块。

这套流水线的核心,就是 “子文档命中,父文档现身”


三、数据建模:父子文档怎么设计?

1. 数据库表(简化版)

  • doc_documents:文档 ID、标题、标签、浏览/下载数……

  • doc_outline_nodes:目录节点 ID、所属文档、父节点、标题、层级、页码。

  • doc_content_blocks:内容块 ID、所属文档、关联目录、正文、表头/页脚、页码、排序。

一个文档 → 多个目录节点 → 多个内容块,典型的一对多。

2. Solr 父子文档字段设计

父文档(文档维度)

  • 常规字段:title(中文分词)、image(不分词)、各类计数(plongs/pint

  • 标签字段:tag_comprehensivetag_technicaltag_business(中文分词)

  • 目录聚合字段:多值字段,如 outline_titleoutline_level,把目录标题全部塞进来,方便直接命中目录关键词

  • 全局检索字段search_all,通过 copyField 汇集 title + outline_title + content_text(可选),简化跨字段查询

子文档(内容块维度)

  • parent_id:指向父文档 ID(关键关联)

  • content_text:正文(中文分词,权重最高)

  • header_list / footer_list:表格标题(中文分词,权重次之)

  • page_numbersort_order:用于排序和高亮定位

为什么把目录信息也冗余到父文档?
因为用户搜目录标题时,我们希望直接由父文档命中,不需要走子文档再折返。这是提升性能的小技巧。


四、索引同步:如何把关系数据变成父子文档?

同步步骤并不复杂,核心是 聚合 + 分批次

// 伪代码示意
List<DocDocument> docs = documentMapper.listAll();
for (DocDocument doc : docs) {
    SolrInputDocument parentDoc = new SolrInputDocument();
    parentDoc.setField("id", "doc_" + doc.getId());
    parentDoc.setField("title", doc.getTitle());
    // ... 其他元数据
    
    // 聚合目录标题为多值字段
    List<String> outlineTitles = outlineMapper.getTitlesByDoc(doc.getId());
    parentDoc.setField("outline_title", outlineTitles);
    
    // 聚合标签
    parentDoc.setField("tag_comprehensive", doc.getComprehensiveTags());
    
    // 添加父文档
    solrClient.add(parentDoc);
    
    // 子文档:每个内容块一个 SolrInputDocument,parent_id 指向父文档 id
    List<DocContentBlock> blocks = blockMapper.listByDoc(doc.getId());
    for (DocContentBlock block : blocks) {
        SolrInputDocument childDoc = new SolrInputDocument();
        childDoc.setField("id", "block_" + block.getId());
        childDoc.setField("parent_id", "doc_" + doc.getId());
        childDoc.setField("content_text", block.getContentText());
        // ... 其他字段
        solrClient.add(childDoc);
    }
}
solrClient.commit();

注意点:

  • 父文档 ID 加前缀 doc_,子文档 ID 加前缀 block_,避免冲突且方便识别。

  • del_flag 用于逻辑删除,父文档必须显式设置 del_flag:0,查询时过滤。

  • 如果文档量大,务必分页同步,防止 OOM。


五、查询实现:最难啃的骨头

接口 /doc/search 接收 keyword 和分页参数,必须做到:

  • 命中任一字段就返回父文档

  • 分页按文档数,而不是按内容块

  • 中文长短语既要精准匹配靠前,又要容忍分词差异

1. 查询构建的两条腿

第一条腿:父文档直接命中

parentQuery: (search_all:关键词 OR tag_comprehensive:关键词 ...)

适用于标题、目录、标签——这些字段都冗余在父文档里,直接搜即可。

第二条腿:子文档命中,父文档返回
这是父子文档的精华,语法如下:

{!parent which="del_flag:0" v='{!edismax qf="content_text header_list footer_list" pf="content_text^8 header_list^5" ps=2 mm=70%}关键词'}

拆解一下:

  • {!parent which="del_flag:0"}:找子文档,但返回满足 which 条件的父文档。

  • v='{!edismax ...}':内部子查询使用 EDisMax 解析器,对 content_textheader_list 等字段检索。

  • pf 短语字段:content_text^8 表示正文里短语匹配得分权重 8,header_list^5 权重 5。

  • ps 短语斜率:2,允许两个词之间插 1 个无关词。

  • mm 最小匹配:70%,防止因一个词不匹配就整句召回 0。

两条腿用 OR 连接,就是完整的主查询。

2. 分页与去重的关键技巧

q: (parentQuery) OR (子文档查询)
fq: del_flag:0
fq: -content_block_id:[* TO *]   // 强制只返回父文档,重要!
start: 0
rows: 10

加上 -content_block_id:[* TO *] 这个 filter query,Solr 会在分布式合并结果后,剔除所有子文档,剩下纯父文档。此时 start 和 rows 就是对父文档集合的分页,numFound 就是文档总数——完美符合需求。

3. 参数调优:告别 0 召回

上线前我们遇到两个典型问题:

  • 太严格mm=100% + q.op=AND,用户输入“表 1 作业人员……风险控制值”,分词后“表”“作业人员”“风险”任何一个不在正文里,整条查询 0 召回。
    → 改为 mm=70%,允许部分词缺失。

  • 短语权重不足:用户希望“表 1 风险控制值”这种完整短语排最前面,但之前没配 pf,导致拆成单词召回一堆无关文档。
    → 加上 pf,并给 content_text 较高权重,短语匹配的文档自然置顶。

目前稳定在 mm=70% + pf=content_text^8,兼顾召回率和准确率。


六、接口设计:两个端点各司其职

  • /doc/search:文档列表综合检索。返回 total(文档数)、rows(文档卡片数据)。

  • /doc/search-solr/{documentId}:文档内搜索。针对单一文档,用 fq=parent_id:doc_xxx 限制范围,返回内容块级别的高亮片段,用于详情页的“在文档中定位”。

后者是纯子文档查询,不需要 parent 解析器,直接用普通 EDisMax 即可,记得配高亮。


七、避坑指南:你可能也会遇到

  1. NumberFormatException
    现象:查询报错,日志显示 Solr 把子文档 ID 当数字解析。
    原因:误用了 {!child of} 语法,该语法会返回子文档;我们的场景应始终用 {!parent which}
    解决:确认语法,并加上 -content_block_id:[* TO *] 保底。

  2. 同文档多副本重复显示
    现象:相同标题的文档(如不同版本)同时出现在结果里。
    解决

    • 应用层去重:按 title + path 哈希,内存中去重后重新分页(适合数据量不大)。

    • Solr 分组:新增 title_str 不分词字段,用 group.field=title_str 折叠,代价是需要重索引。

  3. 长短语召回 0,但文档明明有
    现象:用户反馈“我复制正文里的句子都搜不到”。
    解决:大概率是 mm 太高或 pf 缺失。先调 mm=70%,观察 debug 输出;若分词过于细碎,可考虑在 Solr schema 中增加 text_ik_max_word 类型。


八、总结:父子文档模式,真香!

这套方案上线后,日均搜索请求从几百涨到三千,用户反馈“搜得准”“不重样”。回过头看,父子文档 + EDisMax 的组合之所以好用,是因为它精准映射了业务模型

  • 父文档 = 文档实体

  • 子文档 = 内容碎片

  • 查询逻辑 = 碎片命中,实体胜出

不需要在应用层做复杂的聚合去重,Solr 原生机制帮我们搞定了文档维度的检索和分页。如果你也在做文档库、知识库、法规库这类“实体+碎片”型搜索,不妨试试这个模式。

最后,参数调优没有银弹。用 EDisMax,先保召回(mm 设低),再调精准(pf 提权),配合线上真实 query 反复迭代,总能找到最适合你业务数据的平衡点。


以上代码示例基于 Java + Solr 8.11,MySQL 5.7。不同版本语法略有差异,但核心逻辑相通。欢迎留言交流你的搜索踩坑经历。


TopTop