
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... | |
} | |
} |
(二)範例資料
由於會在 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" | |
} | |
] |
(三)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"); | |
} |
留言
張貼留言