搜索引擎进行信息检索的优化策略方法(Java中Lucene执行索引、查询等工作原理及解决办法 )
优采云 发布时间: 2022-04-15 18:21搜索引擎进行信息检索的优化策略方法(Java中Lucene执行索引、查询等工作原理及解决办法
)
一、Lucene 简介1.1 什么是 Lucene?1.2 Lucene使用场景
适用于需要少量数据索引的场景。当索引量过大时,需要使用ES、Solr等全文搜索服务器来实现搜索功能。
1.3 你能从这篇文章中学到什么?
本文旨在分享Lucene搜索引擎源码阅读和功能开发的心得体会。Lucene 采用 7.3.1 版本。
二、Lucene 基本工作流程
索引的生成分为两部分:
1. 创建阶段:
2. 搜索阶段:
索引创建和搜索过程如下图所示:
三、Lucene索引构成3.1个前向索引
Lucene 的基本层次结构由五个部分组成:索引、段、文档、域和单词。前向索引的生成是基于Lucene的基本层次结构逐级处理文档,分解领域存储词的过程。
索引文件的层次关系如图1所示:
3.2 倒排索引
Lucene全文索引的核心是一种基于倒排索引的快速索引机制。
倒排索引的原理如图2所示。倒排索引就是简单的基于分析器对文本内容进行分词,记录每个词出现在哪个文章中,从而通过搜索词进行查询用户输入 文章 收录该单词。
**问题:** 使用上述倒排索引时,每次都需要将索引词加载到内存中。到达内存后,内存损失很大。
解决方案:从Lucene4开始,Lucene使用FST来减少索引词造成的空间消耗。
FST(Finite StateTransducers),中文名有限状态机转换器。其主要特点在于以下四点:
具体存储方式如图3所示:
倒排索引相关文件包括三个文件:.tip、.tim和.doc,其中:
3.3 索引查询和文档搜索过程
Lucene 使用倒排索引来定位需要查询的文档号。通过文档编号搜索文档后,使用词重等信息对文档进行排序并返回。
文件格式如图4所示:
以上主要讲解了Lucene的工作原理,下面将介绍Lucene在Java中的相关代码,进行索引、查询等操作。
四、Lucene的增删改操作
Lucene项目中文本的解析、存储等操作都是由IndexWriter类实现的。IndexWriter 文件主要由 Directory 和 IndexWriterConfig 两个类组成。其中:
目录:用于指定存放索引文件的目录类型。既然需要搜索文本内容,自然是先将文本内容和索引信息写入目录。目录是一个抽象类,它允许索引存储的许多不同实现。常见的存储方式一般有本地存储(FSDirectory)、内存(RAMDirectory)等。
IndexWriterConfig:用于在写入文件内容时指定IndexWriter的相关配置,包括OpenMode索引构建方式、相似度相关算法等。
IndexWriter 究竟是如何对索引进行操作的?下面简单分析下IndexWriter索引操作的相关源码。
4.1. 文档补充
一种。Lucene 会为每个文档创建一个 ThreadState 对象,该对象持有 DocumentWriterPerThread 来执行文件的增删改查操作;
ThreadState getAndLock(Thread requestingThread, DocumentsWriter documentsWriter) {
ThreadState threadState = null;
synchronized (this) {
if (freeList.isEmpty()) {
// 如果不存在已创建的空闲ThreadState,则新创建一个
return newThreadState();
} else {
// freeList后进先出,仅使用有限的ThreadState操作索引
threadState = freeList.remove(freeList.size()-1);
// 优先使用已经初始化过DocumentWriterPerThread的ThreadState,并将其与当前
// ThreadState换位,将其移到队尾优先使用
if (threadState.dwpt == null) {
for(int i=0;i IndexWriter.MAX_STORED_STRING_LENGTH) {
throw new IllegalArgumentException("stored field \"" + field.name() + "\" is too large (" + value.length() + " characters) to store");
}
try {
storedFieldsConsumer.writeField(fp.fieldInfo, field);
} catch (Throwable th) {
throw AbortingException.wrap(th);
}
}
}
// 建立DocValue(通过文档查询文档下包含了哪些词)
DocValuesType dvType = fieldType.docValuesType();
if (dvType == null) {
throw new NullPointerException("docValuesType must not be null (field: \"" + fieldName + "\")");
}
if (dvType != DocValuesType.NONE) {
if (fp == null) {
fp = getOrAddField(fieldName, fieldType, false);
}
indexDocValue(fp, dvType, field);
}
if (fieldType.pointDimensionCount() != 0) {
if (fp == null) {
fp = getOrAddField(fieldName, fieldType, false);
}
indexPoint(fp, field);
}
C。要分析Field,首先需要构造一个TokenStream类,用于生成和转换token流。TokenStream 有两个重要的派生类,Tokenizer 和 TokenFilter,其中 Tokenizer 用于通过 java.io.Reader 类读取字符,生成 Token 流,然后通过任意数量的 TokenFilter 处理这些输入的 Token 流。具体源码如下:
// invert:对Field进行分词处理首先需要将Field转化为TokenStream
try (TokenStream stream = tokenStream = field.tokenStream(docState.analyzer, tokenStream))
// TokenStream在不同分词器下实现不同,根据不同分词器返回相应的TokenStream
if (tokenStream != null) {
return tokenStream;
} else if (readerValue() != null) {
return analyzer.tokenStream(name(), readerValue());
} else if (stringValue() != null) {
return analyzer.tokenStream(name(), stringValue());
}
public final TokenStream tokenStream(final String fieldName, final Reader reader) {
// 通过复用策略,如果TokenStreamComponents中已经存在Component则复用。
TokenStreamComponents components = reuseStrategy.getReusableComponents(this, fieldName);
final Reader r = initReader(fieldName, reader);
// 如果Component不存在,则根据分词器创建对应的Components。
if (components == null) {
components = createComponents(fieldName);
reuseStrategy.setReusableComponents(this, fieldName, components);
}
// 将java.io.Reader输入流传入Component中。
components.setReader(r);
return components.getTokenStream();
}
d。根据IndexWriterConfig中配置的分词器,通过策略模式返回分词器对应的分词器组件。针对不同的语言和不同的分词需求,分词组件有很多不同的实现方式。
以 StandardAnalyzer 为例:
// 标准分词器创建Component过程,涵盖了标准分词处理器、Term转化小写、常用词过滤三个功能
protected TokenStreamComponents createComponents(final String fieldName) {
final StandardTokenizer src = new StandardTokenizer();
src.setMaxTokenLength(maxTokenLength);
TokenStream tok = new StandardFilter(src);
tok = new LowerCaseFilter(tok);
tok = new StopFilter(tok, stopwords);
return new TokenStreamComponents(src, tok) {
@Override
protected void setReader(final Reader reader) {
src.setMaxTokenLength(StandardAnalyzer.this.maxTokenLength);
super.setReader(reader);
}
};
}
e. 获取到TokenStream后,通过TokenStream中的incrementToken方法分析获取属性,然后通过TermsHashPerField下的add方法构造倒排表,最后将Field的相关数据存储在FreqProxPostingsArray类型的freqProxPostingsArray中, TermVectorsPostingsArray 的 termVectorsPostingsArray。构成一个倒置表;
// 以LowerCaseFilter为例,通过其下的increamentToken将Token中的字符转化为小写
public final boolean incrementToken() throws IOException {
if (input.incrementToken()) {
CharacterUtils.toLowerCase(termAtt.buffer(), 0, termAtt.length());
return true;
} else
return false;
}
try (TokenStream stream = tokenStream = field.tokenStream(docState.analyzer, tokenStream)) {
// reset TokenStream
stream.reset();
invertState.setAttributeSource(stream);
termsHashPerField.start(field, first);
// 分析并获取Token属性
while (stream.incrementToken()) {
……
try {
// 构建倒排表
termsHashPerField.add();
} catch (MaxBytesLengthExceededException e) {
……
} catch (Throwable th) {
throw AbortingException.wrap(th);
}
}
……
}
4.2 删除文件
一种。Lucene下要删除一个文档,首先将要删除的Term或者Query添加到删除队列中;
synchronized long deleteTerms(final Term... terms) throws IOException {
// TODO why is this synchronized?
final DocumentsWriterDeleteQueue deleteQueue = this.deleteQueue;
// 文档删除操作是将删除的词信息添加到删除队列中,根据flush策略进行删除
long seqNo = deleteQueue.addDelete(terms);
flushControl.doOnDelete();
lastSeqNo = Math.max(lastSeqNo, seqNo);
if (applyAllDeletes(deleteQueue)) {
seqNo = -seqNo;
}
return seqNo;
}
湾。根据 Flush 策略触发删除操作;
private boolean applyAllDeletes(DocumentsWriterDeleteQueue deleteQueue) throws IOException {
// 判断是否满足删除条件 --> onDelete
if (flushControl.getAndResetApplyAllDeletes()) {
if (deleteQueue != null) {
ticketQueue.addDeletes(deleteQueue);
}
// 指定执行删除操作的event
putEvent(ApplyDeletesEvent.INSTANCE); // apply deletes event forces a purge
return true;
}
return false;
}
public void onDelete(DocumentsWriterFlushControl control, ThreadState state) {
// 判断并设置是否满足删除条件
if ((flushOnRAM() && control.getDeleteBytesUsed() > 1024*1024*indexWriterConfig.getRAMBufferSizeMB())) {
control.setApplyAllDeletes();
if (infoStream.isEnabled("FP")) {
infoStream.message("FP", "force apply deletes bytesUsed=" + control.getDeleteBytesUsed() + " vs ramBufferMB=" + indexWriterConfig.getRAMBufferSizeMB());
}
}
}
4.3 文档更新
文档的更新是一个先删除再插入的过程,本文不再赘述。
4.4 索引刷新
写入一定数量的文档后,某个线程会触发IndexWriter的Flush操作生成segment,将内存中的Document信息写入硬盘。Flush 操作目前只有一种策略:FlushByRamOrCountsPolicy。FlushByRamOrCountsPolicy 基于两种策略自动执行 Flush 操作:
其中,activeBytes()是dwpt采集的索引占用的内存量,deleteByteUsed是删除索引的量。
@Override
public void onInsert(DocumentsWriterFlushControl control, ThreadState state) {
// 根据文档数进行Flush
if (flushOnDocCount()
&& state.dwpt.getNumDocsInRAM() >= indexWriterConfig
.getMaxBufferedDocs()) {
// Flush this state by num docs
control.setFlushPending(state);
// 根据内存使用量进行Flush
} else if (flushOnRAM()) {// flush by RAM
final long limit = (long) (indexWriterConfig.getRAMBufferSizeMB() * 1024.d * 1024.d);
final long totalRam = control.activeBytes() + control.getDeleteBytesUsed();
if (totalRam >= limit) {
if (infoStream.isEnabled("FP")) {
infoStream.message("FP", "trigger flush: activeBytes=" + control.activeBytes() + " deleteBytes=" + control.getDeleteBytesUsed() + " vs limit=" + limit);
}
markLargestWriterPending(control, state, totalRam);
}
}
}
将内存信息写入索引库。
索引冲洗分为主动冲洗和自动冲洗。该策略触发的Flush操作为Automatic Flush。Active Flush 的执行与 Automatic Flush 的执行有很大的不同。本文不会详细介绍 Active Flush。如果您需要了解,请跳至链接。
4.5 索引段合并
在索引 Flush 时,每个 dwpt 都会生成一个单独的段。当段数过多时,全文搜索可能会跨越多个段,导致多次加载。因此,需要合并太多的段。
通过 MergeScheduler 管理段合并的执行。mergeScheduler 还收录多种管理策略,包括 NoMergeScheduler、SerialMergeScheduler 和 ConcurrentMergeScheduler。
合并操作首先需要通过updatePendingMerges方法根据段合并策略查询需要合并的段。有许多类型的段合并策略。本文只介绍Lucene默认使用的两种段合并策略:TieredMergePolicy和LogMergePolicy。
<p>private synchronized boolean updatePendingMerges(MergePolicy mergePolicy, MergeTrigger trigger, int maxNumSegments)
throws IOException {
final MergePolicy.MergeSpecification spec;
// 查询需要合并的段
if (maxNumSegments != UNBOUNDED_MAX_MERGE_SEGMENTS) {
assert trigger == MergeTrigger.EXPLICIT || trigger == MergeTrigger.MERGE_FINISHED :
"Expected EXPLICT or MERGE_FINISHED as trigger even with maxNumSegments set but was: " + trigger.name();
spec = mergePolicy.findForcedMerges(segmentInfos, maxNumSegments, Collections.unmodifiableMap(segmentsToMerge), this);
newMergesFound = spec != null;
if (newMergesFound) {
final int numMerges = spec.merges.size();
for(int i=0;i