Lucene

予早 2025-10-07 00:28:14
Categories: Tags:

Lucene

Lucene Core 是一个 Java 库,提供索引和搜索功能,以及拼写检查、命中高亮和高级分析/分词能力。Solr 和 Elasticsearch 以 Lucene 为搜索引擎库开发而来,Lucene 充当两者的存储引擎。

https://lucene.apache.org/core/downloads.html

https://archive.apache.org/dist/lucene/java/8.11.4/

https://www.bilibili.com/video/BV17S4y1U7xb

Lucene Index 内存结构

Lucene Index 硬盘结构

Lucene 磁盘

Lucene Index 在文件系统中为一个目录。

Lucene Index 声明周期操作

查询

query -> weight -> collector -> scorer -> reduce

sequenceDiagram
    participant 客户端
    participant IndexSearcher
    participant Weight
    participant BulkScorer
    participant Collector
    participant TopDocs

    客户端->>IndexSearcher: search(query, 10)
    IndexSearcher->>IndexSearcher: rewrite & createWeight
    IndexSearcher->>Weight: 全局通行证
    loop 每个段
        IndexSearcher->>Weight: bulkScorer(ctx)
        Weight-->>IndexSearcher: 段内扫描器
        IndexSearcher->>BulkScorer: score(collector, liveDocs)
        loop 存活doc
            BulkScorer->>Collector: collect(doc)
        end
    end
    Collector->>TopDocs: 合并各段堆
    IndexSearcher-->>客户端: TopDocs
sequenceDiagram
    participant 用户
    participant IndexSearcher
    participant Query
    participant Weight
    participant BulkScorer
    participant Collector
    participant CollectorManager
    participant TopDocs

    用户->>IndexSearcher: search(query,10)
    activate IndexSearcher

    IndexSearcher->>Query: rewrite(reader)
    activate Query
    Query-->>IndexSearcher: 新Query树(原子节点)
    deactivate Query

    IndexSearcher->>Query: createWeight(searcher,TOP_SCORES,1.0f)
    activate Query
    Query->>Weight: 构造Weight(全局统计+boost)
    Weight-->>IndexSearcher: Weight通行证
    deactivate Query

    IndexSearcher->>CollectorManager: new TopScoreDocCollectorManager(10)
    activate CollectorManager
    CollectorManager-->>IndexSearcher: manager(篮子管理员)
    deactivate CollectorManager

    IndexSearcher->>IndexSearcher: search(leaves,weight,manager)
    loop 对每段LeafReaderContext
        IndexSearcher->>CollectorManager: newCollector()
        activate CollectorManager
        CollectorManager-->>IndexSearcher: 小篮子LeafCollector
        deactivate CollectorManager

        IndexSearcher->>Weight: bulkScorer(ctx)
        activate Weight
        Weight->>BulkScorer: 构造段内扫描器
        BulkScorer-->>IndexSearcher: BulkScorer
        deactivate Weight

        alt scorer!=null
            IndexSearcher->>BulkScorer: score(collector,getLiveDocs())
            activate BulkScorer
            loop 段内nextDoc
                BulkScorer->>BulkScorer: 检查liveDocs位图
                alt 存活
                    BulkScorer->>Collector: collect(doc)
                    activate Collector
                    Collector->>Collector: 小顶堆插入
                    deactivate Collector
                end
            end
            deactivate BulkScorer
        end
    end

    IndexSearcher->>CollectorManager: reduce(collectors)
    activate CollectorManager
    loop 遍历各小篮子
        CollectorManager->>Collector: topDocs()
        activate Collector
        Collector-->>CollectorManager: ScoreDoc[]
        deactivate Collector
        CollectorManager->>CollectorManager: 全局大顶堆合并
    end
    CollectorManager->>TopDocs: new TopDocs(totalHits,results)
    TopDocs-->>IndexSearcher: final TopDocs
    deactivate CollectorManager

    IndexSearcher-->>用户: TopDocs
    deactivate IndexSearcher
// ========== 1. 用户入口 ==========
TopDocs hits = indexSearcher.search(query, 10);

// ========== 2. 入口方法内部 ==========
public TopDocs search(Query query, int numHits) {
    // 2.1 重写
    Query rewritten = query.rewrite(reader);          // ① rewrite

    // 2.2 创建 Weight(通行证)
    Weight weight = rewritten.createWeight(
            this,                                  // IndexSearcher
            ScoreMode.TOP_SCORES,                  // 需要打分
            1.0f);                                 // 全局 boost

    // 2.3 提前构造最后要用的 CollectorManager
    CollectorManager<TopScoreDocCollector, TopDocs> manager =
            new TopScoreDocCollectorManager(numHits);

    // 2.4 真正搜索
    TopDocs result = search(leaves, weight, manager);
    return result;
}

// ========== 3. 多段搜索核心 ==========
TopDocs search(List<LeafReaderContext> leaves,
               Weight weight,
               CollectorManager<C, T> collectorManager) {

    List<C> collectors = new ArrayList<>(leaves.size());

    // 3.1 给每段预先 new 一个 LeafCollector(小篮子)
    for (LeafReaderContext ctx : leaves) {
        collectors.add(collectorManager.newCollector());
    }

    // 3.2 顺序或并发扫描各段
    for (int i = 0; i < leaves.size(); i++) {
        LeafReaderContext ctx = leaves.get(i);
        C collector = collectors.get(i);

        // 创建段内扫描器
        BulkScorer scorer = weight.bulkScorer(ctx);   // ③ scorer
        if (scorer != null) {                         // 该段可能无命中
            scorer.score(
                collector,                            // ④ collect
                ctx.reader().getLiveDocs());          // 过滤已删除
        }
    }

    // 3.3 合并所有小篮子 → 全局大篮子
    return collectorManager.reduce(collectors);
}

// ========== 4. CollectorManager 合并 ==========
public TopDocs reduce(List<TopScoreDocCollector> collectors) {
    // 把所有小顶堆里的候选再丢进一个大顶堆
    PriorityQueue<ScoreDoc> globalPQ = ...;
    for (TopScoreDocCollector c : collectors) {
        ScoreDoc[] segmentTop = c.topDocs().scoreDocs;
        for (ScoreDoc sd : segmentTop) {
            globalPQ.insertWithOverflow(sd);
        }
    }
    // 转数组并返回
    TopDocs finalDocs = new TopDocs(totalHits, globalPQ.getResults());
    return finalDocs;
}
客户端
 │
 │  TopDocs hits = indexSearcher.search(query, 10)
 ▼
IndexSearcher::search
 ├─① rewrite(Query) ──→ 新 Query 树(只含原子节点)
 ├─② createWeight(ScoreMode.TOP_SCORES) ──→ Weight(含全局统计)
 ├─③ new TopScoreDocCollectorManager(10) ──→ 提前造好“篮子管理员”
 ▼
IndexSearcher::search(leaves, weight, manager)
 ├─3.1  for 段 in leaves
 │      collectors += manager.newCollector()   // 每段一个小篮子
 │
 ├─3.2  for 段 in leaves                       // 可并发 ForkJoin
 │      BulkScorer s = weight.bulkScorer(ctx)  // ③ 段内扫描器
 │      s.score(collector, ctx.reader().getLiveDocs())
 │                │              │
 │                │              └─> Bits(跳过已删文档)
 │                └─> collector.collect(doc)   // ④ 存活 doc 进小顶堆
 ▼
manager.reduce(collectors)                    // 4. 合并小顶堆
 ├─ PriorityQueue<ScoreDoc> globalPQ           // 大顶堆
 └─ TopDocs(finalHits, globalPQ.results)
 ▲
返回给用户

增加、更新、删除

Lucene 中写操作(增加、更新、删除)由 IndexWriter 完成。

flowchart TD
    IW[IndexWriter<br/>全局唯一] --> DW[DocumentsWriter<br/>单例]
    DW -->|Thread-1| DWPT1[DWPT<br/>DocumentsWriterPerThread]
    DW -->|Thread-2| DWPT2[DWPT<br/>DocumentsWriterPerThread]
    DW -->|Thread-n| DWPTn[DWPT<br/>DocumentsWriterPerThread]
    DWPT1 --> Buf1[五大管道<br/>内存 buffer]
    DWPT2 --> Buf2[五大管道<br/>内存 buffer]
    DWPTn --> Bufn[五大管道<br/>内存 buffer]
  1. 一个索引目录下仅允许一个 IndexWriter,其由 write.lock 保证。

  2. 一个 IndexWriter 有且仅有一个 DocumentsWriter。

  3. 一个 DocumentsWriter 基于 CAS 和 ThreadLocal 为每个线程分配 DocumentsWriterPerThread(DWPT)。

  4. 每个 DWPT 均有五大管道及其各自对应的内存 Buffer,循环依次写每个管道:

    管道 负责结构 内存组件 最终文件
    ① 倒排管道 词项 → 文档列表 FreqProxTermsWriter .tim .tip .doc .pos .pay
    ② 行存管道 原始字段值 StoredFieldsWriter .fdt .fdx
    ③ 列存管道 排序/聚合字段 DocValuesWriter .dvd .dvm
    ④ 向量管道 词向量(可选) TermVectorsWriter .tvx .tvd .tvf
    ⑤ 长度管道 字段长度/加权 NormsWriter .nvd .nvm

增加和更新流程

客户端
 │
 │  addDocument(doc) / updateDocument(term,doc)
 ▼
IndexWriter.updateDocument(...)
 ├─进入 synchronized 块
 │   ├─ 更新:BufferedUpdates.addTerm(term)  // 画删除位图
 │   └─ 新增:DWPT 五大管道写内存 buffer
 │
 ├─检测 flush 条件
 │   ├─ buffer 满 │ 文档数超限 │ 显式 flush()
 │   └─ 条件到?→ 同线程立即 flush
 │        ├─ 锁定 DWPT
 │        ├─ 内存 buffer → 新段(磁盘 I/O)
 │        └─ 注册 SegmentInfos(段列表变)
 │
 ├─退出 synchronized,返回客户端
 │
 └─通知 MergeScheduler
      ├─MergePolicy.findMerges()  // 必调用,返回建议列表
      ├─若列表非空→ 提交 OneMerge 任务 → 后台线程异步执行
      └─若列表空→ 无任务,直接结束

删除流程

客户端线程
 │
 │  deleteDocuments(term/query)
 ▼
IndexWriter.deleteDocuments(...)
 ├─进入 synchronized 块
 │   └─ 仅 FrozenBufferedUpdates.addTerm/Query  // 写删除位图
 │
 ├─检测 flush 条件(同新增)
 │   └─ 条件到?→ 同线程立即 flush
 │        ├─ 无文档 buffer,仅落 .del 文件
 │        └─ 注册 SegmentInfos(段列表变)
 │
 ├─退出 synchronized,返回客户端
 │
 └─通知 MergeScheduler
      ├─MergePolicy.findMerges()  // 必调用,返回建议列表
      ├─若列表非空→ 提交 OneMerge 任务 → 后台线程异步执行
      └─若列表空→ 无任务,直接结束

删除只是内存位图/队列,segment 文件里追加 .del 文件;真正的 doc 移除在 merge 阶段

flush、refresh、merge、commit

flowchart LR
    A[内存] -->|flush| B[段文件<br/>PageCache]
    B -->|refresh| C[Reader可见]
    B -->|merge| D[大段+空间释放]
    D -->|commit| E[磁盘持久化]
维度 flush refresh merge commit
目的 内存→段文件 段→Reader 视图 多段→少段 事务点落盘
产出 新段全套文件 新 DirectoryReader 合并后大段 新 segments_N + fsync
可见性 ❌ 不可搜 ✅ 立即可搜 不影响 ✅ 立即可搜
fsync ❌ 不写盘 ❌ 不写盘 ❌ 不写盘 ✅ 强制落盘
触发 RAM 满 / 手动 默认 1 s / 手动 段数/大小阈值 手动 / 定时
代价 中等(压缩+写缓存) 极低(CAS 换指针) 高(I/O+CPU) 高(刷盘)
参数 ramBufferSizeMB indexWriter.getReader() TieredMergePolicy commit()

flush 生段,refresh 可见,merge 瘦身,commit 落盘,前三写缓存,后一写硬盘。

flush(内存 → 文件系统缓存)

触发时机

阶段 源码 生成文件 说明
1. 冻结 DWPT DWPT.abort() 不再接受新 doc 保证并发安全
2. 刷倒排 FreqProxTerms.flush().tim/.tip/.doc 倒排表 + 字典 FOR+VarInt 压缩
3. 刷行存 StoredFieldsWriter.finish().fdt/.fdx 原始文档 行存压缩
4. 刷列存 DocValuesConsumer.flush().dvd/.dvm 排序/聚合 按数据类型选编码
5. 写删除 BufferedUpdatesStream.applyDeletes().del 删除位图 只写逻辑删
6. 生成 SegmentInfo new SegmentInfo().si 段元数据 包含版本、docCount、文件列表
7. 写 CFE 清单 CompoundFileWriter.cfs/.cfe 单文件 默认合并写减少 fd
8. 更新 SegmentInfos SegmentInfos.update()segments_N 全局指针 CAS 换指针,无锁

数据产出

一次 flush 会将 DWPT 中的五大管道缓冲区数据写入 OS 页缓冲,生成一套新文件,等待内核刷盘

_5.fdt   _5.fdx      行存
_5.tim   _5.tip      倒排字典
_5.doc   _5.pos      倒排列表
_5.dvd   _5.dvm      列存
_5.nvd   _5.nvm      长度
_5.tvx   _5.tvd      词向量(可选)
_5.del               删除位图(如果有)
_5.si                段元数据
_5.cfe _5.cfs        复合文件(默认开启)

注意:flush 不调用 fsync,数据仍在 OS 文件系统页缓存;只有 commit 才做 Directory.sync()

luke

Luke 是一款专为 Apache Lucene 设计的图形化索引诊断工具,作用类似于“Lucene 的 DBA 客户端”。它把索引文件里那些不可读的二进制数据变成可视化界面,让你不用写代码就能浏览、查询、调试甚至轻度维护索引

refresh

merge(段合并 → 真正物理删除)

索引Flush时每个dwpt会单独生成一个segment,当segment过多时进行全文检索可能会跨多个segment,产生多次加载的情况,因此需要对过多的segment进行合并。

触发时机

步骤 源码 输入 输出 关键点
1. 选段 TieredMergePolicy.findMerges() 所有 SegmentInfo OneMerge 对象列表 分层打分(5% 阶梯)
2. 加锁 mergingSegments Set 防止重复被选
3. 应用删除 mergeReader.applyDeletes() .del 位图 新段无被删 doc 真正丢弃已删文档
4. 合并字典 SegmentMerger.mergeTerms() 多段 .tim .tim/.tip 多路归并(优先队列)
5. 合并倒排 mergeTerms()appendPostings() .doc/.pos/.pay 新倒排 docID 重新连续编号
6. 合并行存 StoredFieldsMerger .fdt .fdt 顺序拷贝,去删行
7. 合并列存 DocValuesMerger .dvd .dvd 按类型选合并策略
8. 生成新段 SegmentInfo newSegmentName() .si + .cfs 新段大小 ≈ 旧段和
9. 提交清单 IndexWriter.mergeFinish() segments_N 原子切换 旧段文件异步删除

合并后才物理抹去已删文档,磁盘空间此时真正释放

commit

唯一会进行 fsync 的操作

Elasticsearch

Elasticsearch 底层使用 Lucene 作为搜索引擎库,Lucene 中没有分布式和并发等功能,仅适用于单机数据处理,若想要处理海量数据,则需要基于 Lucene 进行相关设计,Elasticsearch 就是在 Lucene 基础之上,进行功能增强而实现的分布式搜索引擎。

存储

Elasticsearch 集群由若干 Node 构成,ELasticsearch 中存储 Elasticsearch Index,Elasticsearch Index 由若干 Shard 构成,若干 Shard 按照一定策略分布在整个集群的若干 Node 上,由此实现 Elasticsearch Index 的分布式存储,Shard 分为主分片和副本分片,主分片负责处理写入请求和存储数据,副本分片只负责存储数据,是主分片的拷贝,文档会存储在具体的某个主分片和副本分片上,每一个 Shard 对应一个 Lucene Index。

  1. ES 客户端将写索引操作包装为 HTTP 请求发送至 ES 服务器
  2. ES 服务器解析写索引操作并执行,数据被写入 index buffer,同时写Translog,执行成功向客户端响应结果

当达成下述条件时触发 refresh index 操作,index buffer 数据被写入 index segment,此时 segment 存储在内存中还未持久化到磁盘

  1. 按照时间频率触发,默认情况是每 1 秒触发 1 次 Refresh,可通过index.refresh_interval 设置;
  2. 当Index Buffer 被占满的时候,会触发 Refresh,Index Buffer 的大小默认值是 JVM 所占内存容量的 10%;
  3. 手动调用调用Refresh API。
    • POST /my_index/_refresh,refresh 索引 my_index
    • POST /*/_refresh,refresh 所有索引

由于Refresh操作默认间隔为1s,因此会产生大量的小Segment,ES查询时会同时查询所有的Segment,并对结果进行汇总,大量小Segment会使性能变差。因此ES会对小Segment进行段合并(Merge),合并操作会丢弃掉重复的键,并只保留每个键最近的更新。段合并之后搜索请求可以直接访问合并之后的Segment,从而提高搜索性能。

当达成下述条件时触发 merge 操作

  1. ES自动启动后台任务进行Merge;
  2. 手动调用_forcemerge API主动触发。

在段合并完成之后,ES会将Segment文件Flush到磁盘中,并创建一个Commit Point文件,用来标识被Flush到磁盘的Segment。Commit Point其实是记录所有的Segment信息,关于移除的Segment的信息会记录在“.del”文件中,查询结果后会从该文件中进行过滤。

Flush操作是将Segment从文件系统缓存写入到磁盘进行持久化,在执行 Flush 的时候会依次执行下面操作:

  1. 清空Index Buffer
  2. 记录 Commit Point
  3. 刷新Segment到磁盘
  4. 删除translog

Elasticsearch 写数据流程

Elasticsearch 持久化模型

  1. 客户端向 Elasticsearch 集群某节点发起请求,该节点成为协调节点

  2. 协调节点基于hash取模计算文档要写入的主分片,路由请求到该主分片所在的数据节点

  3. 数据节点上处理该请求,写入数据到对应主分片,并将数据同步到对应的副分片

    1. 写 Lucene Index 内存缓存,同时写 Translog,此时文档在缓冲区还无法被搜索到,仅可以通过 id 访问。

    2. 如果希望该文档能立刻被搜索,需要手动调用refresh 操作。在 Elasticsearch 中,默认情况下 _refresh 操作设置为每秒执行一次。 在此操作期间,内存中缓冲区的内容将复制到内存中新创建的 Segment 中,如下图所示。 结果,新数据可用于搜索。

      这个refresh的时间间隔可以由 index 设置中 index.refresh_interval 来定义。只有在 buffer 的内容写入到 Segement 后,这个被写入的文档才变为可以搜索的文档。通常 buffer 里的内容被写入到 Segment 里去,有三个条件:

      • 由索引中的设置所指定的 refresh_interval 启动的周期性的 refresh。在默认的情况下为1s
      • 在导入文档时强制 refresh:PUT twitter/_doc/1?refresh=true
      • 当 In Memory Buffer 满了,在默认的情况下为 node Heap 的 10%

      fdfresh 会触发 Lucene Flush,实质上意味着将内存缓冲区中的所有文档都写入新的 Lucene Segment,如下面的图所示。 这些连同所有现有的内存段一起被提交到磁盘,该磁盘清除事务日志(参见图4)。 此提交本质上是 Lucene 提交(commit)。

  4. 协调节点等待主分片和副分片均写成功后返回客户端结果

Lucene 中的 Reopen
当调用 Lucene Reopen 时,将使累积的数据可用于搜索。 尽管可以搜索最新数据,但这不能保证数据的持久性或未将其写入磁盘。 我们可以调用 n 次重新打开功能,并使最新数据可搜索,但不能确定磁盘上是否存在数据。

Lucene 中的 Commits
Lucene 提交使数据安全。 对于每次提交,来自不同段的数据将合并并推送到磁盘,从而使数据持久化。 尽管提交是持久保存数据的理想方法,但问题是每个提交操作都占用大量资源。 每个提交操作都有其自己的内部 I/O 操作以及与其相关的读/写周期。 这就是为什么我们希望在基于 Lucene 的系统中一次又一次地重新使用重新打开功能以使新数据可搜索的确切原因。

Elasticsearch 读数据流程

Query-then-Fetch运行机制

Elasticsearch的分布式搜索的运行机制称为Query-then-Fetch。具体分为Query和Fetch两个阶段:

Query阶段

用户发出搜索请求到达ES节点。节点收到请求后,会以协调节点(Coordinating Node)的身份,在6个主副分片中随机选择3个分片,发送查询请求。

被选中的节点,进行排序(根据score值进行排序)。然后每个分片都返回 From+size 个排序后的文档id和排序值给协调节点。 注意这里返回的是文档id。

Fetch阶段

Coordinating节点将Query阶段从每个分片获取的排序的文档id列表重新进行排序,选取 From 到 From+size 个文档的id。

multi get请求的方式,到相应的分片获取详细的文档数据。

4.2.2 为什么需要两阶段才能完成搜索

因为Elasticsearch在查询的时候不知道文档位于哪个分片,因此索引的所有分片都要参与搜索,然后协调节点将结果合并,在根据文档ID获取文档内容。例如现在有5个分片,需要查询匹配度Top10的数据,那么每个分片都要查询出当前分片的Top10的数据,协调节点将5×10个结果再次进行排序,返回Top10的结果给客户端。

4.2.3 Query-then-Fetch存在问题和解决方案

Query-then-Fetch存在问题分为两方面,一个是性能问题,一个是相关性算分问题。

性能问题主要表现为深度分页的问题。Elasticsearch数据是分片存储的,数据分布在多台机器上。有这样一个场景,如何获取前1000个文档?当获取从990-1000的文档时候,会在每个分片上面都先获取1000个文档,然后再由协调节点聚合所有分片的结果在排序选取前1000个文档。

这个过程有什么问题吗?当然是有的,页数越深,处理文档越多,占用内存越多,耗时越长。所以要尽量避免深度分页。当然,ES官方也注意了这个问题,所以通过index.max_result_window限定最多到10000条数据。当然我们也可以根据业务需要修改这个参数,这也解释了:为什么Google搜索结果只有相关度最高的17页结果,百度只有76页的结果,原因之一是受限于Elasticsearch深度分页的性能问题。

  1. 当数据量不大的时候,将主分片数设置为1;当数据量足够大的时候,只要保证文档均匀分布在各个分片上面,结果一般不会出现偏差
  2. 使用DFS Query Then Fetch
    在搜索的URL中指定参数 _search?search_type=dfs_query_then_fetch ;这样就可以保证每个分片把各个分片的词频和文档频率进行搜集,然后进行一次相关性算分。但是这样会耗费更多的CPU和内存资源,执行性能较低。

使用Search_After
ES提供实时的下一页文档获取功能,这个功能只能下一页,不能上一页;
不能指定页数,不能使用from参数;

类型 场景
From/Size 需要实时获取顶部的部分文档,且需要自由翻页
Scroll 需要全部文档,如导出所有数据的功能
Search_After 需要全部文档,不需要自由翻页

ES的分片存储决定了搜索机制。其实存储和搜索不能分割开来看,只存储不可搜索,这个存储是没有意义的;只搜索没有存储(数据源)是空中楼阁。

节点有不同角色

https://www.elastic.co/guide/en/elasticsearch/reference/7.10/modules-node.html

许多编程语言内置的简单哈希函数可能并不适合分片,例如,Java的Object.hashCode和Ruby的Object#hash,同一键在不同的进程中可能返回不同的哈希值。许多数据系统使用MurmurHash算法进行哈希计算,例如Redis、Memcached等

并发控制

物理文件分析

https://blog.csdn.net/qq_27529917/article/details/108590073

https://www.zhangxt.top/2022/02/06/elasticsearch-storage-model/

https://zhuanlan.zhihu.com/p/443722667

ES为什么搜索这么快

index 中的每一个字段都会建立索引,其中最为著名的是text使用倒排索引进行全文检索

关联表数据存储es方案

ES中通过join类型字段构建父子关联

https://blog.csdn.net/w1014074794/article/details/119355162

es重建索引标准步骤,???

聚合查询结果不精确

https://www.bilibili.com/video/BV1ifqBYqEZ6?spm_id_from=333.788.videopod.episodes&vd_source=1c6bbc08f3ca11adc6d983810711f9ca&p=43

https://blog.csdn.net/laoyang360/article/details/107133008

cardinality聚合不精确问题

https://www.cnblogs.com/jingdongkeji/p/17378245.html

模糊查询

支持错字

动态类型

局部性原理告诉我们要尽可能减少IO损耗,尽可能IO的数据都是需要的数据,所以才会有专门的索引结构

索引别名

https://www.bilibili.com/video/BV1KmMqzsEU3?spm_id_from=333.788.videopod.episodes&vd_source=1c6bbc08f3ca11adc6d983810711f9ca&p=9

ik分词器词库热更新

https://www.bilibili.com/video/BV1TR4y1Q7kd?spm_id_from=333.788.videopod.episodes&vd_source=1c6bbc08f3ca11adc6d983810711f9ca&p=69

https://blog.csdn.net/qq_40592041/article/details/107856588

分片策略

持久化机制

00