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

需求背景

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

{

"title": "阿根廷小组未能出线",

"content": "今天小组赛结束,阿根廷输给尼日尼亚"

}

那么dismax的查询方式是会比较在字段title和字段content上的得分,得分最大的做为整个文档的得分。如果还不了解dismax的计算方式可以先去谷歌看看文档了解一下,本文从这里开始。

接下来要提一个新需求,我需要得到dismax在title和content上分别的得分,这个得分后续可能用于做一些新的排序和召回的计算,总之我现在希望在查询的时候我能返回这两个字段分别的得分。

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

方案设计

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

通过explain参数拿到explain后解析

通过aop方式代理类来优雅的修改逻辑

直接去源代码找到想要的东西

经过几天不懈的努力,终于证明了三种方式其实都不怎么可行,其中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 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 details; // sub-explanations

/** Create a new explanation */

private Explanation(boolean match, float value, String description, Collection 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计算。那么接下来的思路逻辑虽然很丑陋,却成了不得不做的事情。我原来希望可以不入侵的方式修改源代码,来达到解耦,但是遍寻不到路后也不能坐以待毙,开始尝试暴力修改。

源码修改

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

通过aop方式的修改(aspectJ)

aop我不多介绍,相较于暴力的修改源码,aop方式不但干净而且解耦,如果依赖升级的情况下自身升级也相对容易。假设我如果需要拦截获取第三方jar包中方法或者类来进行修改的话我一定会选择这种方法。但是有两个缺点。

比如第三方jar包中的类或者方法定义为final的情况下是无法通过aspectJ来拦截的,究其原因是java本身语法上已经定义了final是无法派生出新的东西的。

能改动的逻辑也比较有限,适合轻度修改做补丁之类的工作。

直接修改源代码

这里有个问题,第三方依赖包在工程中是以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 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 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源码的人非常欢迎联系我~我很乐意一起学习。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值