使用 Lucene 搜索

作者:  Paul King
PMC 成员

发布日期:2024-11-25 03:30PM (最后更新:2024-12-22 08:45AM)


Groovy 博客文章经常引用其他 Apache 项目。也许我们想知道涉及了哪些其他项目和哪些博客文章。鉴于这些页面已发布,我们可以使用诸如 Apache NutchApache Solr 之类的工具来抓取/索引这些网页并使用这些工具进行搜索。对于这篇文章,我们将从原始源 (AsciiDoc) 文件中搜索我们所需的信息,这些文件可以在 groovy-website 仓库中找到。我们将首先研究如何使用正则表达式查找项目引用,然后使用 Apache Lucene

项目名称的正则表达式

为了本篇文章的需要,我们假设项目引用将包含“Apache”一词,后跟项目名称。为了更有趣,我们还将包含对 Eclipse 项目的引用。我们还将为包含子项目的项目做出规定,至少对于 Apache Commons,这样就可以识别诸如“Apache Commons Math”之类的名称。我们将排除 Apache Groovy,因为它可能会命中每一篇 Groovy 博客文章。我们还将排除一些在常用短语中出现的单词,例如“Apache License”和“Apache Projects”,这些短语在我们的搜索查询中可能看起来像项目名称,但实际上不是。

这绝不是一个完美的名称引用查找器,例如,我们经常在博客文章中首次引入 Apache Commons Math 时使用其全名,但在文章后面,我们会回退到更友好的“Commons Math”引用,其中“Apache”是从上下文中理解的。我们可以使正则表达式更复杂以适应这种情况,但就本篇文章而言,这并没有任何好处,所以我们不会这样做。

String tokenRegex = /(?ix)             # ignore case, enable whitespace & comments
    \b                                 # word boundary
    (                                  # start capture of all terms
        (                              # capture project name term
            (apache|eclipse)\s         # foundation name
            (commons\s)?               # optional subproject name
            (
                ?!(groovy              # negative lookahead for excluded words
                | and   | license  | users
                | https | projects | software
                | or    | prefixes | technologies)
            )\w+
        )                              # end capture project name term
        |                              # alternatively
        (                              # capture non-project term
            \w+?\b                     # non-greedily match any other word chars
        )                              # end capture non-project term
    )                                  # end capture term
/

我们使用了 Groovy 的多行斜线字符串,这样就无需转义反斜杠。我们还启用了正则表达式空格和注释来解释正则表达式。如果您愿意,可以随意创建一个紧凑的(长)无注释单行表达式。

使用正则表达式匹配收集项目名称统计信息

有了正则表达式,我们来看看如何使用 Groovy 匹配器来查找所有项目名称。首先,我们将定义另一个常用常量,即我们博客的根目录,如果您想继续并运行这些示例,可能需要更改它。

String baseDir = '/projects/groovy-website/site/src/site/blog' // (1)
  1. 你需要签出 Groovy 网站,并在此处将 `baseDir` 指向它。

首先,让我们创建一个小助手方法,用于打印漂亮的成果图(我们将使用 JColor 中的 `colorize` 方法)

def display(Map<String, Integer> data, int max, int scale = 1) {
    data.each { k, v ->
        var label = "$k ($v)"
        var color = k.startsWith('apache') ? MAGENTA_TEXT() : BLUE_TEXT()
        println "${label.padRight(32)} ${colorize(bar(v * scale, 0, max, max), color)}"
    }
}

现在,我们的脚本将遍历该目录中的所有文件,使用我们的正则表达式处理它们并跟踪我们找到的命中数。

var histogram = [:].withDefault { 0 } // (1)

new File(baseDir).traverse(nameFilter: ~/.*\.adoc/) { file ->  // (2)
    var m = file.text =~ tokenRegex // (3)
    var projects = m*.get(2).grep()*.toLowerCase()*.replaceAll('\n', ' ') // (4)
    var counts = projects.countBy() // (5)
    if (counts) {
        println "$file.name: $counts" // (6)
        counts.each { k, v -> histogram[k] += v } // (7)
    }
}

println "\nFrequency of total hits mentioning a project:"
display(histogram.sort { e -> -e.value }, 50) // (8)
  1. 这是一个为不存在的键提供默认值的映射

  2. 这将遍历目录,处理每个 AsciiDoc 文件

  3. 我们定义了匹配器

  4. 这将提取项目名称(捕获组 2),忽略其他单词(使用 grep),转换为小写,并删除换行符,以防术语可能跨行末尾

  5. 这汇总了该文件的命中计数

  6. 我们打印出每个博客文章的文件名及其项目引用

  7. 我们将文件聚合添加到整体聚合中

  8. 我们打印出漂亮的 ASCII 条形图,总结了总体聚合。

当我们的脚本运行时,输出如下所示:

apache-nlpcraft-with-groovy.adoc: [apache nlpcraft:5]
classifying-iris-flowers-with-deep.adoc: [eclipse deeplearning4j:5, apache commons math:1, apache spark:2]
community-over-code-eu-2024.adoc: [apache ofbiz:1, apache commons math:2, apache ignite:1, apache spark:1, apache wayang:1, apache beam:1, apache flink:1]
community-over-code-na-2023.adoc: [apache ignite:8, apache commons numbers:1, apache commons csv:1]
deck-of-cards-with-groovy.adoc: [eclipse collections:5]
deep-learning-and-eclipse-collections.adoc: [eclipse collections:7, eclipse deeplearning4j:2]
detecting-objects-with-groovy-the.adoc: [apache mxnet:12]
fruity-eclipse-collections.adoc: [eclipse collections:9, apache commons math:1]
fun-with-obfuscated-groovy.adoc: [apache commons math:1]
groovy-2-5-clibuilder-renewal.adoc: [apache commons cli:2]
groovy-graph-databases.adoc: [apache age:11, apache hugegraph:3, apache tinkerpop:3]
groovy-haiku-processing.adoc: [eclipse collections:3]
groovy-list-processing-cheat-sheet.adoc: [eclipse collections:4, apache commons collections:3]
groovy-lucene.adoc: [apache nutch:1, apache solr:1, apache lucene:3, apache commons:4, apache commons math:2, apache spark:1]
groovy-null-processing.adoc: [eclipse collections:6, apache commons collections:4]
groovy-pekko-gpars.adoc: [apache pekko:4]
groovy-record-performance.adoc: [apache commons codec:1]
handling-byte-order-mark-characters.adoc: [apache commons io:1]
lego-bricks-with-groovy.adoc: [eclipse collections:6]
matrix-calculations-with-groovy-apache.adoc: [apache commons math:6, eclipse deeplearning4j:1, apache commons:1]
natural-language-processing-with-groovy.adoc: [apache opennlp:2, apache spark:1]
reading-and-writing-csv-files.adoc: [apache commons csv:2]
set-operations-with-groovy.adoc: [eclipse collections:3]
solving-simple-optimization-problems-with-groovy.adoc: [apache commons math:5, apache kie:1]
using-groovy-with-apache-wayang.adoc: [apache wayang:9, apache spark:7, apache flink:1, apache commons csv:1, apache ignite:1]
whiskey-clustering-with-groovy-and.adoc: [apache ignite:7, apache wayang:1, apache spark:2, apache commons csv:2]
wordle-checker.adoc: [eclipse collections:3]
zipping-collections-with-groovy.adoc: [eclipse collections:4]

Frequency of total hits mentioning a project:
eclipse collections (50)         ██████████████████████████████████████████████████▏
apache commons math (18)         ██████████████████▏
apache ignite (17)               █████████████████▏
apache spark (14)                ██████████████▏
apache mxnet (12)                ████████████▏
apache wayang (11)               ███████████▏
apache age (11)                  ███████████▏
eclipse deeplearning4j (8)       ████████▏
apache commons collections (7)   ███████▏
apache commons csv (6)           ██████▏
apache nlpcraft (5)              █████▏
apache pekko (4)                 ████▏
apache hugegraph (3)             ███▏
apache tinkerpop (3)             ███▏
apache lucene (3)                ███▏
apache flink (2)                 ██▏
apache commons cli (2)           ██▏
apache commons (2)               ██▏
apache opennlp (2)               ██▏
apache ofbiz (1)                 ▏
apache beam (1)                  ▏
apache commons numbers (1)       ▏
apache nutch (1)                 ▏
apache solr (1)                  ▏
apache commons codec (1)         ▏
apache commons io (1)            ▏
apache kie (1)                   

使用 Lucene 索引

lucene logo 好的,正则表达式并不难,但通常我们可能想搜索很多东西。像 Lucene 这样的搜索框架可以帮助解决这个问题。让我们看看将 Lucene 应用到我们的问题中会是什么样子。

首先,我们将定义一个自定义分析器。Lucene 非常灵活,并内置了分析器。在典型情况下,我们可能只对所有找到的单词进行索引。为此有一个内置分析器。如果使用其中一个内置分析器,要查询我们的项目名称,我们需要构建一个跨越多个(单词)术语的查询。我们稍后将研究它可能是什么样子,但出于我们小示例的目的,我们假设项目名称是不可分割的术语,并以这种方式切分我们的文档。

幸运的是,Lucene 有一个模式分词器,这使我们能够重用现有的正则表达式。基本上,我们的索引将包含项目名称词项和其他找到的词。

class ProjectNameAnalyzer extends Analyzer {
    @Override
    protected TokenStreamComponents createComponents(String fieldName) {
        var src = new PatternTokenizer(~tokenRegex, 0)
        var result = new LowerCaseFilter(src)
        new TokenStreamComponents(src, result)
    }
}

现在让我们对文档进行分词并让 Lucene 对它们进行索引。

var analyzer = new ProjectNameAnalyzer() // (1)
var indexDir = new ByteBuffersDirectory() // (2)
var config = new IndexWriterConfig(analyzer)

new IndexWriter(indexDir, config).withCloseable { writer ->
    var indexedWithFreq = new FieldType(stored: true,
        indexOptions: IndexOptions.DOCS_AND_FREQS,
        storeTermVectors: true)
    new File(baseDir).traverse(nameFilter: ~/.*\.adoc/) { file ->
        file.withReader { br ->
            var document = new Document()
            document.add(new Field('content', br.text, indexedWithFreq)) // (3)
            document.add(new StringField('name', file.name, Field.Store.YES)) // (4)
            writer.addDocument(document)
        }
    }
}
  1. 这是我们基于正则表达式的分析器

  2. 我们将为我们的小例子使用内存索引

  3. 存储文档内容以及词项位置信息

  4. 同时存储文件名

定义索引后,我们通常会执行某种搜索。我们很快就会这样做,但首先,对于我们感兴趣的信息类型,Lucene API 的一部分允许我们探索索引。以下是我们可能这样做的方式:

var reader = DirectoryReader.open(indexDir)
var vectors = reader.termVectors()
var storedFields = reader.storedFields()

Set projects = []
for (docId in 0..<reader.maxDoc()) {
    String name = storedFields.document(docId).get('name')
    TermsEnum terms = vectors.get(docId, 'content').iterator() // (1)
    var found = [:]
    while (terms.next() != null) {
        PostingsEnum postingsEnum = terms.postings(null, PostingsEnum.ALL)
        while (postingsEnum.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
            int freq = postingsEnum.freq()
            var string = terms.term().utf8ToString().replaceAll('\n', ' ')
            if (string.startsWith('apache ') || string.startsWith('eclipse ')) { // (2)
                found[string] = freq
            }
        }
    }
    if (found) {
        println "$name: $found"
        projects += found.keySet()
    }
}

var terms = projects.collect { name -> new Term('content', name) }
var byReverseValue = { e -> -e.value }

println "\nFrequency of total hits mentioning a project (top 10):"
var termFreq = terms.collectEntries { term ->
    [term.text(), reader.totalTermFreq(term)]  // (3)
}
display(termFreq.sort(byReverseValue).take(10), 50)

println "\nFrequency of documents mentioning a project (top 10):"
var docFreq = terms.collectEntries { term ->
    [term.text(), reader.docFreq(term)]  // (4)
}
display(docFreq.sort(byReverseValue).take(10), 20, 2)
  1. 获取所有索引词条

  2. 查找匹配项目名称的术语,这样我们就可以将它们保存到一个集合中。

  3. 获取我们词项集合中每个词项的命中频率元数据

  4. 获取我们词项集合中每个词项的文档频率元数据

当我们运行此代码时,我们看到

apache-nlpcraft-with-groovy.adoc: [apache nlpcraft:5]
classifying-iris-flowers-with-deep.adoc: [apache commons math:1, apache spark:2, eclipse deeplearning4j:5]
community-over-code-eu-2024.adoc: [apache beam:1, apache commons math:2, apache flink:1, apache ignite:1, apache ofbiz:1, apache spark:1, apache wayang:1]
community-over-code-na-2023.adoc: [apache commons csv:1, apache commons numbers:1, apache ignite:8]
deck-of-cards-with-groovy.adoc: [eclipse collections:5]
deep-learning-and-eclipse-collections.adoc: [eclipse collections:7, eclipse deeplearning4j:2]
detecting-objects-with-groovy-the.adoc: [apache mxnet:12]
fruity-eclipse-collections.adoc: [apache commons math:1, eclipse collections:9]
fun-with-obfuscated-groovy.adoc: [apache commons math:1]
groovy-2-5-clibuilder-renewal.adoc: [apache commons cli:2]
groovy-graph-databases.adoc: [apache age:11, apache hugegraph:3, apache tinkerpop:3]
groovy-haiku-processing.adoc: [eclipse collections:3]
groovy-list-processing-cheat-sheet.adoc: [apache commons collections:3, eclipse collections:4]
groovy-lucene.adoc: [apache commons:4, apache commons math:2, apache lucene:3, apache nutch:1, apache solr:1, apache spark:1]
groovy-null-processing.adoc: [apache commons collections:4, eclipse collections:6]
groovy-pekko-gpars.adoc: [apache pekko:4]
groovy-record-performance.adoc: [apache commons codec:1]
handling-byte-order-mark-characters.adoc: [apache commons io:1]
lego-bricks-with-groovy.adoc: [eclipse collections:6]
matrix-calculations-with-groovy-apache.adoc: [apache commons:1, apache commons math:6, eclipse deeplearning4j:1]
natural-language-processing-with-groovy.adoc: [apache opennlp:2, apache spark:1]
reading-and-writing-csv-files.adoc: [apache commons csv:2]
set-operations-with-groovy.adoc: [eclipse collections:3]
solving-simple-optimization-problems-with-groovy.adoc: [apache commons math:4, apache kie:1]
using-groovy-with-apache-wayang.adoc: [apache commons csv:1, apache flink:1, apache ignite:1, apache spark:7, apache wayang:9]
whiskey-clustering-with-groovy-and.adoc: [apache commons csv:2, apache ignite:7, apache spark:2, apache wayang:1]
wordle-checker.adoc: [eclipse collections:3]
zipping-collections-with-groovy.adoc: [eclipse collections:4]

Frequency of total hits mentioning a project (top 10):
eclipse collections (50)         ██████████████████████████████████████████████████▏
apache commons math (17)         █████████████████▏
apache ignite (17)               █████████████████▏
apache spark (14)                ██████████████▏
apache mxnet (12)                ████████████▏
apache wayang (11)               ███████████▏
apache age (11)                  ███████████▏
eclipse deeplearning4j (8)       ████████▏
apache commons collections (7)   ███████▏
apache commons csv (6)           ██████▏

Frequency of documents mentioning a project (top 10):
eclipse collections (10)         ████████████████████▏
apache commons math (7)          ██████████████▏
apache spark (6)                 ██████████▏
apache ignite (4)                ████████▏
apache commons csv (4)           ████████▏
eclipse deeplearning4j (3)       ██████▏
apache wayang (3)                ██████▏
apache flink (2)                 ████▏
apache commons collections (2)   ████▏
apache commons (2)               ████

到目前为止,我们只展示了关于我们索引的精心策划的元数据。但为了表明我们有一个支持搜索的索引,让我们查找所有提到表情符号的文档。它们常常使编程示例变得非常有趣!

var parser = new QueryParser("content", analyzer)
var searcher = new IndexSearcher(reader)
var query = parser.parse('emoji*')
var results = searcher.search(query, 10)
println "\nTotal documents with hits for $query --> $results.totalHits"
results.scoreDocs.each {
    var doc = storedFields.document(it.doc)
    println "${doc.get('name')}"
}

当我们运行此代码时,我们看到

Total documents with hits for content:emoji* --> 11 hits
adventures-with-groovyfx.adoc
create-groovy-blog.adoc
deep-learning-and-eclipse-collections.adoc
fruity-eclipse-collections.adoc
groovy-haiku-processing.adoc
groovy-lucene.adoc
helloworldemoji.adoc
seasons-greetings-emoji.adoc
set-operations-with-groovy.adoc
solving-simple-optimization-problems-with-groovy.adoc

Lucene 拥有非常丰富的 API。现在我们来看看 Lucene 的一些替代用法。

我们通常会运行查询并探索这些结果,而不是探索索引元数据。我们现在将研究如何做到这一点。在探索查询结果时,我们将使用 `lucene-highlight` 模块中 `vectorhighlight` 包中的一些类。您通常会使用该模块中的功能来高亮显示命中,作为可能在网页上显示它们的一部分,作为某些网页搜索功能的一部分。对于我们来说,我们只会挑选出我们感兴趣的术语,即与我们的查询匹配的项目名称。

为了使高亮功能正常工作,我们要求索引器在索引时存储一些额外信息,特别是词项位置和偏移量。索引代码变为如下所示:

new IndexWriter(indexDir, config).withCloseable { writer ->
    new File(baseDir).traverse(nameFilter: ~/.*\.adoc/) { file ->
        file.withReader { br ->
            var document = new Document()
            var fieldType = new FieldType(stored: true,
                indexOptions: IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS,
                storeTermVectors: true,
                storeTermVectorPositions: true,
                storeTermVectorOffsets: true)
            document.add(new Field('content', br.text, fieldType))
            document.add(new StringField('name', file.name, Field.Store.YES))
            writer.addDocument(document)
        }
    }
}

即使在之前的例子中,我们也可以存储这些额外信息,但之前并不需要。

接下来,我们定义一个辅助方法来从匹配项中提取实际的项目名称

List<String> handleHit(ScoreDoc hit, Query query, DirectoryReader dirReader) {
    boolean phraseHighlight = true
    boolean fieldMatch = true
    var fieldQuery = new FieldQuery(query, dirReader, phraseHighlight, fieldMatch)
    var stack = new FieldTermStack(dirReader, hit.doc, 'content', fieldQuery)
    var phrases = new FieldPhraseList(stack, fieldQuery)
    phrases.phraseList*.termsInfos*.text.flatten() // (1)
}
  1. 将 `FieldPhraseList` 转换为 `TermInfo` 实例列表,再转换为字符串列表。

定义了我们的辅助方法后,我们现在可以编写查询代码了。

var query = parser.parse(/apache\ * OR eclipse\ */) // (1)
var results = searcher.search(query, 30) // (2)
println "Total documents with hits for $query --> $results.totalHits\n"

var storedFields = searcher.storedFields()
var histogram = [:].withDefault { 0 }
results.scoreDocs.each { ScoreDoc scoreDoc -> // (3)
    var doc = storedFields.document(scoreDoc.doc)
    var found = handleHit(scoreDoc, query, reader) // (4)
    println "${doc.get('name')}: ${found*.replaceAll('\n', ' ').countBy()}"
    found.each { histogram[it.replaceAll('\n', ' ')] += 1 } // (5)
}

println "\nFrequency of total hits mentioning a project (top 10):"
display(histogram.sort { e -> -e.value }.take(10), 50) // (6)
  1. 搜索以 apache 或 eclipse 为前缀的术语

  2. 执行我们的查询,结果限制为 30 条

  3. 处理每个结果

  4. 提取实际匹配的术语

  5. 同时汇总计数

  6. 以漂亮的条形图形式显示前 10 个聚合结果

输出与之前基本相同

Total documents with hits for content:apache * content:eclipse * --> 28 hits

classifying-iris-flowers-with-deep.adoc: [eclipse deeplearning4j:5, apache commons math:1, apache spark:2]
fruity-eclipse-collections.adoc: [eclipse collections:9, apache commons math:1]
groovy-list-processing-cheat-sheet.adoc: [eclipse collections:4, apache commons collections:3]
groovy-null-processing.adoc: [eclipse collections:6, apache commons collections:4]
matrix-calculations-with-groovy-apache.adoc: [apache commons math:6, eclipse deeplearning4j:1, apache commons:1]
apache-nlpcraft-with-groovy.adoc: [apache nlpcraft:5]
community-over-code-eu-2024.adoc: [apache ofbiz:1, apache commons math:2, apache ignite:1, apache spark:1, apache wayang:1, apache beam:1, apache flink:1]
community-over-code-na-2023.adoc: [apache ignite:8, apache commons numbers:1, apache commons csv:1]
deck-of-cards-with-groovy.adoc: [eclipse collections:5]
deep-learning-and-eclipse-collections.adoc: [eclipse collections:7, eclipse deeplearning4j:2]
detecting-objects-with-groovy-the.adoc: [apache mxnet:12]
fun-with-obfuscated-groovy.adoc: [apache commons math:1]
groovy-2-5-clibuilder-renewal.adoc: [apache commons cli:2]
groovy-graph-databases.adoc: [apache age:11, apache hugegraph:3, apache tinkerpop:3]
groovy-haiku-processing.adoc: [eclipse collections:3]
groovy-lucene.adoc: [apache nutch:1, apache solr:1, apache lucene:3, apache commons:4, apache commons math:2, apache spark:1]
groovy-pekko-gpars.adoc: [apache pekko:4]
groovy-record-performance.adoc: [apache commons codec:1]
handling-byte-order-mark-characters.adoc: [apache commons io:1]
lego-bricks-with-groovy.adoc: [eclipse collections:6]
natural-language-processing-with-groovy.adoc: [apache opennlp:2, apache spark:1]
reading-and-writing-csv-files.adoc: [apache commons csv:2]
set-operations-with-groovy.adoc: [eclipse collections:3]
solving-simple-optimization-problems-with-groovy.adoc: [apache commons math:5, apache kie:1]
using-groovy-with-apache-wayang.adoc: [apache wayang:9, apache spark:7, apache flink:1, apache commons csv:1, apache ignite:1]
whiskey-clustering-with-groovy-and.adoc: [apache ignite:7, apache wayang:1, apache spark:2, apache commons csv:2]
wordle-checker.adoc: [eclipse collections:3]
zipping-collections-with-groovy.adoc: [eclipse collections:4]

Frequency of total hits mentioning a project (top 10):
eclipse collections (50)         ██████████████████████████████████████████████████▏
apache commons math (18)         ██████████████████▏
apache ignite (17)               █████████████████▏
apache spark (14)                █████████████▏
apache mxnet (12)                ████████████▏
apache wayang (11)               ███████████▏
apache age (11)                  ███████████▏
eclipse deeplearning4j (8)       ████████▏
apache commons collections (7)   ███████▏
apache commons csv (6)           ██████

我们也可以聚合提到项目名称的文件计数。它看起来也会和以前一样。

使用 Lucene Facets

除了 Lucene 为自身目的在索引中存储的元数据之外,Lucene 还提供了一种机制,称为分面(facets),用于存储自定义元数据。分面允许进行更强大的搜索。它们通常用于将搜索结果分组到类别中。搜索用户可以深入到类别中以细化他们的搜索。

注意
分面是一个非常强大的功能。鉴于我们正在索引 asciidoc 源文件,我们甚至可以使用像 AsciidoctorJ 这样的库从我们的源文件中提取更多元数据并将其存储为分面。例如,我们可以提取标题、作者、关键词、发布日期等等。这将使我们能够进行一些非常强大的搜索。我们将其留作读者的练习。但是如果您尝试了,请告诉我们您的进展!

我们用分面来存储每个文档的项目名称。一个捕获项目名称信息的分面可能就足够了,但为了说明 Lucene 的一些特性,我们将使用三个分面并在每个分面中存储略有不同的信息。

分面 类型 描述/示例

projectHitCounts

IntAssociationFacetField

项目引用命中计数,例如 “Apache Lucene” 在 `groovy-lucene.adoc` 中有 2 次命中。

projectFileCounts

FacetField

按文档划分的项目引用,例如“Apache Spark”被 `classifying-iris-flowers-with-deep.adoc` 引用。

projectNameCounts

FacetField

项目引用作为文档的层次结构,例如 ["Apache", "Commons", "Math"] 由 `fruity-eclipse-collections.adoc` 引用。

我们将使用正则表达式查找项目名称,并将信息存储在我们的分面中。Lucene 会创建一个特殊的**分类法(taxonomy)**索引来索引分面信息。我们也将启用它。

var analyzer = new ProjectNameAnalyzer()
var indexDir = new ByteBuffersDirectory()
var taxonDir = new ByteBuffersDirectory()
var config = new IndexWriterConfig(analyzer)
var indexWriter = new IndexWriter(indexDir, config) // (1)
var taxonWriter = new DirectoryTaxonomyWriter(taxonDir) // (2)

var fConfig = new FacetsConfig().tap { // (3)
    setHierarchical('projectNameCounts', true)
    setMultiValued('projectNameCounts', true)
    setMultiValued('projectFileCounts', true)
    setMultiValued('projectHitCounts', true)
    setIndexFieldName('projectHitCounts', '$projectHitCounts')
}

new File(baseDir).traverse(nameFilter: ~/.*\.adoc/) { file ->
    var m = file.text =~ tokenRegex
    var projects = m*.get(2).grep()*.toLowerCase()*.replaceAll('\n', ' ').countBy()
    file.withReader { br ->
        var document = new Document()
        var indexedWithFreq = new FieldType(stored: true,
            indexOptions: IndexOptions.DOCS_AND_FREQS,
            storeTermVectors: true)
        document.add(new Field('content', br.text, indexedWithFreq))
        document.add(new StringField('name', file.name, Field.Store.YES))
        if (projects) {
            println "$file.name: $projects"
            projects.each { k, v -> // (4)
                document.add(new IntAssociationFacetField(v, 'projectHitCounts', k))
                document.add(new FacetField('projectFileCounts', k))
                document.add(new FacetField('projectNameCounts', k.split()))
            }
        }
        indexWriter.addDocument(fConfig.build(taxonWriter, document))
    }
}
indexWriter.close()
taxonWriter.close()
  1. 我们的常规索引写入器

  2. 一个用于我们分类法的作者

  3. 定义我们感兴趣的分面的一些属性

  4. 我们将感兴趣的分面添加到文档中

既然我们正在索引时收集项目名称,我们就可以将它们打印出来

apache-nlpcraft-with-groovy.adoc: [apache nlpcraft:5]
classifying-iris-flowers-with-deep.adoc: [eclipse deeplearning4j:5, apache commons math:1, apache spark:2]
community-over-code-eu-2024.adoc: [apache ofbiz:1, apache commons math:2, apache ignite:1, apache spark:1, apache wayang:1, apache beam:1, apache flink:1]
community-over-code-na-2023.adoc: [apache ignite:8, apache commons numbers:1, apache commons csv:1]
deck-of-cards-with-groovy.adoc: [eclipse collections:5]
deep-learning-and-eclipse-collections.adoc: [eclipse collections:7, eclipse deeplearning4j:2]
detecting-objects-with-groovy-the.adoc: [apache mxnet:12]
fruity-eclipse-collections.adoc: [eclipse collections:9, apache commons math:1]
fun-with-obfuscated-groovy.adoc: [apache commons math:1]
groovy-2-5-clibuilder-renewal.adoc: [apache commons cli:2]
groovy-graph-databases.adoc: [apache age:11, apache hugegraph:3, apache tinkerpop:3]
groovy-haiku-processing.adoc: [eclipse collections:3]
groovy-list-processing-cheat-sheet.adoc: [eclipse collections:4, apache commons collections:3]
groovy-lucene.adoc: [apache nutch:1, apache solr:1, apache lucene:3, apache commons:4, apache commons math:2, apache spark:1]
groovy-null-processing.adoc: [eclipse collections:6, apache commons collections:4]
groovy-pekko-gpars.adoc: [apache pekko:4]
groovy-record-performance.adoc: [apache commons codec:1]
handling-byte-order-mark-characters.adoc: [apache commons io:1]
lego-bricks-with-groovy.adoc: [eclipse collections:6]
matrix-calculations-with-groovy-apache.adoc: [apache commons math:6, eclipse deeplearning4j:1, apache commons:1]
natural-language-processing-with-groovy.adoc: [apache opennlp:2, apache spark:1]
reading-and-writing-csv-files.adoc: [apache commons csv:2]
set-operations-with-groovy.adoc: [eclipse collections:3]
solving-simple-optimization-problems-with-groovy.adoc: [apache commons math:5, apache kie:1]
using-groovy-with-apache-wayang.adoc: [apache wayang:9, apache spark:7, apache flink:1, apache commons csv:1, apache ignite:1]
whiskey-clustering-with-groovy-and.adoc: [apache ignite:7, apache wayang:1, apache spark:2, apache commons csv:2]
wordle-checker.adoc: [eclipse collections:3]
zipping-collections-with-groovy.adoc: [eclipse collections:4]

现在进行搜索时,我们可以提取分类信息以及其他信息。通过 `projectHitCounts`,我们可以从搜索结果中获取最高命中的分类元数据。我们将使用 `MatchAllDocsQuery` 来匹配所有文档,即元数据将适用于所有文档。

var reader = DirectoryReader.open(indexDir)
var searcher = new IndexSearcher(reader)
var taxonReader = new DirectoryTaxonomyReader(taxonDir)
var fcm = new FacetsCollectorManager()
var query = new MatchAllDocsQuery()
var fc = FacetsCollectorManager.search(searcher, query, 0, fcm).facetsCollector()

var topN = 5
var projects = new TaxonomyFacetIntAssociations('$projectHitCounts', taxonReader,
    fConfig, fc, AssociationAggregationFunction.SUM)
var hitData = projects.getTopChildren(topN, 'projectHitCounts').labelValues

println "\nFrequency of total hits mentioning a project (top $topN):"
display(hitData.collectEntries{ lv -> [lv.label, lv.value] }, 50)

println "\nFrequency of documents mentioning a project (top $topN):"
display(hitData.collectEntries{ lv -> [lv.label, lv.count] }, 20, 2)

当运行此代码时,我们可以看到总命中数和文件数的频率

Frequency of total hits mentioning a project (top 5):
eclipse collections (50)         ██████████████████████████████████████████████████▏
apache commons math (18)         ██████████████████▏
apache ignite (17)               █████████████████▏
apache spark (14)                ██████████████▏
apache mxnet (12)                ████████████▏

Frequency of documents mentioning a project (top 5):
eclipse collections (10)         ████████████████████▏
apache commons math (7)          ██████████████▏
apache ignite (4)                ████████▏
apache spark (6)                 ████████████▏
apache mxnet (1)                 ██

现在,关于文档频率的分类法信息是基于命中数得分最高的命中。我们的另一个分面(`projectFileCounts`)独立跟踪文档频率。让我们看看如何查询该信息。

var facets = new FastTaxonomyFacetCounts(taxonReader, fConfig, fc)

println "\nFrequency of documents mentioning a project (top $topN):"
println facets.getTopChildren(topN, 'projectFileCounts')

我们可以像之前一样将搜索结果(`FacetResult` 实例)显示为条形图,但结果的 `toString` 也非常有用。以下是运行上述代码的样子:

Frequency of documents mentioning a project (top 5):
dim=projectFileCounts path=[] value=-1 childCount=27
  eclipse collections (10)
  apache commons math (7)
  apache spark (6)
  apache ignite (4)
  apache commons csv (4)

将此结果与我们上一个分面的结果进行比较时,我们可以看到 commons csv 在更多文件中被提及,尽管 mxnet 被提及的次数更多。一般来说,您会决定哪个文档频率对您更感兴趣,如果您不需要这些额外信息,则会跳过 `projectFileCounts` 分面。

我们的最后一个分面(`projectNameCounts`)是一个分层分面。这些通常在“浏览”搜索结果时交互式使用。我们可以按第一个词查看项目名称,例如基金会。然后我们可以深入到其中一个基金会,例如“Apache”,并找到引用的项目,然后对于 commons,我们可以深入到其子项目。以下是执行此操作的代码。

['apache', 'commons'].inits().reverseEach { path -> // (1)
    println "Frequency of documents mentioning a project with path $path (top $topN):"
    println "${facets.getTopChildren(topN, 'projectNameCounts', *path)}"
}
  1. `inits()` 方法返回一个列表的所有前缀,包括空列表。

输出如下

Frequency of documents mentioning a project with path [] (top 5):
dim=projectNameCounts path=[] value=-1 childCount=2
  apache (21)
  eclipse (12)

Frequency of documents mentioning a project with path [apache] (top 5):
dim=projectNameCounts path=[apache] value=-1 childCount=18
  commons (16)
  spark (6)
  ignite (4)
  wayang (3)
  flink (2)

Frequency of documents mentioning a project with path [apache, commons] (top 5):
dim=projectNameCounts path=[apache, commons] value=-1 childCount=7
  math (7)
  csv (4)
  collections (2)
  numbers (1)
  cli (1)

我们现在既有分类法索引,也有普通索引,所以我们仍然可以进行可能只使用后者的即席查询。

var parser = new QueryParser('content', analyzer)
var query = parser.parse(/apache\ * AND eclipse\ * AND emoji*/)
var results = searcher.search(query, topN)
var storedFields = searcher.storedFields()
assert results.totalHits.value() == 1 &&
    storedFields.document(results.scoreDocs[0].doc).get('name') == 'fruity-eclipse-collections.adoc'

此查询显示,恰好有一篇博客文章提到了 Apache 项目、Eclipse 项目以及表情符号。

更复杂的查询

作为最后一个例子,我们之前选择在索引时提取项目名称。我们也可以改用更典型的分析器,代价是需要在搜索时使用更复杂的跨度查询来提取项目名称。让我们看看这种情况下代码可能是什么样子。

首先,我们将使用 `StandardAnalyzer` 进行索引。

var analyzer = new StandardAnalyzer()
var indexDir = new ByteBuffersDirectory()
var config = new IndexWriterConfig(analyzer)

new IndexWriter(indexDir, config).withCloseable { writer ->
    new File(baseDir).traverse(nameFilter: ~/.*\.adoc/) { file ->
        file.withReader { br ->
            var document = new Document()
            var fieldType = new FieldType(stored: true,
              indexOptions: IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS,
              storeTermVectors: true,
              storeTermVectorPositions: true,
              storeTermVectorOffsets: true)
            document.add(new Field('content', br.text, fieldType))
            document.add(new StringField('name', file.name, Field.Store.YES))
            writer.addDocument(document)
        }
    }
}

现在我们的查询需要更复杂。我们有几种选择,但我们将选择使用 Lucene 的一些低级查询类来组合我们的查询。

注意
在考虑 Lucene 的低级查询类之前,您可能需要查看 Lucene 的一些高级查询类,例如 `QueryParser` 类。它支持将查询表示为字符串,并包含对短语、范围、正则表达式术语等的支持。据我所知,它不支持短语中的正则表达式,因此我们将探索下面的低级类。

我们将查找类似“apache commons ”或“(apache|eclipse) ”的表达式,其中 *suffix* 是不带基金会前缀的项目名称,或者在 Apache Commons 的情况下,是子项目名称。

我们不会像正则表达式那样有一个停用词(排除词)列表,而是会有一个允许的项目后缀名称列表。如果需要,切换到停用词方法也很容易。

IndexReader reader = DirectoryReader.open(indexDir)
var searcher = new IndexSearcher(reader)

var projects = [
    'math', 'spark', 'lucene', 'collections', 'deeplearning4j',
    'beam', 'wayang', 'csv', 'io', 'numbers', 'ignite', 'mxnet', 'age',
    'nlpcraft', 'pekko', 'hugegraph', 'tinkerpop', 'commons',
    'cli', 'opennlp', 'ofbiz', 'codec', 'kie', 'flink'
]
var suffix = new SpanMultiTermQueryWrapper(new RegexpQuery( // (1)
    new Term('content', "(${projects.join('|')})")))

// look for apache commons <suffix>
SpanQuery[] spanTerms = ['apache', 'commons'].collect{
    new SpanTermQuery(new Term('content', it))
} + suffix
var apacheCommons = new SpanNearQuery(spanTerms, 0, true)

// look for (apache|eclipse) <suffix>
var foundation = new SpanMultiTermQueryWrapper(new RegexpQuery(
    new Term('content', '(apache|eclipse)')))
var otherProject = new SpanNearQuery([foundation, suffix] as SpanQuery[], 0, true)

var builder = new BooleanQuery.Builder(minimumNumberShouldMatch: 1)
builder.add(otherProject, BooleanClause.Occur.SHOULD)
builder.add(apacheCommons, BooleanClause.Occur.SHOULD)
var query = builder.build()
var results = searcher.search(query, 30)
println "Total documents with hits for $query --> $results.totalHits"
  1. 正则表达式查询被包装以出现在跨度查询中

当我们运行这个时,我们看到的命中数与之前相同。

Total documents with hits for (spanNear([SpanMultiTermQueryWrapper(content:/(apache|eclipse)/), SpanMultiTermQueryWrapper(content:/(math|spark|lucene|collections|deeplearning4j|beam|wayang|csv|io|numbers|ignite|mxnet|age|nlpcraft|pekko|hugegraph|tinkerpop|commons|cli|opennlp|ofbiz|codec|kie|flink)/)], 0, true) spanNear([content:apache, content:commons, SpanMultiTermQueryWrapper(content:/(math|spark|lucene|collections|deeplearning4j|beam|wayang|csv|io|numbers|ignite|mxnet|age|nlpcraft|pekko|hugegraph|tinkerpop|commons|cli|opennlp|ofbiz|codec|kie|flink)/)], 0, true))~1 --> 28 hits

对于这个例子,我们可能要考虑的另一件事是利用 Groovy 优秀的领域特定语言(DSL)功能。通过定义一个辅助方法 `span`,并为 Lucene 的 `Query` 类提供一个 `or` 的元编程扩展,我们可以用更紧凑和易懂的形式重写上一个例子的最后 20 行:

var suffix = "(${projects.join('|')})"
var query = span('apache', 'commons', ~suffix) | span(~'(apache|eclipse)', ~suffix)
var results = searcher.search(query, 30)
println "Total documents with hits for $query --> $results.totalHits"

运行代码会得到与之前相同的输出。如果您对 DSL 细节感兴趣,请查看源文件

我们可以在其他词项上尝试我们的 DSL

query = span('jackson', 'databind') | span(~'virt.*', 'threads')
results = searcher.search(query, 30)
println "Total documents with hits for $query --> $results.totalHits"

运行时,我们将看到如下输出:

Total documents with hits for (spanNear([content:jackson, content:databind], 0, true) spanNear([SpanMultiTermQueryWrapper(content:/virt.*/), content:threads], 0, true))~1 --> 8 hits

使用 `StandardAnalyzer` 配合跨度查询无疑开辟了更广泛的查询可能性。但 `StandardAnalyzer` 也有其他优点。它内置了停用词、智能分词、小写转换等功能。其他内置分析器也可能有用。当然,我们也可以让我们的基于正则表达式的分析器更智能。Lucene 的许多功能都是可重用部件,这确实很有帮助。

`StandardAnalyzer` 的一个有趣优势是它能正确处理索引中的表情符号。我们的正则表达式分析器目前只查找“正则表达式单词”字符,不包括表情符号字符,尽管它可以扩展以支持它们。

鉴于我们在此使用了 `StandardAnalyzer`,让我们再次查看索引中的术语,但这次提取表情符号而不是项目名称。

var vectors = reader.termVectors()
var storedFields = reader.storedFields()

var emojis = [:].withDefault { [] as Set }
for (docId in 0..<reader.maxDoc()) {
    String name = storedFields.document(docId).get('name')
    TermsEnum terms = vectors.get(docId, 'content').iterator()
    while (terms.next() != null) {
        PostingsEnum postingsEnum = terms.postings(null, PostingsEnum.ALL)
        while (postingsEnum.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
            var string = terms.term().utf8ToString()
            if (string.codePoints().allMatch(Character::isEmojiPresentation)) {
                emojis[name] += string
            }
        }
    }
}
emojis.collect { k, v -> "$k: ${v.join(', ')}" }.each { println it }

运行时,您应该会看到类似这样的内容(部分平台可能无法显示旗帜表情符号)

LuceneWithStandardAnalyzer

参考文献

结论

我们已经分析了 Groovy 博客文章,使用正则表达式和 Apache Lucene 查找引用的项目。希望这能让您领略 Lucene API 和 Groovy 的一些功能。

更新历史

2024年12月22日:更新至 Lucene 10.1.0