Наш поиск работает вроде бы неплохо, но выглядит как-то не очень аппетитно. Для улучшения визуального восприятия слово или фразу, по которой документ был найден, желательно выделить - например покрасить в другой цвет. Кроме того, текущий способ отображения результатов очень примитивен - не факт, что в начале текста вообще встречается искомая фраза - необходимо показать пользователю именно тот фрагмент текста, в котором Lucene её нашёл, тогда точно будет что подсвечивать. Подобный функционал в Lucene уже реализован в виде отдельного модуля Highlighter
, причём скорость его работы зависит от наличия в индексе Term Vectors (задаётся на этапе создания индекса):
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>tutorial.lucene</groupId>
<artifactId>parent</artifactId>
<packaging>pom</packaging>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>6.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>6.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>6.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-highlighter</artifactId>
<version>6.0.0</version>
</dependency>
</dependencies>
<modules>
<module>server</module>
<module>crawler</module>
<module>common</module>
</modules>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
server/src/main/java/server/HighlightedSearchItem.java
package server;
import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.CorruptIndexException;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.highlight.Highlighter;
import org.apache.lucene.search.highlight.InvalidTokenOffsetsException;
import org.apache.lucene.search.highlight.QueryScorer;
import org.apache.lucene.search.highlight.SimpleHTMLFormatter;
import org.apache.lucene.search.highlight.SimpleSpanFragmenter;
import common.LuceneBinding;
public class HighlightedSearchItem extends DefaultSearchItem {
public final String highlightedTitle, highlightedContent;
HighlightedSearchItem(final String title, final String content, final String uri,
final float score, final String highlightedTitle, final String highlightedContent) {
super(title, content, uri, score);
this.highlightedContent = highlightedContent;
this.highlightedTitle = highlightedTitle;
}
}
class HighlightedAgregator extends Aggregator<HighlightedSearchItem> {
private Highlighter highlighter;
private final String tryHighlight(final String text, final String[] fields)
throws IOException, InvalidTokenOffsetsException {
if (text == null) {
return null;
}
if (this.highlighter == null) {
final QueryScorer scorer = new QueryScorer(this.query);
this.highlighter = new Highlighter(
new SimpleHTMLFormatter("[mazko.github.io]", "[/mazko.github.io]"),
scorer);
this.highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer, 330));
}
for (final String field : fields) {
final String highlighted = this.highlighter.getBestFragment(
LuceneBinding.getAnalyzer(), field, text);
if (highlighted != null) {
return highlighted;
}
}
return text;
}
@Override
HighlightedSearchItem aggregate(final ScoreDoc sd)
throws IOException, CorruptIndexException, InvalidTokenOffsetsException {
final Document doc = this.indexSearcher.doc(sd.doc);
final String title = doc.get(LuceneBinding.TITLE_FIELD);
final String content = doc.get(LuceneBinding.CONTENT_FIELD);
final String highlightedTitle = this.tryHighlight(title,
new String[] {
LuceneBinding.RUS_TITLE_FIELD,
LuceneBinding.ENG_TITLE_FIELD,
LuceneBinding.TITLE_FIELD });
final String highlightedContent = this.tryHighlight(content,
new String[] {
LuceneBinding.RUS_CONTENT_FIELD,
LuceneBinding.ENG_CONTENT_FIELD,
LuceneBinding.CONTENT_FIELD });
return new HighlightedSearchItem(title, content,
doc.get(LuceneBinding.URI_FIELD), sd.score, highlightedTitle, highlightedContent);
}
}
Используемый ранее класс DefaultSearchItem
расширен полями, в каждом из которых будет храниться фрагмент текста, а подсвечиваемая фраза обернута в наборы символов [mazko.github.io] и [/mazko.github.io] - при необходимости их можно в любое время заменить, например, с помощью нежадных (non-greedy) регулярных выражений. Теперь необходимо сообщить об изменениях классу LuceneSearcher
. Модель данных, передаваемая jsp-представлению, также претерпит небольших изменений: TakeResult<DefaultSearchItem>
=> TakeResult<HighlightedSearchItem>
:
server/src/main/java/server/SearchServlet.java
package server;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import common.LuceneBinding;
public class SearchServlet extends HttpServlet {
public final static String QUERY_INPUT = "query";
public final static String RESULTS_PER_PAGE = "resperpage";
public final static String CURRENT_PAGE = "currentpage";
@Override
public void doGet(final HttpServletRequest req, final HttpServletResponse res)
throws ServletException, IOException {
final String query = req.getParameter(SearchServlet.QUERY_INPUT);
final String itemsPerPage = req.getParameter(SearchServlet.RESULTS_PER_PAGE);
final String currentPage = req.getParameter(SearchServlet.CURRENT_PAGE);
if (query != null && !query.isEmpty()) {
int currentPageInt = 1, itemsPerPageInt = 10;
try {
currentPageInt = Integer.parseInt(currentPage);
} catch (final NumberFormatException e) {
}
try {
itemsPerPageInt = Integer.parseInt(itemsPerPage);
} catch (final NumberFormatException e) {
}
try {
req.setAttribute("searchmodel",
new LuceneSearcher<HighlightedSearchItem, HighlightedAgregator>(
HighlightedAgregator.class,
LuceneBinding.INDEX_PATH, query.trim())
.Take(itemsPerPageInt, (currentPageInt - 1) * itemsPerPageInt));
} catch (final Exception e) {
throw new ServletException(e);
}
}
this.getServletContext().getRequestDispatcher("/index.jsp").forward(req, res);
}
}
server/src/main/webapp/index.jsp
<jsp:directive.page language="java"
contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8" />
<jsp:directive.page import="server.TakeResult" />
<jsp:directive.page import="server.SearchServlet" />
<jsp:directive.page import="server.HighlightedSearchItem" />
<%!
public String escapeHTML(String s) {
if (s == null) return null;
s = s.replaceAll("&", "&");
s = s.replaceAll("<", "<");
s = s.replaceAll(">", ">");
s = s.replaceAll("\"", """);
s = s.replaceAll("'", "'");
return s;
}
public String highlight(String s) {
if (s == null) return null;
return s.replaceAll(
"\\[mazko\\.github\\.io\\](.*?)\\[/mazko\\.github\\.io\\]",
"<b style=\"color:red;\">$1</b>");
}
%>
<%
final TakeResult<HighlightedSearchItem> model =
(TakeResult<HighlightedSearchItem>)request
.getAttribute("searchmodel");
final String qDefValue = escapeHTML(request
.getParameter(SearchServlet.QUERY_INPUT));
final int rPerPage = request.getParameter(
SearchServlet.RESULTS_PER_PAGE) == null ? 5 :
Integer.parseInt(request
.getParameter(SearchServlet.RESULTS_PER_PAGE));
final int cPage = request.getParameter(
SearchServlet.CURRENT_PAGE) == null ? 1 :
Integer.parseInt(request
.getParameter(SearchServlet.CURRENT_PAGE));
%>
<html>
<head>
<title>Lucene Highlighter Example</title>
<meta http-equiv="Content-Type"
content="text/html; charset=UTF-8">
</head>
<body>
<form name="search" action="/search" accept-charset="UTF-8">
<p align="center">
<input name="<%= SearchServlet.QUERY_INPUT %>"
<% if(qDefValue != null) { %>
value="<%= qDefValue %>"
<% } %>
size="55" style="text-align:center;"/>
</p>
<p align="center">
<input name="<%= SearchServlet.RESULTS_PER_PAGE %>"
size="5" value="<%= rPerPage %>"
style="text-align:center;" />
Results Per Page
<input type="submit" value="Search!"/>
</p>
</form>
<% if(model != null && model.getItems() != null) { %>
<p style="float:right;">
Page <b><%= cPage %></b> from <b>
<%= (int)Math.ceil(
((float)model.totalHits) / rPerPage)
%></b>
</p>
<% } %>
<% if(model != null && model.getItems() != null) { %>
<% if(cPage > 1) { %>
<form name="search" action="/search"
accept-charset="UTF-8"
style="float:left;">
<% if(qDefValue != null) { %>
<input type="hidden"
name="<%= SearchServlet.QUERY_INPUT %>"
value="<%= qDefValue %>" />
<% } %>
<input type="hidden"
name="<%= SearchServlet.RESULTS_PER_PAGE %>"
value="<%= rPerPage %>" />
<input type="hidden"
name= "<%= SearchServlet.CURRENT_PAGE %>"
value="<%= cPage - 1 %>"/>
<input type="submit"
value="<%= escapeHTML("<") %>"/>
</form>
<% } %>
<% if(model.totalHits > cPage * rPerPage) { %>
<form name="search" action="/search"
accept-charset="UTF-8" >
<% if(qDefValue != null) { %>
<input type="hidden"
name="<%= SearchServlet.QUERY_INPUT %>"
value="<%= qDefValue %>" />
<% } %>
<input type="hidden"
name="<%= SearchServlet.RESULTS_PER_PAGE %>"
value="<%= rPerPage %>" />
<input type="hidden"
name= "<%= SearchServlet.CURRENT_PAGE %>"
value="<%= cPage + 1 %>"/>
<input type="submit"
value="<%= escapeHTML(">") %>"/>
</form>
<% } %>
<% } %>
<p align="center" style="clear:both;">
<% if(model != null) { %>
<% if(model.getItems() != null) { %>
<p>
<% for(HighlightedSearchItem item :
model.getItems()) { %>
<hr/>
<p><b>Score: </b><%= item.score %></p>
<p><b>Url: </b><a href="<%= item.uri %>">
<%= item.uri %></a>
</p>
<p><b>Title: </b>
<%= highlight(
escapeHTML(
item.highlightedTitle)) %>
</p>
<p><b>Content: </b>
<%= highlight(
escapeHTML(
item.highlightedContent)) %>
</p>
<% } %>
</p>
<% } else { %>
I'm sorry I couldn't find what you were looking for.
<% } %>
<% } %>
</p>
<p align="center">
<a href="http://mazko.github.io/">http://mazko.github.io/</a>
</p>
</body>
</html>
Теперь результаты поиска выглядят повеселей:
~$ mvn clean install -pl server/ jetty:run
Далее рассмотрим синтаксис поисковых запросов в Lucene.