elasticsearch2.3.5深度修改源代码获取dismax每个字段的得分

需求背景

使用dismax查询的特点在于说,可以比较综合的考虑字段得分来进行打分。假设我有以下数据

{
    "title": "阿根廷小组未能出线",
    "content": "今天小组赛结束,阿根廷输给尼日尼亚"
}

那么dismax的查询方式是会比较在字段title和字段content上的得分,得分最大的做为整个文档的得分。如果还不了解dismax的计算方式可以先去谷歌看看文档了解一下,本文从这里开始。
接下来要提一个新需求,我需要得到dismax在title和content上分别的得分,这个得分后续可能用于做一些新的排序和召回的计算,总之我现在希望在查询的时候我能返回这两个字段分别的得分。

总结下我们的需求,浓缩成简单的一句话:拿到title和content在dismax查询下的得分

方案设计

总结清楚了需求和目的,那么接下来就是设计方案的时候,首先我考虑以下几种方案

  1. 通过explain参数拿到explain后解析
  2. 通过aop方式代理类来优雅的修改逻辑
  3. 直接去源代码找到想要的东西
    经过几天不懈的努力,终于证明了三种方式其实都不怎么可行,其中3我做出来了,但是我也不忍直视自己究竟修改了多少源代码,也只是1方式的复杂版。但是这一次源代码探索之旅是学习到很多东西的,也希望有一天成为开源贡献者。
    那么本篇主要讲方法3

源码探索

首先做一个猜测,每个分数和字段都是一一对应的,score->field,那么我找到分数在代码中的位置很可能就能找到字段值

看看我的查询体

{
  "explain": "true",
  "from": 0,
  "size": 1,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "should": [
            {
              "dis_max": {
                "boost": 3,
                "queries": [
                  {
                    "match": {
                      "appNgram1": {
                        "query": "懒人投资",
                        "type": "boolean",
                        "boost": 2.5
                      }
                    }
                  },
                  {
                    "match": {
                      "appNgram2": {
                        "query": "懒人投资",
                        "type": "boolean",
                        "boost": 1.2
                      }
                    }
                  }
                ]
              }
            }
          ]
        }
      },
      "functions": []
    }
  }
}

一段很典型的dismax query
回顾我们的目标,我们要拿到每个字段的分数,那么哪里能看得到分数呢?explain即可(以下是实际数据的分数情况)

"_explanation": {
"value": 5.177436,
"description": "max of:",
"details":……

max of:表示使用max运算,即取最大值的运算。OK我们展开下面的选项,可以很清晰的看到懒人投资四个字分词后分别在每个appNgram1和appNgram2的得分。其实好像我们已经拿到了分数?看下图


两个分数我们无从得知到底哪个分数是appNgram1还是appNgram2。再展开后的是分词后的每个单字在字段中的得分。到这一步,便会产生这样的想法,是不是源代码中可以通过简单的修改添加一两行代码把field显示出来?
很顺利的找到了dismax比较分数的位置,如下

package org.apache.lucene.search;

import org.apache.lucene.index.Term;

import java.io.IOException;
import java.util.List;

public class DisjunctionMaxScorer extends DisjunctionScorer {

    /* Multiplier applied to non-maximum-scoring subqueries for a document as they are summed into the result. */
    private final float tieBreakerMultiplier;

    /**
     * Creates a new instance of DisjunctionMaxScorer
     *
     * @param weight
     *          The Weight to be used.
     * @param tieBreakerMultiplier
     *          Multiplier applied to non-maximum-scoring subqueries for a
     *          document as they are summed into the result.
     * @param subScorers
     *          The sub scorers this Scorer should iterate on
     */
    DisjunctionMaxScorer(Weight weight, float tieBreakerMultiplier, List<Scorer> subScorers, boolean needsScores) {
        super(weight, subScorers, needsScores);
        this.tieBreakerMultiplier = tieBreakerMultiplier;
    }

    @Override
    protected float score(DisiWrapper topList) throws IOException {
        float scoreSum = 0;
        float scoreMax = 0;
        for (DisiWrapper w = topList; w != null; w = w.next) {
            final float subScore = w.scorer.score();
            scoreSum += subScore;
            if (subScore > scoreMax) {
                scoreMax = subScore;
            }
        }
        return scoreMax + (scoreSum - scoreMax) * tieBreakerMultiplier;
    }
}

代码中比较大小处就是dismax分数计算的逻辑,OK分数找到了,那么再找到字段名岂不是皆大欢喜?
没有
debug到图中比较大小的位置,完全无法找到任何field的信息,完全不知道到底是计算哪个字段的分数。topList相当于一个iterator的迭代器,找不到任何字段信息。本来好像近在咫尺,只好做罢。
接下来再看看打分类

……省略

public final class Explanation {

 ……省略

  private final boolean match;                          // whether the document matched
  private final float value;                            // the value of this node
  private final String description;                     // what it represents
  private final List<Explanation> details;              // sub-explanations

  /** Create a new explanation  */
  private Explanation(boolean match, float value, String description, Collection<Explanation> details) {
    this.match = match;
    this.value = value;
    this.description = Objects.requireNonNull(description);
    this.details = Collections.unmodifiableList(new ArrayList<>(details));
    for (Explanation detail : details) {
      Objects.requireNonNull(detail);
    }
  }
……省略
}

只看构造函数,我们很清晰的看得出打分类的构造,通过debug发现,details中包含着子类的打分 ,很明显的树状数据结构,可以递归获取整棵打分树。那么这里能不能拿到对应的字段呢?

还是没有

唯一的String类型变量description,是操作描述符,就是前面的max of:sum of:等等 ,details是树的子节点,value是分数,match是否匹配文档。又失败了。
两个地方都没找到,我开始怀疑我之前的思路是否正确,我重新观察了一下代码和查询出来的结果,得出新的结论。

分数和字段不是一一对应的,dismax运行在appNgram1字段上其实是分解为 appNgram1:懒 appNgram1:人 appNgram1:投 appNgram1:资 我想要的分数是 appNgram1:懒 + appNgram1:人 + appNgram1:投 + appNgram1:资,在es的设计概念中并不存在这个分数对应哪个字段,即使我们肉眼可见全部是在appNgram1上搜索,但就是拿不到。

换句话说,对于ES来说,并不存在我猜测的有存储score->field这样的映射关系,实际上的映射关系是这样的score->field x:term y(x=0,1,2,3……,y=0,1,2,3……)。需求出错了。
但是在我需要的特定场景下,比如dismax的query下,我可以认为我的x是恒定不变的,比如上面的数据中,我所有的term都是在appNgram1这个字段下进行TFIDF计算。那么接下来的思路逻辑虽然很丑陋,却成了不得不做的事情。我原来希望可以不入侵的方式修改源代码,来达到解耦,但是遍寻不到路后也不能坐以待毙,开始尝试暴力修改。

源码修改

这里关于修改源码的两个方式要说一下,也是为以后定制开源组件做准备。

  1. 通过aop方式的修改(aspectJ)
    aop我不多介绍,相较于暴力的修改源码,aop方式不但干净而且解耦,如果依赖升级的情况下自身升级也相对容易。假设我如果需要拦截获取第三方jar包中方法或者类来进行修改的话我一定会选择这种方法。但是有两个缺点。
  • 比如第三方jar包中的类或者方法定义为final的情况下是无法通过aspectJ来拦截的,究其原因是java本身语法上已经定义了final是无法派生出新的东西的。
  • 能改动的逻辑也比较有限,适合轻度修改做补丁之类的工作。
  1. 直接修改源代码
    这里有个问题,第三方依赖包在工程中是以jar包->class文件存在的,是只读(read-only)文件,我们可以通过反编译或者像IDEA一样可以直接去maven仓库下载源代码,通过ctrl+鼠标左键来阅读源代码,但是无法修改。这里有两个方法
  • 在工程目录下建立和要修改的类同包名类名的文件,然后将其源代码拷贝过来,编译后拿class文件替换掉jar包内的class文件。
  • 在工程目录下建立和要修改的类同包名类名的文件,然后将其源代码拷贝过来,然后就可以直接编译使用,工程内的class文件在classloader中的优先级是比第三方jar包来得高的。这个方法要注意一件事,es的源代码是有做jar包冲突检验的,如果像刚才说的这么做会报jar hell!。所以要先做注释掉检查包冲突的代码,具体代码在org.elasticsearch.bootstrap.JarHell中,注释掉154行开始的public static void checkJarHell(URL urls[]) throws Exception这个方法即可。

由于修改的代码太多实在放不过来我只说下思路

做完上述准备工作后开始考虑怎么修改。我想要的是score->field这样的映射关系,目前我知道score的位置,但没有field,所以我的考虑是在org.apache.lucene.search.Explanation这个类中增加一个field的字段,在计算得分的时候同时保存得分来自于哪个字段。以下是修改过的Explanation类的构造函数

    /** Create a new explanation  */
    private Explanation(boolean match, float value, String description, Collection<Explanation> details, String field) {
        this.field = field;
        this.match = match;
        this.value = value;
        this.description = Objects.requireNonNull(description);
        this.details = Collections.unmodifiableList(new ArrayList<>(details));

        for (Explanation detail : details) {
            Objects.requireNonNull(detail);
        }
    }

DEBUG过程中查看哪个位置创建了Explanation,寻找field字段加入,比如下面的org.apache.lucene.search.BooleanWeight中经过我修改的explain方法

    @Override
    public Explanation explain(LeafReaderContext context, int doc) throws IOException {
  ……省略上面部分
        if (fail) {
            return Explanation.noMatch("Failure to meet condition(s) of required/prohibited clause(s)", subs);
        } else if (matchCount == 0) {
            return Explanation.noMatch("No matching clauses", subs);
        } else if (shouldMatchCount < minShouldMatch) {
            return Explanation.noMatch("Failure to match minimum number of optional clauses: " + minShouldMatch, subs);
        } else {
            //重点看这里 我修改的地方 当分数是来自于sum of:计算的时候我们会保留field信息到Explanation中
            String field = "";
            if (subs.size() > 0){
                field = subs.get(0).field;
            }
            Explanation result = Explanation.match(sum, "sum of:",field, subs);
            final float coordFactor = disableCoord ? 1.0f : coord(coord, maxCoord);
            if (coordFactor != 1f) {
                result = Explanation.match(sum * coordFactor, "product of:", field,
                    result, Explanation.match(coordFactor, "coord("+coord+"/"+maxCoord+")", field));
            }
            return result;
        }
    }

上述代码是类中explain部分,通过定制修改保留下field信息后,其实就相当于在最后获得的Explanation树中可以获得分数所属的field,还需要从lucene中挖出来修改的类包括DisjunctionMaxQuery,TermQuery,DisjunctionMaxScorer等等。最后要在org.elasticsearch.search.internal.InternalSearchHit中补充以下代码用于最后展示

    @Override
    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
……省略
        //测试测试测试
        if (explanation() != null){
            builder.field("flag");
            builder.startObject();
            StringBuffer sb = new StringBuffer();
            Explanation[] details = explanation().getDetails();
            for (Explanation detail : details) {
                if (detail.getDescription().contains("sum of:")
                        && detail.field != null
                        && !detail.field.equals("")){
//                sb.append("field:" + explanation.field + "  value:" + explanation.getValue());
                    builder.field(detail.field, detail.getValue());
                }
            }
            builder.endObject();
        }


        if (innerHits != null) {
            builder.startObject(Fields.INNER_HITS);
            for (Map.Entry<String, InternalSearchHits> entry : innerHits.entrySet()) {
                builder.startObject(entry.getKey());
                entry.getValue().toXContent(builder, params);
                builder.endObject();
            }
            builder.endObject();
        }
        builder.endObject();
        return builder;
    }

我们在拿到得分的时候寻找带有sum of:操作的子节点并构造到xcontent结构体
最后上一下最终结果

flag.png

小结

到最后绕了非常远的道,但其实做成的原理和解析json的原理没什么区别,源代码改的七零八落,自己看着都觉得不舒服。但是这个尝试还是非常有必要的,我身体力行的翻了一遍es源码,把整个搜索链路研究清楚后,才能给出原来的需求是错误的结论,实践出真知。如果有想要讨论es源码的人非常欢迎联系我~我很乐意一起学习。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • 被寒风吹打过的校园今晚显得格外寒冷,树上那几片还未被严霜寒雪吹打下来的树叶在枝头摇晃着,显得格外的冷清和孤伶。这个...
    冉灵阅读 247评论 2 3
  • 每天从这里路过 作者娄春青(滨州康和) 每天 路过那片花园 花 开了 又谢了 随...
    爱还来不及怎舍得伤你阅读 116评论 0 0
  • 我是一朵莲在佛祖面前静修 我在水中开花了只为你将我采摘 你的出现让时光遇见了美 你的采摘让岁月拥抱了温暖
    心花园子阅读 138评论 0 2
  • 西风古战场,提剑试锋芒。 云动八荒外,刃向四海扬。 不怒自生威,长风堪破浪。 大国有雄兵,鼠辈应胆丧。
    垚君阅读 341评论 0 3