【Elasticsearch】使用 Java API Client 實作 function score


https://unsplash.com/photos/_DyUcJalGAc

上一篇文章中,我們用 ElasticSearch 的 Java API Client 撰寫查詢條件。而在搜尋之餘,本文將示範如何透過這款 library 使用 function score,替 document 進行評分。



一、程式專案介紹

本文的範例專案將放置於 GitHub 上,repo 連結在此

(一)資料類別介紹

本文所示範的是將學生資料存放到 ES,因此筆者設計了一個學生類別(Student),其內部還包含課程(Course)與職務(Job)的資料。

public class Student {
private String id; // 學生編號,可當作 document id
private String name; // 姓名
private List<String> departments; // 科系
private List<Course> courses; // 修習課程
private int grade; // 年級
private int conductScore; // 操行成績
private Job job; // 職務
private String introduction; // 自我介紹
private Date englishIssuedDate; // 英文檢定通過日
private String bloodType; // 血型
private List<String> phoneNumbers; // 電話號碼
// getter, setter...
public static class Course {
private String name; // 課程名稱
private int point; // 學分數
// getter, setter...
}
public static class Job {
private String name; // 職務名稱
private Boolean primary; // 是否主要 (正, 副)
// getter, setter...
}
}
view raw Student.java hosted with ❤ by GitHub

(二)範例資料

由於會在 ES 上新增 document,因此準備了以下範例資料。

[
{
"id": "103",
"name": "Vincent Zheng",
"departments": ["資訊管理", "財務金融"],
"courses": [
{ "name": "計算機概論", "point": 3 },
{ "name": "程式設計", "point": 4 },
{ "name": "投資學", "point": 3 },
{ "name": "會計學", "point": 0 }
],
"grade": 2,
"conductScore": 86,
"job": {
"name": "班長",
"primary": true
},
"introduction": "I have a blog used to record what I learn in my career. All of them are about information technology and programming.",
"englishIssuedDate": "2021-01-01",
"bloodType": "A",
"phoneNumbers": [
"0988123456",
"0224123456"
]
},
{
"id": "101",
"name": "Dora Pan",
"departments": ["財務金融"],
"courses": [
{ "name": "財金概論", "point": 3 },
{ "name": "保險學", "point": 3 },
{ "name": "投資學", "point": 3 }
],
"grade": 4,
"conductScore": 74,
"job": {
"name": "衛生股長",
"primary": null
},
"introduction": "Wealth ignores those who ignore it. So I apply knowledge about accounting in my life.",
"englishIssuedDate": "2021-04-01",
"bloodType": "B",
"phoneNumbers": [
"0912345678"
]
},
{
"id": "104",
"name": "Winnie Kuo",
"departments": ["企業管理"],
"courses": [
{ "name": "會計學", "point": 3 },
{ "name": "商業概論", "point": 1 }
],
"grade": 1,
"conductScore": 71,
"job": {
"name": "班長",
"primary": false
},
"introduction": "To lead a team in company in career, learn to lead students in university first.",
"englishIssuedDate": "2021-12-01",
"phoneNumbers": []
},
{
"id": "102",
"name": "Mario Lu",
"departments": ["會計"],
"courses": [
{ "name": "會計學", "point": 5 },
{ "name": "審計學", "point": 3 },
{ "name": "企業資源規劃", "point": 3 }
],
"grade": 3,
"conductScore": 83,
"job": {
"name": "康樂股長"
},
"introduction": "Accounting work can be done by technology. So I start to learn programming on internet.",
"englishIssuedDate": "2022-05-01",
"bloodType": "O"
}
]
view raw students.json hosted with ❤ by GitHub

(三)Repository 層簡介

專案中實作了一個 Repository 層,名為 StudentEsRepository。其中包含了存取 ES 的邏輯,如下:

public class StudentEsRepository {
private final String indexName;
// 其餘略過
public List<Student> find(SearchInfo info) {
var request = new SearchRequest.Builder()
.index(indexName)
.query(info.toQuery())
.sort(info.getSortOptions())
.from(info.getFrom())
.size(info.getSize())
.build();
// 其餘略過
}
}

其中 find 方法是用來進行搜尋,且接收了一個 SearchInfo 物件。SearchInfo 是筆者自訂的類別,它包裝了搜尋時會用到的參數,其定義如下:

public class SearchInfo {
private BoolQuery boolQuery; // 查詢條件
private List<FunctionScore> functionScores = List.of(); // 計分函數
private List<SortOptions> sortOptions; // 排序方式
private Integer from; // 資料的跳過數量
private Integer size; // 資料的擷取數量
public SearchInfo() {
var matchAll = MatchAllQuery.of(b -> b)._toQuery();
this.boolQuery = BoolQuery.of(b -> b.filter(matchAll));
}
// library 使用 Query 類別當作條件的傳遞介面
public Query toQuery() {
return boolQuery._toQuery();
}
// 其餘略過
}

我們會將用來計算 document 分數的函數,存放在 functionScores 變數中。

(四)準備測試類別

在本文,筆者將透過撰寫測試的方式,來使用 Repository 元件發出搜尋請求。於是準備了以下測試類別:

@RunWith(SpringRunner.class)
@SpringBootTest
public class SearchTests {
@Autowired
private StudentEsRepository repository;
// 其餘略過
private void assertDocumentIds(List<Student> actualDocs, String... expectedIdArray) {
var expectedIds = List.of(expectedIdArray);
var actualIds = actualDocs.stream()
.map(Student::getId)
.collect(Collectors.toList());
assertEquals(expectedIds, actualIds);
}
}

由於 ES 預設是按照 document 分數來排序,因此筆者撰寫了名為 assertDocumentIds 的方法。它接受兩個參數,比對搜尋到的資料與順序是否如預期。



二、Function Score API 的使用方式

讓我們回顧一下在搜尋時使用 function score 的 DSL 寫法,以下是一個範例的搜尋請求。

{
    "query": {
        "function_score": {
            "query": {
                "match_all": {}
            },
            "functions": [],            
            "score_mode": "sum",
            "boost_mode": "replace",
            "max_boost": 30.0
        }
    }
}

關於各種 function score 以及相關參數的定義,筆者在此不贅述,讀者可參考「【ElasticSearch 8】使用 function score 在查詢時評分」文章。

我們知道在程式中發出搜尋請求時,會建立一個 SearchRequest 物件。若想使用 function score,就會在 SearchRequest 所接收的 Query 物件中添加相關的參數。

SearchInfo 中有代表查詢條件的 boolQuery 變數,以及代表函數的 functionScores 變數。現在筆者改寫其中的 toQuery 方法,調整產生 Query 物件的方式。

public class SearchInfo {
private BoolQuery boolQuery;
private List<FunctionScore> functionScores;
// 其餘略過
public Query toQuery() {
if (CollectionUtils.isEmpty(functionScores)) {
return boolQuery._toQuery();
}
return new FunctionScoreQuery.Builder()
.query(boolQuery._toQuery())
.functions(functionScores)
.scoreMode(FunctionScoreMode.Sum)
.boostMode(FunctionBoostMode.Replace)
.maxBoost(30.0)
.build()
._toQuery();
}
}

在搜尋未使用 function score 時,採用原本的 boolQuery,再轉為 Query 物件即可。

若使用了 function score,則會建立出 FunctionScoreQuery 物件,再轉為 Query 物件。過程中除了傳入原本的查詢條件,也使用 functions 方法傳入函數。

至於其餘的 score_mode、boost_mode 與 max_boost 參數,則可很直覺地透過對應的方法來提供。

接下來的小節,筆者將在 SearchUtils 類別示範如何建立出函數物件。要注意的是,library 使用「FunctionScore」這個類別當作函數的傳遞介面,因此實作出的函數都會轉換為 FunctionScore,才能實際使用。


三、Field Value Factor

需建立 FieldValueFactorScoreFunction 物件。相關的參數都可透過它的 builder 所提供的方法添加上去。

/**
* <pre>
* {
* "field_value_factor": {
* "field": {@param field},
* "factor": {@param factor},
* "modifier": {@param modifier},
* "missing": {@param missing}
* }
* }
* </pre>
*/
public static FunctionScore createFieldValueFactor(
String field, Double factor, FieldValueFactorModifier modifier, Double missing) {
return new FieldValueFactorScoreFunction.Builder()
.field(field)
.factor(factor)
.modifier(modifier)
.missing(missing)
.build()
._toFunctionScore();
}

以下範例的計分方式,是先將年級乘 0.5,再做平方。若年級欄位沒有值,則預設為 0。寫成測試如下:

@Test
public void testFunctionScore_FieldValueFactor() {
var fieldValueFactorScore = SearchUtils
.createFieldValueFactor("grade", 0.5, FieldValueFactorModifier.Square, 0.0);
var searchInfo = new SearchInfo();
searchInfo.setFunctionScores(List.of(fieldValueFactorScore));
var students = repository.find(searchInfo);
// Dora (4.0) -> Mario (2.25) -> Vincent (1.0) -> Winnie (0.25)
assertDocumentIds(students, "101", "102", "103", "104");
}



四、Weight 與 Filter

我們可直接建立 FunctionScore 物件,並在過程中透過 builder 提供的 filter 與 weight 方法,給予對應的參數。

其中 filter 方法接受一個 Query 物件,代表 document 需符合該條件,才能得到 weight 的分數。

/**
* <pre>
* {
* "filter": {@param query},
* "weight": {@param weight}
* }
* </pre>
*/
public static FunctionScore createConditionalWeightFunctionScore(Query query, Double weight) {
return new FunctionScore.Builder()
.filter(query)
.weight(weight)
.build();
}

若想將上一節介紹的 FieldValueFactor 的函數計算結果,也給予一個權重,則可將其傳入到 FunctionScore 中。

/**
* <pre>
* {
* "field_value_factor": {@param function},
* "weight": {@param weight}
* }
* </pre>
*/
public static FunctionScore createWeightedFieldValueFactor(
FieldValueFactorScoreFunction function, Double weight) {
return new FunctionScore.Builder()
.fieldValueFactor(function)
.weight(weight)
.build();
}

以下範例的計分方式,是就讀財務金融系者得 3 分,修習程式設計課程者得 1.5 分,每升一個年級得 0.5 分。寫成測試如下:

@Test
public void testFunctionScore_ConditionalWeight() {
var departmentQuery = SearchUtils
.createTermQuery("departments.keyword", "財務金融");
var departmentScore = SearchUtils
.createConditionalWeightFunctionScore(departmentQuery, 3.0);
var courseQuery = SearchUtils
.createTermQuery("courses.name.keyword", "程式設計");
var courseScore = SearchUtils
.createConditionalWeightFunctionScore(courseQuery, 1.5);
FunctionScore fieldValueFactorScore = SearchUtils
.createFieldValueFactor("grade", 1.0, FieldValueFactorModifier.None, 0.0);
var gradeScore = SearchUtils
.createWeightedFieldValueFactor(fieldValueFactorScore.fieldValueFactor(), 0.5);
var searchInfo = new SearchInfo();
searchInfo.setFunctionScores(List.of(
departmentScore,
courseScore,
gradeScore
));
var students = repository.find(searchInfo);
// Vincent (5.5) -> Dora (5.0) -> Mario (1.5) -> Winnie (0.5)
assertDocumentIds(students, "103", "101", "102", "104");
}

對於科系與課程,準備了代表條件的 TermQuery。而年級則採用 FieldValueFactor 計算,但實作方式稍微不同。此處是從 FunctionScore 中取出 FieldValueFactorScoreFunction 物件,用來建立出另一個函數。


五、衰減函數(Decay Function)

(一)用於數值欄位

需建立出 DecayPlacement 物件,用以包裝衰減函數相關的參數,包含 origin、offset、scale 與 decay。

/**
* <pre>
* {
* "origin": {@param origin},
* "offset": {@param offset},
* "scale": {@param scale},
* "decay": {@param decay}
* }
* </pre>
*/
public static DecayPlacement createDecayPlacement(
Number origin, Number offset, Number scale, Double decay) {
return new DecayPlacement.Builder()
.origin(JsonData.of(origin))
.offset(JsonData.of(offset))
.scale(JsonData.of(scale))
.decay(decay)
.build();
}

我們知道衰減函數分為 linear、exp 與 gauss 三種。此處筆者再撰寫一方法,用 DecayPlacement 建立出 gauss 函數。

/**
* <pre>
* {
* "gauss": {@param placement}
* }
* </pre>
*/
public static FunctionScore createGaussFunction(String field, DecayPlacement placement) {
var decayFunction = new DecayFunction.Builder()
.field(field)
.placement(placement)
.build();
return new FunctionScore.Builder()
.gauss(decayFunction)
.build();
}

在此建立出 DecayFunction 物件,過程中添加了相關參數與 document 的欄位名稱。

以下範例是將 gauss 衰減函數用於操行分數的欄位。在設計上,我們假設 85 到 100 分為最優。而 75 分者,document 分數為 0.5。更低分者,分數變化率開始緩和。寫成測試如下:

@Test
public void testFunctionScore_DecayFunction_Number() {
var placement = SearchUtils
.createDecayPlacement(100, 15, 10, 0.5);
var decayFunctionScore = SearchUtils
.createGaussFunction("conductScore", placement);
var searchInfo = new SearchInfo();
searchInfo.setFunctionScores(List.of(decayFunctionScore));
var students = repository.find(searchInfo);
// Vincent (1.0) -> Mario (0.9726) -> Dora (0.4322) -> Winnie (0.2570)
assertDocumentIds(students, "103", "102", "101", "104");
}

(二)用於日期欄位

對於日期欄位,衰減函數的參數可用時間的毫秒數(以數值表示)或時間單位(以字串表示)。若選擇時間單位,前提是在 mapping 中有將該欄位的 type 設為「date」才行。

筆者撰寫另一個建立 DecayPlacement 的方法,它接受字串參數,藉此用「表示式」(expression)的方式傳入時間參數。

public static DecayPlacement createDecayPlacement(
String originExp, String offsetExp, String scaleExp, Double decay) {
return new DecayPlacement.Builder()
.origin(JsonData.of(originExp))
.offset(JsonData.of(offsetExp))
.scale(JsonData.of(scaleExp))
.decay(decay)
.build();
}

以下範例是將 gauss 衰減函數用於英文檢定通過日的欄位。在設計上,我們假設 90 天內通過者為最優。而 360 天前通過者,document 分數為 0.5。更早通過者,分數變化率開始緩和。寫成測試如下:

@Test
public void testFunctionScore_DecayFunction_Date() {
var placement = SearchUtils
.createDecayPlacement("now", "90d", "270d", 0.5);
var decayFunctionScore = SearchUtils
.createGaussFunction("englishIssuedDate", placement);
var searchInfo = new SearchInfo();
searchInfo.setFunctionScores(List.of(decayFunctionScore));
var students = repository.find(searchInfo);
// Mario -> Winnie -> Dora -> Vincent
assertDocumentIds(students, "102", "104", "101", "103");
}



上一篇:【ElasticSearch 8】使用 Java API Client 實作查詢條件與搜尋

留言