Solr父子文档在文档检索中的实战应用
- Solr
- 时间:2026-02-13 11:52
- 43人已阅读
🔔🔔好消息!好消息!🔔🔔
有需要的朋友👉:微信号
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_comprehensive、tag_technical、tag_business(中文分词)目录聚合字段:多值字段,如
outline_title、outline_level,把目录标题全部塞进来,方便直接命中目录关键词全局检索字段:
search_all,通过copyField汇集title+outline_title+content_text(可选),简化跨字段查询
子文档(内容块维度)
parent_id:指向父文档 ID(关键关联)content_text:正文(中文分词,权重最高)header_list/footer_list:表格标题(中文分词,权重次之)page_number、sort_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_text、header_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 即可,记得配高亮。
七、避坑指南:你可能也会遇到
NumberFormatException
现象:查询报错,日志显示 Solr 把子文档 ID 当数字解析。
原因:误用了{!child of}语法,该语法会返回子文档;我们的场景应始终用{!parent which}。
解决:确认语法,并加上-content_block_id:[* TO *]保底。同文档多副本重复显示
现象:相同标题的文档(如不同版本)同时出现在结果里。
解决:应用层去重:按
title+path哈希,内存中去重后重新分页(适合数据量不大)。Solr 分组:新增
title_str不分词字段,用group.field=title_str折叠,代价是需要重索引。长短语召回 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。不同版本语法略有差异,但核心逻辑相通。欢迎留言交流你的搜索踩坑经历。
下一篇: 返回列表