65.9K
CodeProject 正在变化。 阅读更多。
Home

Lucene 和 Java 深入教程

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2024 年 3 月 8 日

MIT

23分钟阅读

viewsIcon

7003

downloadIcon

169

这是一篇关于在 Java 应用程序中集成 Lucene 搜索和索引引擎的更深入教程。

引言

五年前,我写了教程文章 《Lucene 全文搜索 - 一个非常基础的教程》。那是一个关于将 Lucene 集成到基于 Java 的控制台应用程序的初学者教程。最近,我又重新拾起了 Lucene 用于我的小型副项目。五年过去了,Lucene 发生了很多变化。在进行这次集成工作时,我学到了一些新东西。我将在本教程中分享这些。

首先,我了解到 Lucene SDK 已经发布了多个新版本。因此,我旧教程中的一些内容不再适用。我遇到的下一个问题是 StringFieldTextField 之间的区别。我还学到了一些新东西。

本教程将探讨所有这些要点。总体而言,将 Lucene 集成到应用程序中的通用方法几乎相同。改变的是您应该编写的实际代码。我将从头开始,解释如何将 Lucene 配置到您的应用程序中,包括您应该在项目中包含的版本和 JAR 包,以及使 Lucene 在您的应用程序中运行所需的引导步骤。以及如何创建文档索引、如何搜索它们以及如何在需要时更新文档。这将是一个有趣的教程。

架构

本教程包含一个非常酷的示例应用程序。它是一个基于 Spring Boot 的 MVC 应用程序。它具有以下功能:

这个应用程序的酷之处在于用户可以创建和修改索引存储库并测试文档搜索。然后可以将索引存储库集成到不同的应用程序中。这假设其他应用程序使用相同的文档存储格式和与此示例应用程序几乎相同的搜索功能。我在开发 Spring MVC 静态网站时产生了这种想法。我没有在该应用程序中添加/编辑文档,而是将文档浏览、添加/编辑/删除功能分离到一个新应用程序中——职责分离。您可以将它用于相同目的。复制并粘贴您的索引、文档存储格式类型和搜索功能到您的应用程序中。您的 Java 应用程序将具有全文搜索功能。

对于此示例应用程序,我使用的是最新的 Lucene SDK。我将从该示例应用程序所需的 JAR 依赖项开始。然后我将讨论我为成功集成 Lucene 所做的所有更改。

示例应用程序的 POM 文件

在撰写本教程时,最新的 Lucene SDK 版本是 9.9.1。我在我的 Maven POM 文件中包含了以下 JAR 包:

<dependency>
   <groupId>org.apache.lucene</groupId>
   <artifactId>lucene-core</artifactId>
   <version>9.9.1</version>
</dependency>

<dependency>
   <groupId>org.apache.lucene</groupId>
   <artifactId>lucene-queryparser</artifactId>
   <version>9.9.1</version>
</dependency>

<dependency>
   <groupId>org.apache.lucene</groupId>
   <artifactId>lucene-codecs</artifactId>
   <version>9.9.1</version>
</dependency>

第一个 JAR 依赖项是打开存储库和索引文档所必需的。第二个 JAR 用于创建查询以在存储库中搜索文档。最后一个用于为索引操作提供标准的分词分析器。在我之前的教程中,我只使用了第一个 JAR 依赖项。在新版本中,一些查询功能和分词分析功能不再是 Lucene-core JAR 依赖项的一部分。我必须包含第二个和第三个 JAR 才能使所有所需功能正确编译。

在下一节中,我将解释所有 Lucene 功能。我还将指出这些功能需要新 JAR 依赖项的位置。

利用 Lucene 功能

在深入代码之前,我想解释一下我想索引和检索的文档。此类文档包含以下元素:

这些元素/字段应捕获文档的所有可索引/仅存储方面。如果需要更多,您可以随时添加更多字段来捕获它们。

自从我写第一篇教程以来,Lucene SDK 用于索引或查询文档的大部分概念都保持不变。一个可索引文档由多个字段组成。每个字段都有一个特定类型。最常用的类型是 TextField。还有 StringField(也是一种基于文本的可索引字段)和数字字段,如 LongField。字段可以是可索引字段,也可以是非可索引字段。可索引字段可查询;后者则不可查询。可以指定字段是否存储原始内容。我忘记了一件事是 StringFieldTextField 之间的区别。StringField 将内容视为一个令牌,而 TextField 将根据指定的分词分析器将单词分解为令牌。假设它们是相同的,我对大多数字段使用了 StringField,然后我的查询未能找到结果,因为单个单词未能匹配这些字段中的任何内容。我花了几个小时才找到原因,只是在查阅了最新文档之后。我应该在开始工作之前阅读我自己的教程。那会节省我那几个小时。如果您想了解更多关于许多不同类型的字段,可以查看我的 之前的教程。我已经很好地解释了它们。这些字段的行为方式是相同的。

这是我的可索引文档数据类型

package org.hanbo.boot.rest.models;

import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;

import org.springframework.util.StringUtils;
import org.slf4j.*;

public class IndexableDocumentModel
implements Serializable
{
   private static Logger _logger = 
           org.slf4j.LoggerFactory.getLogger(IndexableDocumentModel.class);
   private static final long serialVersionUID = 4473992068573410570L;

   private String id;
   
   private String title;
   
   private String keywords;
   
   private String type;
   
   private String abstractText;
   
   private String author;
   
   private Date createdDate;
   
   private String createdDateInputValue;

   private Date updatedDate;
   
   private String content;
   
   private String contentUrl;
   
   private boolean saveSuccess;
   
   private String errorMessage;
   
   public IndexableDocumentModel()
   {
      setErrorMessage("");
      setCreatedDate(new Date());
   }

   public String getId()
   {
      return id;
   }

   public void setId(String id)
   {
      this.id = id;
   }
   
   public String getTitle()
   {
      return title;
   }

   public void setTitle(String title)
   {
      this.title = title;
   }

   public String getKeywords()
   {
      return keywords;
   }

   public void setKeywords(String keywords)
   {
      this.keywords = keywords;
   }

   public String getType()
   {
      return type;
   }

   public void setType(String type)
   {
      this.type = type;
   }

   public String getAbstractText()
   {
      return abstractText;
   }

   public void setAbstractText(String abstractText)
   {
      this.abstractText = abstractText;
   }

   public String getAuthor()
   {
      return author;
   }

   public void setAuthor(String author)
   {
      this.author = author;
   }

   public Date getCreatedDate()
   {
      return createdDate;
   }

   public void setCreatedDate(Date createdDate)
   {
      this.createdDate = createdDate;
      SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
      String datevalText = fmt.format(createdDate);
      this.setCreatedDateInputValue(datevalText);
   }

   public String getContent()
   {
      return content;
   }

   public void setContent(String content)
   {
      this.content = content;
   }

   public String getContentUrl()
   {
      return contentUrl;
   }

   public void setContentUrl(String contentUrl)
   {
      this.contentUrl = contentUrl;
   }

   public String getErrorMessage()
   {
      return errorMessage;
   }

   public void setErrorMessage(String errorMessage)
   {
      this.errorMessage = errorMessage;
   }

   public boolean isSaveSuccess()
   {
      return saveSuccess;
   }

   public void setSaveSuccess(boolean saveSuccess)
   {
      this.saveSuccess = saveSuccess;
   }

   public String getCreatedDateInputValue()
   {
      return createdDateInputValue;
   }

   public void setCreatedDateInputValue(String createdDateInputValue)
   {
      this.createdDateInputValue = createdDateInputValue;
   }
   
   public Date getUpdatedDate()
   {
      return updatedDate;
   }

   public void setUpdatedDate(Date updatedDate)
   {
      this.updatedDate = updatedDate;
   }
   
   public void convertDateTextToDateObject()
   {
      String dateText = getCreatedDateInputValue();
      if (StringUtils.hasText(dateText))
      {
         SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
         try
         {
            Date dateVal = fmt.parse(dateText);
            setCreatedDate(dateVal);
         }
         catch(Exception ex)
         {
            _logger.error("Exception occurred when attempt to 
                           convert string to Date object.", ex);
         }
      }
   }
}

此数据模型对象类型使用 ModelAttribute 绑定到添加/编辑文档页面。其中一个字段用于指定此文档的创建日期。我需要两组属性访问器来处理这一个日期字段。上次更新日期字段会自动更新,因此只有一组访问器。我还将操作成功和详细错误/成功消息的属性包含在此数据模型中,因为它们重新绑定到同一页面,并且可以用于显示先前操作的状态。您将在后面的部分中看到如何使用它。

将文档添加到 Lucene 索引库

将文档添加到 Lucene 索引存储库的方式与我在 我的上一个教程 中记录的方式相同。由于过去五年发生了变化,我不得不做一些修改。在示例项目中,我有一个名为 DocumentIndexerServiceImpl 的服务类实现。在其中,您可能会找到以下方法:

@Override
public boolean indexDocument(Document docToIndex)
{
   boolean retVal = false;
   
   if (docToIndex == null)
   {
      _logger.error("Document to index is NULL. Unable to complete operation.");
      return retVal;
   }
         
   IndexWriter writer = null;
   try
   {
      Directory indexWriteToDir =
            FSDirectory.open(Paths.get(indexDirectory));
      
      IndexWriterConfig iwc = new IndexWriterConfig(new StandardAnalyzer());
      iwc.setOpenMode(OpenMode.CREATE_OR_APPEND);
      iwc.setCodec(new SimpleTextCodec());
      writer = new IndexWriter(indexWriteToDir, iwc);
      writer.addDocument(docToIndex);
      writer.flush();
      writer.commit();
      
      retVal = true;
   }
   catch(Exception ex)
   {
      _logger.error("Exception occurred when indexing document with id [%s].", ex);
      retVal = false;
   }
   finally
   {
      if (writer != null)
      {
         try
         {
            writer.close();
         }
         catch(Exception ex)
         { }
      }
   }
   
   return retVal;
}

在此方法中,最核心的部分在 try-catch 块中。我选择将文档写入指定的文件目录。这是利用 Lucene 最简单的方式。您还可以选择内存或其他存储介质来存储索引文件。新功能是我将 StandardAnalyzer 对象添加到 IndexWriterConfig 对象。对于相同的配置对象,我添加了一个 SimpleTextCodec 对象,以便索引写入器可以使用美国英语的基本规则对文本进行分词。我还将此 IndexWriterConfig 对象的打开模式设置为 “OpenMode.CREATE_OR_APPEND”。这基本上意味着如果目录没有索引文件,写入器使用此 config 对象创建一个;如果存在索引文件,则新文档将附加到索引文件。

其余部分创建 IndexWriter 对象并通过调用其 AddDocument() 方法写入文档。作为方法参数传入的 Document 对象将是要添加的文档。完成后,writer 对象会进行刷新并提交保存。在方法结束时,writer 对象将关闭。

文档如何添加

上一个教程不同,此示例应用程序提供了一个页面,允许用户输入文档的所有字段,然后单击“保存”按钮,应用程序会将文档保存到 Lucene 索引中。这是用户可以输入文档信息的页面的屏幕截图:

从屏幕截图中可以看出,该页面是一个典型的基于 MVC 的网页。您输入所有字段的值,然后单击页面底部的“保存”按钮。文档将被索引到 Lucene 索引存储库中。这是我在文件“addNew.html”中定义的表单:

<form th:action="@{/addNewDocument}" th:object="${documentModel}" 
      method="post" novalidate>
   <div class="row" th:if='${documentModel.errorMessage != null 
   && !documentModel.errorMessage.trim().equals("")}'>
      <div class="col">
         <div class="alert alert-danger" th:if="${documentModel.saveSuccess == false}" 
         th:text="${documentModel.errorMessage}"></div>
         <div class="alert alert-success" th:if="${documentModel.saveSuccess == true}" 
         th:text="${documentModel.errorMessage}"></div>
      </div>
   </div>
   <div class="row">
      <div class="col mb-2">
         <label for="titleField">Title</label>
         <input th:field="${documentModel.title}" 
         id="titleField" name="titleField" class="form-control" />
      </div>
   </div>
   <div class="row">
      <div class="col-4 mb-2">
         <label for="contentTypeField">Content Type</label>
         <input th:field="${documentModel.type}" id="contentTypeField" 
         name="contentTypeField" class="form-control" />
      </div>
   </div>
   <div class="row">
      <div class="col-4 mb-2">
         <label for="authorField">Author</label>
         <input th:field="${documentModel.author}" id="authorField" 
         name="authorField" class="form-control" />
      </div>
   </div>
   <div class="row">
      <div class="col-5 mb-2">
         <label for="createdDateField">Created Date</label>
         <input type="text" name="createdDateField" class="form-control"
                  th:field="${documentModel.createdDateInputValue}"
                  th:value="${documentModel.createdDate}?${#dates.format
                             (documentModel.createdDate, 'yyyy-MM-dd')}:''" 
                  placeholder="yyyy-MM-dd"  id="createdDateField" />
      </div>
   </div>

   <div class="row">
      <div class="col mb-2">
         <label for="keywordsField">Keywords</label>
         <input th:field="${documentModel.keywords}" 
         id="keywordsField" name="keywordsField" class="form-control" />
      </div>
   </div>
   <div class="row">
      <div class="col mb-2">
         <label for="contentUrlField">Content URL</label>
         <input th:field="${documentModel.contentUrl}" 
         id="contentUrlField" name="contentUrlField" class="form-control" />
      </div>
   </div>
   <div class="row">
      <div class="col mb-2">
         <label for="abstractTextField">Abstract</label>
         <textarea th:field="${documentModel.abstractText}"
                  id="abstractTextField"
                  name="abstractTextField"
                  rows="4"
                  class="form-control fixed"></textarea>
      </div>
   </div>
   <div class="row">
      <div class="col mb-2">
         <label for="contentField">Content to Index</label>
         <textarea th:field="${documentModel.content}"
                  id="contentField"
                  name="contentField"
                  rows="7"
                  class="form-control fixed"></textarea>
      </div>
   </div>
   <div class="row">
      <div class="col">
         <button class="btn btn-primary form-control" type="submit">Save</button>
      </div>
      <div class="col">
         <button class="btn btn-default btn-outline-secondary form-control" 
         type="reset">Clear</button>
      </div>
   </div>            
</form>

关于上面的代码片段,有几点我想解释一下。首先,我使用 Spring MVC 进行表单处理,仅此而已。您会注意到的第一件事是表单的声明方式:

<form th:action="@{/addNewDocument}" th:object="${documentModel}" 
 method="post" novalidate>
...

</form>

action 属性 (th:action) 是指向将处理文档的动作处理方法的 URL。有趣的是名为 “object” (th:object) 的属性。它指定了绑定到表单的数据模型。在这种情况下,从控制器传递的数据模型对象名为 documentModel。下一块 HTML 标记代码显示操作状态:

<div class="row" th:if='${documentModel.errorMessage != null 
&& !documentModel.errorMessage.trim().equals("")}'>
   <div class="col">
      <div class="alert alert-danger" th:if="${documentModel.saveSuccess == false}" 
      th:text="${documentModel.errorMessage}"></div>
      <div class="alert alert-success" th:if="${documentModel.saveSuccess == true}" 
      th:text="${documentModel.errorMessage}"></div>
   </div>
</div>

请记住我之前提到的 IndexableDocumentModel 数据类型中的两个额外属性。它们在这里使用。代码块将使用属性 saveSuccess 来决定是显示绿色状态栏(当其值为 true 时)还是红色状态栏。在其中,消息将指示上一个操作是否成功。如果没有上一个操作,则不显示任何内容。

在此表单中的每个字段,都有一个字段值到对象类型 IndexableDocumentModel 属性的绑定。这是一个示例:

<div class="row">
   <div class="col mb-2">
      <label for="abstractTextField">Abstract</label>
      <textarea th:field="${documentModel.abstractText}"
               id="abstractTextField"
               name="abstractTextField"
               rows="4"
               class="form-control fixed"></textarea>
   </div>
</div>

这是 HTML 元素 <textarea/>。有一个字段属性 th:field 映射到对象属性 documentModel.abstractText。当页面提交到服务器时,此表单的字段将转换为 IndexableDocumentModel 类型的数据对象,并传递给将处理来自此表单的请求的方法。

最初显示此页面的 Java 代码如下:

@RequestMapping(value="/addNewHtmlDoc", method=RequestMethod.GET)
public String addNewHtmlDoc(Model model)
{
   HtmlDocumentInputModel modelToAdd = new HtmlDocumentInputModel();
   model.addAttribute("documentModel", modelToAdd);
   return "addNewHtmlDoc";
}

当用户填写表单并点击“保存”按钮后,请求将由以下方法处理:

@RequestMapping(value="/addNewDocument", method=RequestMethod.POST)
public String addNewDocument(Model model,
      @ModelAttribute("documentModel")
      IndexableDocumentModel docToAdd)
{
   if (docToAdd != null)
   {
      docToAdd.setUpdatedDate(new Date());
      addNewDocument(docToAdd, model);
   }
   else
   {
      IndexableDocumentModel modelToAdd = new IndexableDocumentModel();
      modelToAdd.setSaveSuccess(false);
      modelToAdd.setErrorMessage("The input document content data model is NULL.");
      model.addAttribute("documentModel", modelToAdd);
   }

   return "addNew";
}

这个方法没做太多事情。它将大部分工作委托给一个名为 addNewDocument()private 方法。我使用这个 private 方法的原因是,示例应用程序有两个地方处理将文档添加到 Lucene 索引存储库,这里以及应用程序尝试索引 HTML 文档的方法。两者共享相同的功能。以下是 private 方法 addNewDocument()

private String addNewDocument(IndexableDocumentModel docToAdd, Model model)
{
   boolean docValid = _docIndexSvc.validateDocumentModel(docToAdd);
   if (!docValid)
   {
      model.addAttribute("documentModel", docToAdd);
      return "addNew";
   }
   
   docToAdd.convertDateTextToDateObject();
   Document indexableDoc = _docIndexSvc.createIndexableDocument(docToAdd);
   if (indexableDoc != null)
   {
      boolean opSuccess = _docIndexSvc.indexDocument(indexableDoc);
      if (opSuccess)
      {
         IndexableDocumentModel modelToAdd = new IndexableDocumentModel();
         modelToAdd.setSaveSuccess(opSuccess);
         modelToAdd.setErrorMessage("Document content has been indexed successfully.");
         model.addAttribute("documentModel", modelToAdd);
         return "addNew";
      }
      else
      {
         docToAdd.setSaveSuccess(opSuccess);
         docToAdd.setErrorMessage("Unable to index document content. 
                                   Please see the server log for more details.");
         model.addAttribute("documentModel", docToAdd);
         return "addNew";
      }
   }
   else
   {
      docToAdd.setSaveSuccess(false);
      docToAdd.setErrorMessage("Unable to create indexable document 
      from the input document model. Please see the server log for more details.");
      model.addAttribute("documentModel", docToAdd);
      return "addNew";
   }
}

这个方法不难理解。首先,它验证 IndexableDocumentModel 数据对象。如果数据对象有任何问题(例如某些属性未填写),它将设置状态为失败,并显示状态消息。相同的数据对象将返回到 addNew.html 页面。然后,该方法将 IndexableDocumentModel 数据对象转换为 Lucene Document 对象。这是 Lucene 将索引的对象。最后,Document 对象将传递给 DocumentIndexerService 对象类型的 indexDocument() 方法。这个 indexDocument() 方法就是我们在本小节最顶部看到的方法。这是将 IndexableDocumentModel 数据对象转换为 Lucene Document 对象的方法:

@Override
public Document createIndexableDocument(IndexableDocumentModel inDocModel)
{
   Document retVal = null;
   if (inDocModel != null)
   {
      retVal = new Document();
      
      String docId = IdUtils.generateDocumentId();
      IndexableField idField = new StringField("DOCID", docId, Field.Store.YES);
      retVal.add(idField);
      
      IndexableField titleField = new TextField
      ("TITLE", inDocModel.getTitle(), Field.Store.YES);
      retVal.add(titleField);

      IndexableField docTypeField = new StringField
      ("DOCTYPE", inDocModel.getType(), Field.Store.YES);
      retVal.add(docTypeField);

      IndexableField authorField = new TextField
      ("AUTHOR", inDocModel.getAuthor(), Field.Store.YES);
      retVal.add(authorField);

      IndexableField keywordsField = new TextField
      ("KEYWORDS", inDocModel.getKeywords(), Field.Store.YES);
      retVal.add(keywordsField);
      
      IndexableField abstractTextField = new TextField
      ("ABSTRACT", inDocModel.getAbstractText(), Field.Store.YES);
      retVal.add(abstractTextField);
      
      IndexableField contentUrlField = 
               new StoredField("PATH", inDocModel.getContentUrl());
      retVal.add(contentUrlField);
      
      IndexableField contentTextField = new TextField
      ("CONTENT", inDocModel.getContent(), Field.Store.YES);
      retVal.add(contentTextField);
      
      IndexableField createdDateField = new LongField
      ("CREATEDDATE", DateUtils.convertDateToLong
                      (inDocModel.getCreatedDate()), Field.Store.YES);
      retVal.add(createdDateField);
      
      IndexableField createdDateTextField = new StringField
      ("CREATEDDATETEXT", DateUtils.convertDateToText
      (inDocModel.getCreatedDate(), false), Field.Store.YES);
      retVal.add(createdDateTextField);
      
      IndexableField updatedDateField = new LongField
      ("UPDATEDDATE", DateUtils.convertDateToLong
                      (inDocModel.getUpdatedDate()), Field.Store.YES);
      retVal.add(updatedDateField);
      
      IndexableField updatedDateTextField = new StringField
      ("UPDATEDDATETEXT", DateUtils.convertDateToText
               (inDocModel.getUpdatedDate(), true), Field.Store.YES);
      retVal.add(updatedDateTextField);
   }

   return retVal;
}

这就是将文档添加到 Lucene 索引存储库的全部内容。您可以在 DocumentIndexingControllerDocumentIndexerServiceImpl 类中找到所有这些。接下来,我将讨论我在之前的教程中做过的事情——如何列出 Lucene 索引存储库中的所有文档以及如何编辑/删除文档。

列出所有文档

Lucene 索引存储库就像一个数据库。不幸的是,没有一个免费的工具允许我查看存储库,并随意编辑或删除文档。我个人不知道也没有搜索过这样的工具。我只是在这个示例应用程序中编写了这些功能。让我们看看第一个功能——如何列出存储库中的所有文档。

DocumentIndexingController 类中,有一个名为 listAll() 的 action 方法。此方法将加载所有文档。我没有添加任何分页功能。所有结果都在同一页面上显示。listAll() 的代码如下:

@RequestMapping(value="/listAll", method=RequestMethod.GET)
public String listAll(Model model)
{
   List<indexeddocumentlistitem> allDocs = _docIndexSvc.allDocuments();
   if (allDocs != null && !allDocs.isEmpty())
   {
      model.addAttribute("allDocuments", allDocs);
      for (IndexedDocumentListItem itm : allDocs)
      {
         debugOutputIndexedDocument(itm);
      }
   }
   else
   {
      model.addAttribute("allDocuments", new ArrayList<IndexedDocumentListItem>());
   }
   
   return "listAll";
}</indexeddocumentlistitem>

它其实没有做太多。实际工作是由服务对象类型 DocumentIndexerServiceImpl 中的 allDocuments() 方法完成的。此方法如下所示:

@Override
public List<IndexedDocumentListItem> allDocuments()
{
   List<IndexedDocumentListItem> retVal = new ArrayList<IndexedDocumentListItem>();
   try
   {
      Directory dirOfIndexes =
            FSDirectory.open(Paths.get(indexDirectory));
      IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dirOfIndexes));
      QueryParser queryParser = new QueryParser("title", new StandardAnalyzer());
      Query query = queryParser.parse("*:*");
      TopDocs allFound = searcher.search(query, 32767);
      
      if (allFound.scoreDocs != null)
      {
         StoredFields storedFields = searcher.storedFields();
         
         for (ScoreDoc doc : allFound.scoreDocs)
         {
            int docidx = doc.doc;
            Document docRetrieved = storedFields.document(docidx);
            if (docRetrieved != null)
            {
               IndexedDocumentListItem docToAdd = 
                      createIndexedDocumentListItem(docidx, docRetrieved);
               retVal.add(docToAdd);
            }
         }
      }
   }
   catch (Exception ex)
   {
      _logger.error("Exception occurred when loading all indexed documents", ex);
      retVal = new ArrayList<IndexedDocumentListItem>();
   }
   
   return retVal;
}

我曾经想知道如何加载存储库中的所有文档。结果发现,最简单的方法是搜索所有字段中的任何文本,它将返回所有文档。您可以通过以下几行代码完成此操作:

...
IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dirOfIndexes));
QueryParser queryParser = new QueryParser("title", new StandardAnalyzer());
Query query = queryParser.parse("*:*");
TopDocs allFound = searcher.search(query, 32767);
...

在上面的代码中,我创建了一个 IndexSearcher 对象。然后我创建了一个 QueryParser 对象。请注意,QueryParser 对象是 org.apache.lucene.queryparser.classic.QueryParser 类型。QueryParser 对象可以为您准备一个 Query 对象,以便可以将其传递给 searcher.search()。第二个参数是要返回的最大结果数。我将其硬编码为 32,767 个结果。所有结果都存储在 TopDocs 对象中。方法的其余部分是遍历结果并将其转换为 IndexedDocumentListItem 对象,并在 listAll.html 页面上显示。

...
if (allFound.scoreDocs != null)
{
   StoredFields storedFields = searcher.storedFields();
   
   for (ScoreDoc doc : allFound.scoreDocs)
   {
      int docidx = doc.doc;
      Document docRetrieved = storedFields.document(docidx);
      if (docRetrieved != null)
      {
         IndexedDocumentListItem docToAdd = 
                createIndexedDocumentListItem(docidx, docRetrieved);
         retVal.add(docToAdd);
      }
   }
}
...

看到了吗?这并不太难。接下来讨论的主题是如何定位并删除文档。这也不太难。每个文档都有一个唯一的文档 ID。我所要做的就是将文档 ID 和字段名传递给正确的 Lucene API,它就会为您删除文档。在 DocumentIndexingController 这个控制器类中,您会找到处理文档删除请求的方法:

@RequestMapping(value="/deleteIndexedDocument", method=RequestMethod.POST, 
consumes = {"application/x-www-form-urlencoded"})
public String deleteIndexedDocument(
      @RequestParam
      Map<String, Object> paramMap,
      Model model)
{
   String documentId = (String)paramMap.get("documentId");
   if (StringUtils.hasText(documentId))
   {
      boolean opSuccess = _docIndexSvc.deleteIndexedDocument(documentId);
      if (opSuccess)
      {
         model.addAttribute("changeOpSuccess", true);
         model.addAttribute("changeOpOutcome", 
               "Document with index id has been deleted successfully.");
      }
      else
      {
         model.addAttribute("changeOpSuccess", false);               
         model.addAttribute("changeOpOutcome", "Unable to delete document with index id. 
         Please check the server log for more details.");               
      }
   }
   
   List<IndexedDocumentListItem> allDocs = _docIndexSvc.allDocuments();
   if (allDocs != null && !allDocs.isEmpty())
   {
      model.addAttribute("allDocuments", allDocs);
   }
   else
   {
      model.addAttribute("allDocuments", new ArrayList<IndexedDocumentListItem>());
   }
   
   return "listAll";
}

此方法首先将删除操作委托给服务对象的 deleteIndexedDocument 方法。然后它加载所有文档并在 listAll.html 页面上显示它们。

DocumentIndexerServiceImpl 类中执行删除操作的方法如下所示:

@Override
public boolean deleteIndexedDocument(String uniqueId)
{
   try
   {
      Directory dirOfIndexes =
            FSDirectory.open(Paths.get(indexDirectory));
      
      IndexWriter writer = new IndexWriter(dirOfIndexes, 
                           new IndexWriterConfig(new StandardAnalyzer()));
      writer.deleteDocuments(new Term("DOCID", uniqueId));
      writer.flush();
      writer.commit();
      writer.close();
      
      return true;
   }
   catch (Exception ex)
   {
      _logger.error(String.format("Exception occurred when attempting 
      to delete document with id [%s]. Exception message: [%s]", 
      uniqueId, ex.getMessage()), ex);
      return false;
   }
}

IndexWriter 类有 deleteDocuments() 这个方法。这个方法的工作方式是首先使用一些查询找到所有匹配的文档,然后将它们从 Lucene 索引存储库中删除。我必须传入一个 Term 对象。Term 对象是查询的一个单元。它有一个字段和一个字符串值,您希望与该字段匹配。要删除一个文档,我需要使用文档 ID 来唯一标识该文档。这就是为什么我向该方法传入一个 Term 对象,其中字段名设置为 DOCID,以及文档 ID(一个不带短划线的 UUID 值)。它的工作方式完全符合设计。这是我学到的新概念,如果我需要根据其唯一文档 ID 获取文档,我可以使用基于 Term 的查询并将其传递给需要调用的 Lucene API。

更新现有文档也很简单。当我进行研究时,在线资源并没有清楚地说明如何操作。在深入研究 Lucene 文档后,我意识到更新操作是一个两步操作。首先,它找到要更新的文档并将其删除。然后,它将更新后的文档插入到索引中。因此,更新操作本质上是一个删除后替换的操作。在 DocumentIndexingController 这个控制器类中,您会发现这个方法处理文档更新请求:

@RequestMapping(value="/updateDocument", method=RequestMethod.POST)
public String updateDocument(Model model,
      @ModelAttribute("documentModel")
      IndexableDocumentModel docToUpdate)
{
   if (docToUpdate != null)
   {
      String docId = docToUpdate.getId();
      System.out.println("Created date: " + docToUpdate.getCreatedDateInputValue());
      if (StringUtils.hasText(docId))
      {
         // Update the document.
         String retVal = updateDocument(docToUpdate, model);
         return retVal;
      }
      else
      {
         // treat this as a new document and add it to index.
         String retVal = addNewDocument(docToUpdate, model);
         return retVal;
      }
   }
   else
   {
      _logger.error("Unable to update existing document. 
      Document model data object is NULL or empty.");
      IndexableDocumentModel modelToAdd = new IndexableDocumentModel();
      modelToAdd.setSaveSuccess(false);
      modelToAdd.setErrorMessage("Unable to update existing document. 
      Document model data object is NULL or empty.");
      model.addAttribute("documentModel", modelToAdd);
      return "addNew";
   }
}

上述方法接收输入的文档数据模型,并首先获取文档 ID。如果文档 ID 不存在,则该文件将被视为新添加的文档。如果文档 ID 存在,则将执行现有文档的更新。该操作委托给同一类中的 updateDocument() 方法。该 updateDocument() 方法如下所示:

private String updateDocument(IndexableDocumentModel docToUpdate, Model model)
{
   boolean docValid = _docIndexSvc.validateDocumentModel(docToUpdate);
   if (!docValid)
   {
      model.addAttribute("documentModel", docToUpdate);
      return "editExisting";
   }
   docToUpdate.setUpdatedDate(new Date());
   
   boolean opSuccess = _docIndexSvc.updateIndexableDocument(docToUpdate);
   docToUpdate.setSaveSuccess(opSuccess);
   if (opSuccess)
   {
      docToUpdate.setErrorMessage("This document has been updated successfully.");
      model.addAttribute("documentModel", docToUpdate);
      return "editExisting";
   }
   else
   {
      docToUpdate.setErrorMessage("Unable to update this document. 
                                   Please see backend server log for more details.");
      model.addAttribute("documentModel", docToUpdate);
      return "editExisting";
   }
}

在此方法中,文档更新操作再次委托给另一个方法,该方法位于 DocumentIndexerServiceImpl 类型的服务对象中。新方法名为 updateIndexableDocument()。它看起来像这样:

@Override
public boolean updateIndexableDocument(IndexableDocumentModel inDocModel)
{
   boolean retVal = false;
   if (inDocModel == null)
   {
      _logger.error("Document to index is NULL. Unable to complete operation.");
      return retVal;
   }
   
   inDocModel.convertDateTextToDateObject();
   
   String docId = inDocModel.getId();
   if (StringUtils.hasText(docId))
   {
      Document updatedDocument = createIndexableDocument(inDocModel);
      IndexableField idField = new StringField("DOCID", docId, Field.Store.YES);
      updatedDocument.add(idField);
      
      IndexWriter writer = null;
      try
      {
         Directory indexWriteToDir =
               FSDirectory.open(Paths.get(indexDirectory));
         
         IndexWriterConfig iwc = new IndexWriterConfig(new StandardAnalyzer());
         iwc.setOpenMode(OpenMode.CREATE_OR_APPEND);
         iwc.setCodec(new SimpleTextCodec());
         writer = new IndexWriter(indexWriteToDir, iwc);
                     
         writer.updateDocument(new Term("DOCID", docId), updatedDocument);
         writer.flush();
         writer.commit();
         
         _logger.info(String.format
            ("Successfully update indexed document with id [%s].", docId));
         retVal = true;
      }
      catch(Exception ex)
      {
         _logger.error
            ("Exception occurred when updating indexed document of id [%s].", ex);
         retVal = false;
      }
      finally
      {
         if (writer != null)
         {
            try
            {
               writer.close();
            }
            catch(Exception ex)
            { }
         }
      }
      
      return retVal;
   }
   else
   {
      _logger.error("Unable to update document. 
                     The document ID is null or empty. 
                     Please add this as a new document to the index.");
      return false;
   }
}

这个方法可能有点令人困惑。让我从头开始解释。一旦方法获取到用于更新的输入文档,它将首先根据输入创建一个新的 Lucene Document。然后,我需要将相同的文档 ID 分配给这个新的 document 对象。代码如下:

...
Document updatedDocument = createIndexableDocument(inDocModel);
IndexableField idField = new StringField("DOCID", docId, Field.Store.YES);
updatedDocument.add(idField);
...

这段代码用于获取新文档并替换 Lucene 索引库中的旧文档。

...
Directory indexWriteToDir =
      FSDirectory.open(Paths.get(indexDirectory));

IndexWriterConfig iwc = new IndexWriterConfig(new StandardAnalyzer());
iwc.setOpenMode(OpenMode.CREATE_OR_APPEND);
iwc.setCodec(new SimpleTextCodec());
writer = new IndexWriter(indexWriteToDir, iwc);
            
writer.updateDocument(new Term("DOCID", docId), updatedDocument);
writer.flush();
writer.commit();
... 

执行文档删除和插入的行是这样的:

...            
writer.updateDocument(new Term("DOCID", docId), updatedDocument);
writer.flush();
writer.commit();
... 

如所示,更新也是通过传入一个 Term 对象来定位要替换的文档完成的。并且在方法中创建的新 document 对象将替换现有文档。

现在,我们已经看到了如何添加一个新 document,如何更新现有 document,以及如何定位和删除现有 document。我想展示的最后一个功能是如何查询文档。

用 Lucene 查询文档

在我之前的教程中,我解释了如何使用 Lucene 搜索功能进行查询。我没有说我实现的功能是原始的。我可以用一个词搜索并得到结果。当我输入多个词时,搜索就会崩溃。在过去的五年里,我一直在思考如何改进这项工作,以便输入字符串可以包含多个词而不是单个词。

我想到的解决方案很简单,我可以将 string 分解成一个单词列表。然后我为所有字段的每个单词生成查询。然后我使用逻辑运算符 “OR” 组合这些查询。

在控制器类 IndexController 中,您可以找到处理全文搜索的方法:

@RequestMapping(value="/searchFor", method=RequestMethod.POST)
public String searchFor(Model model,
      @ModelAttribute("searchInput")
      SearchForInputModel searchInput)
{
   if (searchInput != null)
   {
      String searchText = searchInput.getSearchText();
      if (StringUtils.hasText(searchText))
      {
         List<IndexedDocumentListItem> allMatchedDocs
            = _docIndexSvc.searchFor(searchText);
         if (allMatchedDocs != null && !allMatchedDocs.isEmpty())
         {
            model.addAttribute("allDocuments", allMatchedDocs);
            for (IndexedDocumentListItem itm : allMatchedDocs)
            {
               debugOutputIndexedDocument(itm);
            }
         }
         else
         {
            model.addAttribute("changeOpSuccess", false);
            model.addAttribute("changeOpOutcome", 
            String.format("No document found for the query \"%s\".", searchText));
            model.addAttribute("allDocuments", new ArrayList<IndexedDocumentListItem>());
         }
      }
      else
      {
         model.addAttribute("changeOpSuccess", false);
         model.addAttribute("changeOpOutcome", 
         "Please enter some text to search the indexed documents.");
         model.addAttribute("allDocuments", new ArrayList<IndexedDocumentListItem>());
      }
   }
   
   return "listAll";
}

像之前一样,这个全文搜索功能被委托给了服务对象。该方法名为 searchFor()。它位于 DocumentIndexerServiceImpl 类中。这个方法如下所示:

@Override
public List<IndexedDocumentListItem> searchFor(String searchText)
{
   List<IndexedDocumentListItem> retVal = new ArrayList<IndexedDocumentListItem>();
   if (StringUtils.hasText(searchText))
   {
      List<String> allSearchKeywords = splitSearchText(searchText);
      
      if (allSearchKeywords != null && !allSearchKeywords.isEmpty())
      {
         QueryBuilder bldr = new QueryBuilder(new StandardAnalyzer());
         BooleanQuery.Builder chainQryBldr = new BooleanQuery.Builder();
         
         for (String txtToSearch : allSearchKeywords)
         {
            Query q1 = bldr.createPhraseQuery("TITLE", txtToSearch);
            Query q2 = bldr.createPhraseQuery("KEYWORDS", txtToSearch);
            Query q3 = bldr.createPhraseQuery("CONTENT", txtToSearch);
            Query q4 = bldr.createPhraseQuery("DOCTYPE", txtToSearch);
            Query q5 = bldr.createPhraseQuery("AUTHOR", txtToSearch);
            Query q6 = bldr.createPhraseQuery("ABSTRACT", txtToSearch);

            chainQryBldr.add(q1, Occur.SHOULD);
            chainQryBldr.add(q2, Occur.SHOULD);
            chainQryBldr.add(q3, Occur.SHOULD);
            chainQryBldr.add(q4, Occur.SHOULD);
            chainQryBldr.add(q5, Occur.SHOULD);
            chainQryBldr.add(q6, Occur.SHOULD);
         }
         
         BooleanQuery finalQry = chainQryBldr.build();
         System.out.println("Final Query: " + finalQry.toString());
         
         try
         {
            Directory dirOfIndexes =
                  FSDirectory.open(Paths.get(indexDirectory));
            
            IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dirOfIndexes));
            
            TopDocs allFound = searcher.search(finalQry, 100);
            if (allFound.scoreDocs != null)
            {
               System.out.println("score doc not empty");
               StoredFields storedFields = searcher.storedFields();
               System.out.println(allFound.scoreDocs.length);
               for (ScoreDoc doc : allFound.scoreDocs)
               {
                  System.out.println("For each score doc");
                  System.out.println("Score: " + doc.score);
                  
                  int docidx = doc.doc;
                  Document docRetrieved = storedFields.document(docidx);
                  if (docRetrieved != null)
                  {
                     IndexedDocumentListItem docToAdd = 
                            createIndexedDocumentListItem(docidx, docRetrieved);
                     retVal.add(docToAdd);
                  }
               }
            }
         }
         catch (Exception ex)
         {
            _logger.error("Exception occurred when attempt to search for document.", ex);
            retVal = new ArrayList<IndexedDocumentListItem>();
         }
      }
   }
   
   return retVal;
}

此方法可分为两部分

正如我之前讨论的,我需要将输入值(一个英语单词的句子)分解成一个单词列表,然后每个单词在每个可搜索字段中进行搜索。这是它的实现方式:

List<String> allSearchKeywords = splitSearchText(searchText);

if (allSearchKeywords != null && !allSearchKeywords.isEmpty())
{
   QueryBuilder bldr = new QueryBuilder(new StandardAnalyzer());
   BooleanQuery.Builder chainQryBldr = new BooleanQuery.Builder();
   
   for (String txtToSearch : allSearchKeywords)
   {
      Query q1 = bldr.createPhraseQuery("TITLE", txtToSearch);
      Query q2 = bldr.createPhraseQuery("KEYWORDS", txtToSearch);
      Query q3 = bldr.createPhraseQuery("CONTENT", txtToSearch);
      Query q4 = bldr.createPhraseQuery("DOCTYPE", txtToSearch);
      Query q5 = bldr.createPhraseQuery("AUTHOR", txtToSearch);
      Query q6 = bldr.createPhraseQuery("ABSTRACT", txtToSearch);

      chainQryBldr.add(q1, Occur.SHOULD);
      chainQryBldr.add(q2, Occur.SHOULD);
      chainQryBldr.add(q3, Occur.SHOULD);
      chainQryBldr.add(q4, Occur.SHOULD);
      chainQryBldr.add(q5, Occur.SHOULD);
      chainQryBldr.add(q6, Occur.SHOULD);
   }
   
   BooleanQuery finalQry = chainQryBldr.build();
   System.out.println("Final Query: " + finalQry.toString());
...
}

上面代码片段中的最后一行显示了最终查询的样子。如果我有一个像这样的句子“Brown Fox Jumps Over Lazy Dog”,最终的 Lucene 查询将是这样的:

TITLE:brown KEYWORDS:brown CONTENT:brown DOCTYPE:brown AUTHOR:brown ABSTRACT:brown TITLE:fox KEYWORDS:fox CONTENT:fox DOCTYPE:fox AUTHOR:fox ABSTRACT:fox TITLE:jumps KEYWORDS:jumps CONTENT:jumps DOCTYPE:jumps AUTHOR:jumps ABSTRACT:jumps TITLE:over KEYWORDS:over CONTENT:over DOCTYPE:over AUTHOR:over ABSTRACT:over TITLE:lazy KEYWORDS:lazy CONTENT:lazy DOCTYPE:lazy AUTHOR:lazy ABSTRACT:lazy TITLE:dog KEYWORDS:dog CONTENT:dog DOCTYPE:dog AUTHOR:dog ABSTRACT:dog 

要搜索文档并转换结果,这是其实现方式:

try
{
   Directory dirOfIndexes =
         FSDirectory.open(Paths.get(indexDirectory));
   
   IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dirOfIndexes));
   
   TopDocs allFound = searcher.search(finalQry, 100);
   if (allFound.scoreDocs != null)
   {
      System.out.println("score doc not empty");
      StoredFields storedFields = searcher.storedFields();
      System.out.println(allFound.scoreDocs.length);
      for (ScoreDoc doc : allFound.scoreDocs)
      {
         System.out.println("For each score doc");
         System.out.println("Score: " + doc.score);
         
         int docidx = doc.doc;
         Document docRetrieved = storedFields.document(docidx);
         if (docRetrieved != null)
         {
            IndexedDocumentListItem docToAdd = 
                   createIndexedDocumentListItem(docidx, docRetrieved);
            retVal.add(docToAdd);
         }
      }
   }
}
catch (Exception ex)
{
   _logger.error("Exception occurred when attempt to search for document.", ex);
   retVal = new ArrayList<IndexedDocumentListItem>();
}

一旦结果被转换为可显示的文档列表,它们将被传递给 Thymeleaf 页面模板 listAll.html。如果您想知道它们是如何显示的,请查看该文件。

在下一个小节中,我将解释如何从 HTML 页面中提取数据并将其用于文档索引。这将是本教程的最后一部分。

索引 HTML 页面

想象一下我有一个有 50 多个甚至更多页面的网站。所有页面都加载了精彩内容。对于这个特定项目,不使用 SQL 数据库,所有页面都是静态页面。我需要将所有这些页面索引到索引存储库中。最方便的方法是获取页面,解析其中的字段数据,然后进行索引。所有这些都可以通过自动化完成。在这种情况下,我能提供次优的方案,我有页面的源代码。我将其提供给这个示例应用程序。应用程序将解析数据并进行索引。

解析页面源并不难。我已经在上面的小节中解释了索引是如何工作的。难点在于拥有正确的格式,以便 HTML 页面可以轻松解析以获取字段数据。只要我能控制网站和网页,为页面设置正确的格式一点也不难。以下是我为可解析网页设置的规则:

现在规则已经设定,我所需要的只是一个可以用来解析这些信息的东西。那就是 JSoup。这个 Java 库的行为与 JavaScript 几乎相同。例如,JavaScript 中有 getElementById()。这个 JSoup 库中也有一个。类似地,JavaScript 中的其他几个 DOM 解析函数也可以在 JSoup 中找到。它们的功能结构上是相同的。所以如果你足够了解 JavaScript,学习使用 JSoup 解析 HTML 页面会非常容易。

为了获取 HTML 页面的所有元标签,我使用了 JSoup API 方法 getElementsByTags()。一旦我获取了所有元标签,我就可以遍历它们并解析字段数据。您可以在文件 HtmlParserServiceImpl.java 中找到它是如何完成的。方法是 parseMetaFieldsForIndexing()

private void parseMetaFieldsForIndexing
        (Document htmlDoc, IndexableDocumentModel docToIndex)
{
   Elements metaElements = htmlDoc.getElementsByTag("meta");
   for (Element elem : metaElements)
   {
      String metaKey = elem.attr("name");
      if (!StringUtils.hasText(metaKey))
      {
         metaKey = elem.attr("property");
      }
      
      if (StringUtils.hasText(metaKey))
      {
         metaKey = metaKey.trim();
      }
      
      String metaVal = elem.attr("content");
      if (StringUtils.hasText(metaVal))
      {
         metaVal = metaVal.trim();
      }
      
      if (StringUtils.hasText(metaKey) && StringUtils.hasText(metaVal))
      {
         if (metaKey.equalsIgnoreCase("author"))
         {
            docToIndex.setAuthor(metaVal);
         }
         else if (metaKey.equalsIgnoreCase("description"))
         {
            docToIndex.setAbstractText(metaVal);
         }
         else if (metaKey.equalsIgnoreCase("keywords"))
         {
            docToIndex.setKeywords(metaVal);
         }
         else if (metaKey.equalsIgnoreCase("page_type"))
         {
            docToIndex.setType(metaVal);
         }
         else if (metaKey.equalsIgnoreCase("created_ts"))
         {
            if (StringUtils.hasText(metaVal))
            {
               try
               {
                  Date createdTs = 
                  org.hanbo.boot.rest.services.utils.DateUtils.convertTextToDate
                  (metaVal, false);
                  docToIndex.setCreatedDate(createdTs);
               }
               catch(Exception ex)
               {
                  _logger.error(String.format("Unable to parse date value for created_ts. 
                  Date value [%s]. 
                  Setting the current dare as created_ts.", metaVal), ex);
                  docToIndex.setCreatedDate(new Date());
               }
            }
         }
      }
   }
}

上面的代码片段没有解析 HTML 页面的标题。标题通常在 <title/> 标签中,位于 HTML 页面的 <head/> 部分。以下是我如何提取它:

private void parseHtmlForTitle(Document htmlDoc, IndexableDocumentModel docToIndex)
{
   Elements titleElems= htmlDoc.getElementsByTag("title");
   if (titleElems.size() >= 1)
   {
      Element titleElem = titleElems.first();
      if (titleElem != null)
      {
         String titleVal =  titleElem.text();
         if (StringUtils.hasText(titleVal))
         {
            titleVal = titleVal.trim();
            if (StringUtils.hasText(titleVal))
            {
               System.out.println("Title: " + titleVal);
               docToIndex.setTitle(titleVal);
            }
         }
      }
   }
}

解析这些元字段很容易。困难的部分是从 HTML 页面的正文中提取所有内容。代码如下所示:

private void parseHtmlForBodyContent(Document htmlDoc, IndexableDocumentModel docToIndex)
{
   Elements allElems = htmlDoc.body().select("*");
   StringBuilder sb = new StringBuilder();
   for (Element elem : allElems)
   {
      String tagName = elem.tagName();
      tagName = tagName.trim();
      if (StringUtils.hasText(tagName))
      {
         if (tagName.equalsIgnoreCase("h1")
               || tagName.equalsIgnoreCase("h2")
               || tagName.equalsIgnoreCase("h3")
               || tagName.equalsIgnoreCase("h4")
               || tagName.equalsIgnoreCase("h5")
               || tagName.equalsIgnoreCase("h6")
               || tagName.equalsIgnoreCase("a")
               || tagName.equalsIgnoreCase("p")
               || tagName.equalsIgnoreCase("span")
               || tagName.equalsIgnoreCase("i")
               || tagName.equalsIgnoreCase("b")
               || tagName.equalsIgnoreCase("u")
               || tagName.equalsIgnoreCase("li")
               || tagName.equalsIgnoreCase("th")
               || tagName.equalsIgnoreCase("td"))
         {
            String innerText = elem.text();
            if (StringUtils.hasText(innerText))
            {
               sb.append(innerText);
               sb.append(" ");
            }
         }
      }
   }
   
   String contentToIndex = sb.toString();
   if (StringUtils.hasText(contentToIndex))
   {
      System.out.println("Content: [[[" + contentToIndex + "]]]");
      docToIndex.setContent(contentToIndex);
   }
}

如您所见,该方法并不复杂。它查找 <body/> 节点下的所有子节点。然后我们只需在 for 循环中处理这些节点。如果它找到预期的标签,它将提取内部文本。然后文本将附加到 StringBuilder 对象。附加后,我必须在 string 中添加一个空格,这样我们就不会意外地将两个单词合并为一个。

在同一个类中,您会看到这个方法解析整个 HTML 页面。它使用了我上面描述的所有方法:

@Override
public IndexableDocumentModel parseHtmlForIndexableDocument
                              (String contentUrl, String htmlDocStr)
{
   IndexableDocumentModel retVal = null;
   if (StringUtils.hasText(htmlDocStr))
   {
      retVal = new IndexableDocumentModel();
      
      Document htmlDoc = Jsoup.parse(htmlDocStr, "", Parser.xmlParser());
      parseMetaFieldsForIndexing(htmlDoc, retVal);
      parseHtmlForTitle(htmlDoc, retVal);
      parseHtmlForBodyContent(htmlDoc, retVal);
      
      String docId = IdUtils.generateDocumentId();
      retVal.setId(docId);
      retVal.setContentUrl(contentUrl);
      retVal.setUpdatedDate(new Date());
   }
   
   return retVal;
}

这是调用上述方法来解析 HTML 页面并对其进行索引的地方。您可以在 DocumentIndexingController 类中找到它:

@RequestMapping(value="/addNewHtmlDocument", method=RequestMethod.POST)
public String addNewHtmlDocument(Model model,
      @ModelAttribute("documentModel")
      HtmlDocumentInputModel docToAdd)
{
   if (docToAdd != null)
   {
      String htmlDocToIndex = docToAdd.getHtmlDocContent();
      if (StringUtils.hasText(htmlDocToIndex))
      {
         boolean opSuccess = _docIndexSvc.addHtmlDocumentToIndexRepository
                             (docToAdd.getHtmlContentUrl(), htmlDocToIndex);
         if (opSuccess)
         {
            HtmlDocumentInputModel modelToAdd = new HtmlDocumentInputModel();
            modelToAdd.setSaveSuccess(true);
            modelToAdd.setErrorMessage("The HTML document has been saved successfully.");
            model.addAttribute("documentModel", modelToAdd);
         }
         else
         {
            docToAdd.setSaveSuccess(false);
            docToAdd.setErrorMessage("Unable to save the HTML document 
            you entered to document repository.
            Please see the backend server log for more details.");
            model.addAttribute("documentModel", docToAdd);
         }
      }
      else
      {
         docToAdd.setSaveSuccess(false);
         docToAdd.setErrorMessage("Please enter your HTML document to be indexed.");
         model.addAttribute("documentModel", docToAdd);
      }
   }
   else
   {
      HtmlDocumentInputModel modelToAdd = new HtmlDocumentInputModel();
      modelToAdd.setSaveSuccess(false);
      modelToAdd.setErrorMessage("Invalid input data model. 
      Please enter the HTML document before clicking the button \"Save\".");
      model.addAttribute("documentModel", modelToAdd);
   }
      
   return "addNewHtmlDoc";
}

这就是关于如何解析 HTML 页面以进行文档索引的全部内容。我想指出几点。

总之,就是这样。所有的技术细节都已解释。在下一节中,我将讨论如何使用这个 Web 应用程序来创建您自己的索引库。

如何使用此应用程序

下载源代码后,在构建示例应用程序之前,您必须首先更改 Lucene 索引存储库所在的目录。这可以在示例应用程序的配置文件 application.properties 中找到。您可以在 src 文件夹下的 resources 目录中找到此文件。

该文件看起来像这样:

spring.servlet.multipart.max-file-size=400MB
spring.servlet.multipart.max-request-size=2GB

indexer.dataFolder=/home/hanbosun/Projects/DevJunk/hangofishing/index_data

前两行未被此应用程序使用。它们有益无害。第三行是您需要修改的。该值是针对我的 PC 的。您需要将其更改为针对您的文件系统。

更改路径并保存此配置文件后,是时候构建它了。在基本目录运行以下命令:

mvn clean install 

构建成功后,您可以运行以下命令:

java -jar target/hanbo-spring-se-indexer-1.0.1.jar

当应用程序成功运行后,您将以下 URL 复制并粘贴到浏览器中,然后按 Enter 键:

https://:8080/

如果您一切操作正确,当您导航到上述 URL 时,此示例应用程序的索引页面将显示。它看起来像这样:

还有三个页面:

  • 列出所有:此链接将显示所有已索引文档的页面。
  • 添加新索引:此链接将显示一个页面,用户可以在其中输入文档信息并将其保存为已索引文档。
  • 添加新 HTML 页面:此链接将显示一个页面,用户可以在其中输入 HTML 文档的内容并将其保存为索引文档。

在“列出所有”页面上,每一行都显示两个链接,一个用于编辑索引文档,一个用于删除此索引。

要在索引存储库中创建一些文档,您可以使用“添加新索引”页面,只需填写所有字段并单击“保存”按钮。文档保存后,它将出现在“列出所有”页面中。

添加新索引”页面的屏幕截图

添加新索引”页面的屏幕截图,您可以在其中保存新索引

这是显示所有索引文档的“列出所有”页面:

如您所见,列表中的每条记录都有两个按钮:一个用于从存储库中删除记录,一个用于编辑记录。当点击“删除”按钮时,它将无任何警告地删除记录。当您点击任何记录的“编辑”按钮时,它将显示一个如下所示的页面:

您可以修改页面中的任何内容。然后您可以向下滚动并单击“保存”以保存更改。修改后的文档将替换索引存储库中未修改的文档。

让我们看一下您可以输入 HTML 文档并进行索引的页面。它是“添加新 HTML 页面”。当您到达该页面时,它最初看起来像这样:

再次强调,只有格式良好的 HTML 文档才能使用此页面正确索引。此示例应用程序无法处理任何 HTML 源代码并期望检索用于文档索引的所需字段数据。它只能期望 HTML 源代码中的某些特定位置包含字段数据。并且它从 HTML 源代码中的这些位置检索数据。请仔细查看“索引 HTML 页面”部分以获取更多详细信息。

摘要

至此,我的教程就告一段落了。这对我来说是一个有趣的项目。它小巧且有用。我花了很多时间才完成它。本教程提供了一个应用程序,允许用户管理一个小型 Lucene 索引存储库。它能够列出所有已索引的文档,允许用户检索已索引文档的数据,并更新文档,然后保存。它还提供了从索引存储库中删除现有文档的功能。此外,您可以向索引存储库添加新文档,并且如果 HTML 页面源在正确的位置包含所有可索引的字段数据,您还可以对其进行索引。

与我之前的 Lucene 教程不同,本教程解释了如何列出所有文档,以及如何检索和保存对现有文档的更改。它还提供了一种更好的全文搜索方法,允许用户输入多个词进行全文搜索。如果您的搜索文本中包含“the”、“a”、“this”、“that”等常用词,这种方法并不完美,应用程序将匹配存储库中几乎所有文档。我没有使用搜索结果中的排名分数来优化搜索结果。这可能是一种获得更优化结果的解决方案。或者,我可以删除这些高频搜索关键词,只匹配重要的词。我可以通过各种方式优化全文搜索功能。

在本教程中,我还介绍了一种解析 HTML 页面并将解析后的数据作为可搜索文档进行索引的方法。我在本教程中讨论的是一个使用 JSoup 的简单 HTML 解析器。它只能从 HTML 源代码中的特定位置解析数据。经过大量的努力,您可以扩展此解析器以解析来自任何 HTML 页面的信息。那将是另一个故事。无论如何,我希望本教程对您有用。祝你好运!

历史

  • 2024年3月8日 - 初稿
© . All rights reserved.