• Lucene轻量级搜索引擎,真的太强了!!!Solr 和 ES 都是基于它


    一、基础知识

    1、Lucene 是什么

    Lucene 是一个本地全文搜索引擎,Solr 和 ElasticSearch 都是基于 Lucene 的封装

    Lucene 适合那种轻量级的全文搜索,我就是服务器资源不够,如果上 ES 的话会很占用服务器资源,所有就选择了 Lucene 搜索引擎

    2、倒排索引原理

    全文搜索的原理是使用了倒排索引,那么什么是倒排索引呢?

    1. 先通过中文分词器,将文档中包含的关键字全部提取出来,比如我爱中国,会通过分词器分成我,爱,中国,然后分别对应‘我爱中国’
    2. 然后再将关键字与文档的对应关系保存起来
    3. 最后对关键字本身做索引排序

    3、与传统数据库对比

    Lucene DB
    数据库表(table) 索引(index)
    行(row) 文档(document)
    列(column) 字段(field)

    4、数据类型

    常见的字段类型

    1. StringField:这是一个不可分词的字符串字段类型,适用于精确匹配和排序
    2. TextField:这是一个可分词的字符串字段类型,适用于全文搜索和模糊匹配
    3. IntField、LongField、FloatField、DoubleField:这些是数值字段类型,用于存储整数和浮点数。
    4. DateField:这是一个日期字段类型,用于存储日期和时间。
    5. BinaryField:这是一个二进制字段类型,用于存储二进制数据,如图片、文件等。
    6. StoredField:这是一个存储字段类型,用于存储不需要被索引的原始数据,如文档的内容或其他附加信息。

    Lucene 分词器是将文本内容分解成单独的词汇(term)的工具。Lucene 提供了多种分词器,其中一些常见的包括

    1. StandardAnalyzer:这是 Lucene 默认的分词器,它使用 UnicodeText 解析器将文本转换为小写字母,并且根据空格、标点符号和其他字符来进行分词。
    2. CJKAnalyzer:这个分词器专门为中日韩语言设计,它可以正确地处理中文、日文和韩文的分词。
    3. KeywordAnalyzer:这是一个不分词的分词器,它将输入的文本作为一个整体来处理,常用于处理精确匹配的情况。
    4. SimpleAnalyzer:这是一个非常简单的分词器,它仅仅按照非字母字符将文本分割成小写词汇。
    5. WhitespaceAnalyzer:这个分词器根据空格将文本分割成小写词汇,不会进行任何其他的处理。

    但是对于中文分词器,我们一般常用第三方分词器IKAnalyzer,需要引入它的POM文件

    二、最佳实践

    1、依赖导入

    <lucene.version>8.1.1lucene.version>
    <IKAnalyzer-lucene.version>8.0.0IKAnalyzer-lucene.version>
    
    
    
    <dependency>
        <groupId>org.apache.lucenegroupId>
        <artifactId>lucene-coreartifactId>
        <version>${lucene.version}version>
    dependency>
    
    
    <dependency>
        <groupId>org.apache.lucenegroupId>
        <artifactId>lucene-queryparserartifactId>
        <version>${lucene.version}version>
    dependency>
    
    
    <dependency>
        <groupId>org.apache.lucenegroupId>
        <artifactId>lucene-analyzers-commonartifactId>
        <version>${lucene.version}version>
    dependency>
    
    
    <dependency>
        <groupId>org.apache.lucenegroupId>
        <artifactId>lucene-highlighterartifactId>
        <version>${lucene.version}version>
    dependency>
    
    
    <dependency>
        <groupId>com.jianggujingroupId>
        <artifactId>IKAnalyzer-luceneartifactId>
        <version>${IKAnalyzer-lucene.version}version>
    dependency>
    
    
    

    2、创建索引

    1. 先制定索引的基本数据,包括索引名称和字段
    /**
     * @author: sunhhw
     * @date: 2023/12/25 17:39
     * @description: 定义文章文档字段和索引名称
     */
    public interface IArticleIndex {
    
        /**
         * 索引名称
         */
        String INDEX_NAME = "article";
    
        // --------------------- 文档字段 ---------------------
        String COLUMN_ID = "id";
        String COLUMN_ARTICLE_NAME = "articleName";
        String COLUMN_COVER = "cover";
        String COLUMN_SUMMARY = "summary";
        String COLUMN_CONTENT = "content";
        String COLUMN_CREATE_TIME = "createTime";
    }
    
    1. 创建索引并新增文档
    /**
     * 创建索引并设置数据
     *
     * @param indexName 索引地址
     */
    public void addDocument(String indexName, List documentList) {
        // 配置索引的位置 例如:indexDir = /app/blog/index/article
        String indexDir = luceneProperties.getIndexDir() + File.separator + indexName;
        try {
            File file = new File(indexDir);
            // 若不存在,则创建目录
            if (!file.exists()) {
                FileUtils.forceMkdir(file);
            }
            // 读取索引目录
            Directory directory = FSDirectory.open(Paths.get(indexDir));
            // 中文分析器
            Analyzer analyzer = new IKAnalyzer();
            // 索引写出工具的配置对象
            IndexWriterConfig conf = new IndexWriterConfig(analyzer);
            // 创建索引
            IndexWriter indexWriter = new IndexWriter(directory, conf);
            long count = indexWriter.addDocuments(documentList);
            log.info("[批量添加索引库]总数量:{}", documentList.size());
            // 提交记录
            indexWriter.commit();
            // 关闭close
            indexWriter.close();
        } catch (Exception e) {
            log.error("[创建索引失败]indexDir:{}", indexDir, e);
            throw new UtilsException("创建索引失败", e);
        }
    }
    
    1. 注意这里有个坑,就是这个indexWriter.close();必须要关闭, 不然在执行其他操作的时候会有一个write.lock文件锁控制导致操作失败
    2. indexWriter.addDocuments(documentList)这是批量添加,单个添加可以使用indexWriter.addDocument()
    1. 单元测试
    @Test
    public void create_index_test() {
        ArticlePO articlePO = new ArticlePO();
        articlePO.setArticleName("git的基本使用" + i);
        articlePO.setContent("这里是git的基本是用的内容" + i);
        articlePO.setSummary("测试摘要" + i);
        articlePO.setId(String.valueOf(i));
        articlePO.setCreateTime(LocalDateTime.now());
        Document document = buildDocument(articlePO);
        LuceneUtils.X.addDocument(IArticleIndex.INDEX_NAMEdocument);
    }
    
    private Document buildDocument(ArticlePO articlePO) {
        Document document = new Document();
        LocalDateTime createTime = articlePO.getCreateTime();
        String format = LocalDateTimeUtil.format(createTime, DateTimeFormatter.ISO_LOCAL_DATE);
    
        // 因为ID不需要分词,使用StringField字段
        document.add(new StringField(IArticleIndex.COLUMN_ID, articlePO.getId() == null ? "" : articlePO.getId(), Field.Store.YES));
        // 文章标题articleName需要搜索,所以要分词保存
        document.add(new TextField(IArticleIndex.COLUMN_ARTICLE_NAME, articlePO.getArticleName() == null ? "" : articlePO.getArticleName(), Field.Store.YES));
        // 文章摘要summary需要搜索,所以要分词保存
        document.add(new TextField(IArticleIndex.COLUMN_SUMMARY, articlePO.getSummary() == null ? "" : articlePO.getSummary(), Field.Store.YES));
         // 文章内容content需要搜索,所以要分词保存
        document.add(new TextField(IArticleIndex.COLUMN_CONTENT, articlePO.getContent() == null ? "" : articlePO.getContent(), Field.Store.YES));
        // 文章封面不需要分词,但是需要被搜索出来展示
        document.add(new StoredField(IArticleIndex.COLUMN_COVER, articlePO.getCover() == null ? "" : articlePO.getCover()));
        // 创建时间不需要分词,仅需要展示
        document.add(new StringField(IArticleIndex.COLUMN_CREATE_TIME, format, Field.Store.YES));
        return document;
    }
    

    3、更新文档

    1. 更新索引方法
    /**
     * 更新文档
     *
     * @param indexName 索引地址
     * @param document  文档
     * @param condition 更新条件
     */
    public void updateDocument(String indexName, Document document, Term condition) {
        String indexDir = luceneProperties.getIndexDir() + File.separator + indexName;
        try {
            // 读取索引目录
            Directory directory = FSDirectory.open(Paths.get(indexDir));
            // 中文分析器
            Analyzer analyzer = new IKAnalyzer();
            // 索引写出工具的配置对象
            IndexWriterConfig conf = new IndexWriterConfig(analyzer);
            // 创建索引
            IndexWriter indexWriter = new IndexWriter(directory, conf);
            indexWriter.updateDocument(condition, document);
            indexWriter.commit();
            indexWriter.close();
        } catch (Exception e) {
            log.error("[更新文档失败]indexDir:{},document:{},condition:{}", indexDir, document, condition, e);
            throw new ServiceException();
        }
    }
    
    1. 单元测试
    @Test
    public void update_document_test() {
        ArticlePO articlePO = new ArticlePO();
        articlePO.setArticleName("git的基本使用=编辑");
        articlePO.setContent("这里是git的基本是用的内容=编辑");
        articlePO.setSummary("测试摘要=编辑");
        articlePO.setId("2");
        articlePO.setCreateTime(LocalDateTime.now());
        Document document = buildDocument(articlePO);
        LuceneUtils.X.updateDocument(IArticleIndex.INDEX_NAMEdocumentnew Term("id""2"));
    }
    
    1. 更新的时候,如果存在就更新那条记录,如果不存在就会新增一条记录
    2. new Term("id", "2")搜索条件,跟数据库里的where id = 2差不多
    3. IArticleIndex.INDEX_NAME = article 索引名称

    4、删除文档

    1. 删除文档方法
    /**
    * 删除文档
    *
    * @param indexName 索引名称
    * @param condition 更新条件
    */
    public void deleteDocument(String indexName, Term condition) {
      String indexDir = luceneProperties.getIndexDir() + File.separator + indexName;
      try {
          // 读取索引目录
          Directory directory = FSDirectory.open(Paths.get(indexDir));
          // 索引写出工具的配置对象
          IndexWriterConfig conf = new IndexWriterConfig();
          // 创建索引
          IndexWriter indexWriter = new IndexWriter(directory, conf);
    
          indexWriter.deleteDocuments(condition);
          indexWriter.commit();
          indexWriter.close();
      } catch (Exception e) {
          log.error("[删除文档失败]indexDir:{},condition:{}", indexDir, condition, e);
          throw new ServiceException();
      }
    }
    
    1. 单元测试
    @Test
    public void delete_document_test() {
        LuceneUtils.X.deleteDocument(IArticleIndex.INDEX_NAMEnew Term(IArticleIndex.COLUMN_ID"1"));
    }
    
    1. 删除文档跟编辑文档类似

    5、删除索引

    把改索引下的数据全部清空

    /**
    * 删除索引
    *
    * @param indexName 索引地址
    */
    public void deleteIndex(String indexName) {
      String indexDir = luceneProperties.getIndexDir() + File.separator + indexName;
      try {
          // 读取索引目录
          Directory directory = FSDirectory.open(Paths.get(indexDir));
          // 索引写出工具的配置对象
          IndexWriterConfig conf = new IndexWriterConfig();
          // 创建索引
          IndexWriter indexWriter = new IndexWriter(directory, conf);
          indexWriter.deleteAll();
          indexWriter.commit();
          indexWriter.close();
      } catch (Exception e) {
          log.error("[删除索引失败]indexDir:{}", indexDir, e);
          throw new ServiceException();
      }
    }
    
    

    6、普通查询

    1. TermQuery查询
    Term term = new Term("title""lucene");
    Query query = new TermQuery(term);
    

    上述代码表示通过精确匹配字段"title"中包含"lucene"的文档。

    1. PhraseQuery查询
    PhraseQuery.Builder builder = new PhraseQuery.Builder();
    builder.add(new Term("content""open"));
    builder.add(new Term("content""source"));
    PhraseQuery query = builder.build();
    

    上述代码表示在字段"content"中查找包含"open source"短语的文档

    1. BooleanQuery查询
    TermQuery query1 = new TermQuery(new Term("title""lucene"));
    TermQuery query2 = new TermQuery(new Term("author""john"));
    BooleanQuery.Builder builder = new BooleanQuery.Builder();
    builder.add(query1, BooleanClause.Occur.MUST);
    builder.add(query2, BooleanClause.Occur.MUST);
    BooleanQuery query = builder.build();
    

    上述代码表示使用布尔查询同时满足"title"字段包含"lucene"和"author"字段包含"john"的文档。

    1. WildcardQuery查询
    WildcardQuery示例:
    java
    WildcardQuery query = new WildcardQuery(new Term("title""lu*n?e"));
    

    上述代码表示使用通配符查询匹配"title"字段中以"lu"开头,且第三个字符为任意字母,最后一个字符为"e"的词项

    1. MultiFieldQueryParser查询
    String[] fields = {"title""content""author"};
    Analyzer analyzer = new StandardAnalyzer();
    
    MultiFieldQueryParser parser = new MultiFieldQueryParser(fields, analyzer);
    Query query = parser.parse("lucene search");
    

    a. 在"title", "content", "author"三个字段中搜索关键字"lucene search"的文本数据 b. MultiFieldQueryParser 默认使用 OR 运算符将多个字段的查询结果合并,即只要在任意一个字段中匹配成功即

    可以使用MultiFieldQueryParser查询来封装一个简单的搜索工具类,这个较为常用

    /**
    * 关键词搜索
    *
    * @param indexName 索引目录
    * @param keyword   查询关键词
    * @param columns   被搜索的字段
    * @param current   当前页
    * @param size      每页数据量
    * @return
    */
    public List search(String indexName, String keyword, String[] columns, int current, int size) {
      String indexDir = luceneProperties.getIndexDir() + File.separator + indexName;
      try {
          // 打开索引目录
          Directory directory = FSDirectory.open(Paths.get(indexDir));
          IndexReader reader = DirectoryReader.open(directory);
          IndexSearcher searcher = new IndexSearcher(reader);
          // 中文分析器
          Analyzer analyzer = new IKAnalyzer();
          // 查询解析器
          QueryParser parser = new MultiFieldQueryParser(columns, analyzer);
          // 解析查询关键字
          Query query = parser.parse(keyword);
          // 执行搜索,获取匹配查询的前 limit 条结果。
          int limit = current * size;
          // 搜索前 limit 条结果
          TopDocs topDocs = searcher.search(query, limit); 
          // 匹配的文档数组
          ScoreDoc[] scoreDocs = topDocs.scoreDocs;
          // 计算分页的起始 - 结束位置
          int start = (current - 1) * size;
          int end = Math.min(start + size, scoreDocs.length);
          // 返回指定页码的文档
          List documents = new ArrayList<>();
          for (int i = start; i < end; i++) {
              Document doc = searcher.doc(scoreDocs[i].doc);
              documents.add(doc);
          }
          // 释放资源
          reader.close();
          return documents;
      } catch (Exception e) {
          log.error("查询 Lucene 错误: ", e);
          return null;
      }
    }
    
    

    7、关键字高亮

    @Test
    public void searchArticle() throws InvalidTokenOffsetsException, IOException, ParseException {
        String keyword = "安装";
        String[] fields = {IArticleIndex.COLUMN_CONTENT, IArticleIndex.COLUMN_ARTICLE_NAME};
        // 先查询出文档列表
        List documentList = LuceneUtils.X.search(IArticleIndex.INDEX_NAME, keyword, fields, 1100);
    
        // 中文分词器
        Analyzer analyzer = new IKAnalyzer();
        // 搜索条件
        QueryParser queryParser = new MultiFieldQueryParser(fields, analyzer);
        // 搜索关键词,也就是需要高亮的字段
        Query query = queryParser.parse(keyword);
        // 高亮html语句
        Formatter formatter = new SimpleHTMLFormatter("""");
        QueryScorer scorer = new QueryScorer(query);
        Highlighter highlighter = new Highlighter(formatter, scorer);
        // 设置片段长度,一共展示的长度
        highlighter.setTextFragmenter(new SimpleFragmenter(50));
        List list = new ArrayList<>();
    
        for (Document doc : documentList) {
            SearchArticleVO articleVO = new SearchArticleVO();
            articleVO.setId(doc.get(IArticleIndex.COLUMN_ID));
            articleVO.setCover(doc.get(IArticleIndex.COLUMN_COVER));
            articleVO.setArticleName(doc.get(IArticleIndex.COLUMN_ARTICLE_NAME));
            articleVO.setSummary(doc.get(IArticleIndex.COLUMN_SUMMARY));
            articleVO.setCreateTime(LocalDate.parse(doc.get(IArticleIndex.COLUMN_CREATE_TIME)));
            for (String field : fields) {
                // 为文档生成高亮
                String text = doc.get(field);
                // 使用指定的分析器对文本进行分词
                TokenStream tokenStream = TokenSources.getTokenStream(field, text, analyzer);
                // 找到其中一个关键字就行了
                String bestFragment = highlighter.getBestFragment(tokenStream, text);
                if (StringUtils.isNotBlank(bestFragment)) {
                    // 输出高亮结果,取第一条即可
                    if (field.equals(IArticleIndex.COLUMN_ARTICLE_NAME)) {
                        articleVO.setArticleName(bestFragment);
                    }
                    if (field.equals(IArticleIndex.COLUMN_CONTENT)) {
                        articleVO.setSummary(bestFragment);
                    }
                }
            }
            list.add(articleVO);
        }
    }
    

    我是一零贰肆,一个关注Java技术和记录生活的博主。

    欢迎扫码关注“一零贰肆”的公众号,一起学习,共同进步,多看路,少踩坑。

  • 相关阅读:
    redis深度历险 2 - Redis的基本数据类型以及使用场景
    Python---死循环概念---while True
    微软拆分 VS Code 中 Python 扩展,部分功能可独立下载
    Code For Better 谷歌开发者之声——开发者必备神器
    IDEA自定义Servlet、Filter、Listener模板
    贝叶斯推理
    vr火灾逃生安全科普软件开展消防突击教育安全有效
    如何加速JavaScript 代码运行速度
    【Lilishop商城】No2-1.确定项目结构和数据结构(用户、商品、订单、促销等模块)
    营销互动类小游戏策划与开发
  • 原文地址:https://www.cnblogs.com/sun2020/p/18067127