我正在尝试将 Lucene 中的多个文本字段的日期过滤和查询结合起来。
例如,我想查询文档的
title
和content
,并且只包含在特定日期范围内具有updated
字段的文档。
我遇到的奇怪行为是,当我添加日期过滤器时,我得到的文档包含在与日期过滤器匹配但与查询字符串不匹配的结果中。
有返回的文档带有文字
0.0
匹配分数。
我在查询构造中遗漏了什么吗?或者这是可接受的行为,我应该从结果中过滤掉
0.0
分数文档?
下面是包含输出的代码示例:
使用lucene 7.3.0代码,以Kotlin代码为例:
package lookup.lucene
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.document.Document
import org.apache.lucene.document.Field
import org.apache.lucene.document.LongPoint
import org.apache.lucene.document.TextField
import org.apache.lucene.index.*
import org.apache.lucene.search.*
import org.apache.lucene.store.Directory
import org.apache.lucene.store.RAMDirectory
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
object DateFilterTroubleshootingSandbox {
private val indexDirectory: Directory = RAMDirectory()
private val analyzer = StandardAnalyzer()
val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
@Throws(IOException::class)
@JvmStatic
fun main(args: Array<String>) {
setupIndex()
// "ergonomic keyboard"
performSearchWithDateFilter("ergonomic")
}
@Throws(IOException::class)
private fun setupIndex() {
val config = IndexWriterConfig(analyzer)
val writer = IndexWriter(indexDirectory, config)
addDoc(
writer,
"ergonomic keyboard Review (title and content match)",
"Review of the latest ergonomic keyboard",
dateFormatter.parse("2024-04-02").time
)
addDoc(
writer,
"Cycling Updates (completely unrelated)",
"Best bicycles of 2024",
dateFormatter.parse("2024-04-10").time
)
addDoc(
writer,
"Office Comfort (on word in content matches)",
"Review of ergonomic chairs",
dateFormatter.parse("2024-04-15").time
)
writer.close()
}
@Throws(IOException::class)
private fun addDoc(
writer: IndexWriter,
title: String,
content: String,
updatedMillis: Long,
) {
val doc = Document()
doc.add(TextField("title", title, Field.Store.YES))
doc.add(TextField("content", content, Field.Store.YES))
doc.add(LongPoint("updatedMillis", updatedMillis))
writer.addDocument(doc)
}
@Throws(IOException::class)
private fun performSearchWithDateFilter(queryString: String) {
println("performSearchWithDateFilter: $queryString")
val reader: IndexReader = DirectoryReader.open(indexDirectory)
val searcher = IndexSearcher(reader)
val query = buildQuery(queryString, "2024-04-09", "2024-04-22")
val docs = searcher.search(query, 10)
printDocs(searcher, docs)
reader.close()
}
private fun buildQuery(queryString: String, fromDate: String, toDate: String): Query {
val booleanQuery = BooleanQuery.Builder()
arrayOf("title", "content")
.forEach { field ->
booleanQuery.add(
BoostQuery(TermQuery(Term(field, queryString)), 1.1f), BooleanClause.Occur.SHOULD
)
}
val fromMillis = dateFormatter.parse(fromDate).time
val toMillis = dateFormatter.parse(toDate).time
val dateRangeQuery = LongPoint.newRangeQuery("updatedMillis", fromMillis, toMillis)
booleanQuery.add(dateRangeQuery, BooleanClause.Occur.FILTER)
return booleanQuery.build()
}
private fun printDocs(searcher: IndexSearcher, docs: TopDocs) {
docs.scoreDocs.forEach { scoreDoc ->
val docId = scoreDoc.doc
val doc = searcher.doc(docId)
val title = doc.get("title")
val content = doc.get("content")
println("Doc ID: $docId, Score: ${scoreDoc.score}, Title='$title', Content='$content'")
}
}
}
performSearchWithDateFilter: ergonomic
Doc ID: 2, Score: 0.5390563, Title='Office Comfort (on word in content matches)', Content='Review of ergonomic chairs'
Doc ID: 1, Score: 0.0, Title='Cycling Updates (completely unrelated)', Content='Best bicycles of 2024'
我将使用 Java,而不是 Kotlin,因为如果我尝试使用 Kotlin,我可能会把代码搞得一团糟。
您看到的结果与
BooleanClause.Occur.FILTER
运算符的工作方式有关。如此处所述:
— 当需要某个子句出现在结果集中但不影响分数时,请使用此运算符。结果集中的每个文档都将匹配所有此类子句。FILTER
另请参阅here,其中描述为:
与
类似,只不过这些条款不参与评分。MUST
问题中的代码构建布尔查询的三个子句,以便它有效地指出:
标题可能包含“人体工程学”,内容可能包含“人体工程学”,并且日期范围必须在 x 和 y 之间。
因此,结果是您实际上获得了意外的 Doc ID 1 的分数 - 因为它与
FILTER
子句匹配 - 并且该分数是 0.0
。
了解该行为的另一种方法是查看一个可能的解决方案。
在此修复中(对 Java 而非 Kotlin 表示歉意),我重写了
buildQuery()
方法,如下所示:
private static Query buildQuery(String queryString, String fromDate, String toDate) throws ParseException {
var termQuery = new BooleanQuery.Builder();
Arrays.asList("title", "content").forEach(field -> {
termQuery.add(
new BoostQuery(new TermQuery(new Term(field, queryString)), 1.1f),
BooleanClause.Occur.SHOULD);
});
var fromMillis = dateFormatter.parse(fromDate).getTime();
var toMillis = dateFormatter.parse(toDate).getTime();
var dateRangeQuery = LongPoint.newRangeQuery("updatedMillis", fromMillis, toMillis);
var datesQuery = new BooleanQuery.Builder();
datesQuery.add(dateRangeQuery, BooleanClause.Occur.FILTER);
var finalQuery = new BooleanQuery.Builder();
finalQuery.add(termQuery.build(), BooleanClause.Occur.MUST);
finalQuery.add(datesQuery.build(), BooleanClause.Occur.MUST);
return finalQuery.build();
}
在这里,我引入了一个额外的
BooleanQuery
,它允许我有效地将两个 SHOULD
关键字查询嵌套在括号中 - 通过将它们放置在单独的 BooleanQuery
对象中。然后我将 FILTER
项放入“最终”BooleanQuery
对象中,并为其提供 MUST
运算符。
现在,使用这段代码,您得到的输出如下:
performSearchWithDateFilter: ergonomic
Doc ID: 2 Score: 0.5390563 Title='Office Comfort (on word in content matches) Content='Review of ergonomic chairs'
正是您所期待的一击。
最后,比较这两种方法的另一种方法是打印出执行的查询的文本表示:
System.out.println(query.toString());
原始查询:
(title:ergonomic)^1.1 (content:ergonomic)^1.1 #updatedMillis:[1712646000000 TO 1713769200000]
更新后的查询:
+((title:ergonomic)^1.1 (content:ergonomic)^1.1) +(#updatedMillis:[1712646000000 TO 1713769200000])
注意前两个搜索词周围的括号意味着您必须(
+
)在标题或内容(或两者)中找到搜索词 - 然后您还必须匹配日期范围(但这不会影响总匹配分数)。
或者,正如您所注意到的,您可以保留代码并仅过滤结果以删除任何得分为零的内容。
这一切中最不直观的部分可能是您使用原始代码返回了零分文档。这就是 Lucene 在使用
FILTER
时的工作原理。所有与过滤器匹配的内容都会被赋予一个分数(0.0
)。所有带有分数的内容都会被返回 - 即使任何其他条款(例如您的关键字条款)没有提供额外的评分信息。
(Lucene“布尔值”与您可能在其他地方看到的布尔值的行为并不完全相同!但这并不完全令人惊讶,因为 Lucene 首先也是最重要的是尝试按相关性进行排名,而不是简单地包含/拒绝。因此它使用
SHOULD
和 MUST
作为其运算符。但在 其他地方,它也尝试将这些映射到更熟悉的布尔运算符,例如 AND
、OR
、NOT
。)