Используя подсказки в процессе ввода поискового запроса можно быстрее искать нужную информацию. Поисковые подсказки - это схожие с вашим запросом термины. Они появляются на экране по мере того, как вы вводите в строку поиска запрос. Для данного процесса критически важна скорость реакции на ввод пользователя, поэтому на серверной части ускорить процесс поможет специально заточенный индекс / модель данных, а на стороне клиента неплохо бы использовать AJAX дабы не перезагружать каждый раз HTML-страницу целиком при изменении поискового запроса. На картинке показан результат работы алгоритма AnalyzingSuggester, который можно запустить скачав исходники в конце текущего материала.
Первым делом нужно добавить в мульти-модульный maven проект новую зависимость lucene-suggest
:
src/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>
<properties>
<lucene.version>6.0.0</lucene.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-facet</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-suggest</artifactId>
<version>${lucene.version}</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>
С помощью методов store
/ load
в классе AnalyzingSuggester можно сохранить / восстанавливать модель данных автодополнения в файл для последующего быстрого старта. Поскольку эта логика используется как при индексации так и при поиске, она вынесена в их общий модуль LuceneBinding
. С помощью метода weight()
задаётся приоритет для фраз автодополнения - в данном случае наиболее короткие фразы будут первыми в списке:
src/common/src/main/java/common/LuceneBinding.java
package common;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.facet.FacetsConfig;
import org.apache.lucene.search.spell.Dictionary;
import org.apache.lucene.search.suggest.InputIterator;
import org.apache.lucene.search.suggest.analyzing.AnalyzingSuggester;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.BytesRef;
/* This class is used both by Crawler and SearchServlet */
public final class LuceneBinding {
private static final Path ROOT = Paths.get(System.getProperty("user.home"), "lucene-tutorial-index");
public static final Path SEARCH_INDEX_PATH = LuceneBinding.ROOT.resolve("search");
public static final Path TAXO_INDEX_PATH = LuceneBinding.ROOT.resolve("taxo");
public static final Path SUGGEST_INDEX_PATH = LuceneBinding.ROOT.resolve("suggest");
public static final String FIELD_ID = "id";
public static final String FIELD_TITLE = "title";
public static final String FIELD_CONTENT = "content";
public static final String FIELD_CATEGORY = "category";
public static final String FIELD_DIRECTOR = "director";
public static final String FIELD_RATE = "rate";
public static final String FACET_DIRECTOR = "Director";
public static final String FACET_DATE = "Release Date";
public static final String FACET_CATEGORY = "Category";
public static final int SUGGEST_MAX_SHINGLES = 5;
public static FacetsConfig getFacetsConfig() {
final FacetsConfig config = new FacetsConfig();
config.setHierarchical(LuceneBinding.FACET_DATE, true);
config.setMultiValued(LuceneBinding.FACET_CATEGORY, true);
return config;
}
public static Analyzer getAnalyzer() {
return new StandardAnalyzer();
}
public static final class Suggester extends AnalyzingSuggester {
private static final Path ser = LuceneBinding.SUGGEST_INDEX_PATH.resolve("serialized");
private Suggester() throws IOException {
super(FSDirectory.open(LuceneBinding.SUGGEST_INDEX_PATH.resolve("tmp")),
"", LuceneBinding.getAnalyzer());
}
public static void store(final Dictionary input) throws IOException {
final Suggester suggester = new Suggester();
suggester.build(new InputIterator() {
final InputIterator i = input.getEntryIterator();
private BytesRef c;
final int MAX_TOKEN_LENGTH = LuceneBinding.SUGGEST_MAX_SHINGLES
* (1 + StandardAnalyzer.DEFAULT_MAX_TOKEN_LENGTH);
@Override
public BytesRef next() throws IOException {
this.c = this.i.next();
return this.c;
}
@Override
public long weight() {
// short phrases more important
return this.MAX_TOKEN_LENGTH - this.c.length;
}
@Override
public BytesRef payload() {
return this.i.payload();
}
@Override
public boolean hasPayloads() {
return this.i.hasPayloads();
}
@Override
public Set<BytesRef> contexts() {
return this.i.contexts();
}
@Override
public boolean hasContexts() {
return this.i.hasContexts();
}
});
try (final OutputStream os = Files.newOutputStream(Suggester.ser)) {
suggester.store(os);
}
}
public static Suggester load() throws IOException {
final Suggester suggester = new Suggester();
try (final InputStream is = Files.newInputStream(Suggester.ser)) {
suggester.load(is);
}
return suggester;
}
}
}
Индекс для автодополнения будет строится по двум полям suggestIndexer.add(title, description)
. Комбинации слов достигаются с помощью ShingleAnalyzerWrapper:
src/crawler/src/main/java/crawler/SugestIndexer.java
package crawler;
import java.io.Closeable;
import java.io.IOException;
import java.nio.file.Path;
import org.apache.lucene.analysis.shingle.ShingleAnalyzerWrapper;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.search.spell.LuceneDictionary;
import org.apache.lucene.store.FSDirectory;
import common.LuceneBinding;
import common.LuceneBinding.Suggester;
public class SugestIndexer implements Closeable {
private final IndexWriter writer;
public static final String FIELD_SUGGEST = "SUGGEST";
private static final Path SHINGLES_INDEX_PATH = LuceneBinding.SUGGEST_INDEX_PATH.resolve("shingles");
public SugestIndexer() throws IOException {
final IndexWriterConfig iwConfig = new IndexWriterConfig(
new ShingleAnalyzerWrapper(LuceneBinding.getAnalyzer(), LuceneBinding.SUGGEST_MAX_SHINGLES));
iwConfig.setOpenMode(OpenMode.CREATE);
this.writer = new IndexWriter(FSDirectory.open(SugestIndexer.SHINGLES_INDEX_PATH), iwConfig);
}
public void add(final String... items) throws IOException {
final Document doc = new Document();
for (final String item : items) {
doc.add(new TextField(SugestIndexer.FIELD_SUGGEST, item, Store.NO));
}
this.writer.addDocument(doc);
}
@Override
public void close() throws IOException {
this.writer.close();
try (final IndexReader reader = DirectoryReader.open(this.writer.getDirectory())) {
Suggester.store(new LuceneDictionary(reader, SugestIndexer.FIELD_SUGGEST));
}
// TODO: we can remove SHINGLES_INDEX_PATH directory now
}
}
Сервлет отдаёт все варианты автодополнений в JSON-формате http://localhost:8080/api/suggest?q=космос
:
src/server/src/main/java/server/SuggestServlet.java
package server;
import java.io.IOException;
import java.util.AbstractMap.SimpleEntry;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.json.JSONArray;
import common.LuceneBinding.Suggester;
public class SuggestServlet extends HttpServlet {
private static volatile Suggester suggester;
private static int MAX_SUGGESTS = 22;
@Override
public void doGet(final HttpServletRequest req, final HttpServletResponse res)
throws ServletException, IOException {
if (SuggestServlet.suggester == null) {
synchronized (SuggestServlet.class) {
if (SuggestServlet.suggester == null) {
SuggestServlet.suggester = Suggester.load();
}
}
}
final String query = req.getParameter("q");
final Collection<?> lookupResultList = SuggestServlet.suggester
.lookup(query, false, SuggestServlet.MAX_SUGGESTS).stream()
.sorted((e1, e2) -> Long.compare(e2.value, e1.value))
.map(v -> Stream
.of(new SimpleEntry<>("key", v.key), new SimpleEntry<>("hi", v.highlightKey),
new SimpleEntry<>("val", v.value))
.collect(HashMap::new, (m, e) -> m.put(e.getKey(), e.getValue()), Map::putAll))
.collect(Collectors.toCollection(ArrayList::new));
final JSONArray json = new JSONArray(lookupResultList);
res.setContentType("application/json");
res.setCharacterEncoding("UTF-8");
res.getWriter().write(json.toString());
}
}
Сборка и запуск:
~$ cd src
~$ mvn clean install
~$ mvn -pl crawler/ exec:java -Dexec.mainClass="crawler.App"
~$ mvn -pl server/ jetty:run