实现一个全站搜索功能的那些事

663 阅读7分钟

本篇文章干货满满,先收藏再观看,避免以后找不到了。闲话不多说,先看效果。

效果演示

Screenshot_20240715_171839_com.dorachat.dorachat.jpg

Screenshot_20240715_171907_com.dorachat.dorachat.jpg

Screenshot_20240715_171913_com.dorachat.dorachat.jpg

话题引入

没错,今天我们的话题主要是围绕,如何搭建一个搜全平台的搜索引擎来展开的。本篇会讲解后端和客户端的完整解决方案。需要一定的知识储备,不是通向幼儿园的车。新入行的准程序员也可以看看思路。先来解释一下什么是搜索引擎,有了数据库为什么还需要这么个玩意儿?搜索引擎会在一个项目的服务器架构演化的过程中被采用。搜索引擎的功能之一就是为了分压,当然这前面会优先采用其他技术达到这一目的。分走数据库的访问压力,一般首先会考虑应用层的方案。比如Nginx反代的请求分发、熔断、降级等,这一部分是最基本的高并发保护措施。然后就是考虑Redis缓存,二八原则,因为大部分都是读的请求,小部分是写的请求。常用数据的访问就直接绕开数据库了,直接去读缓存就可以了。然后数据量不断增长后,海量数据的检索就会大幅消耗数据库连接池的性能,这个时候你不能老是熔断、降级,一直给用户返回服务器繁忙,请稍候重试,那用户还玩个锤子。这个时候搜索引擎就派上用场了,直接开搜索引擎集群了。

代码实现

Android端

搜索框的实现很简单,Android的基础知识。回车键功能改成搜索,只需要给EditText添加以下属性即可。

android:imeOptions="actionSearch"

点一下输入法的回车,也就是搜索按钮,执行以下代码调用搜索。

binding.etSearchInput.setOnEditorActionListener(TextView.OnEditorActionListener { _, actionId, _ ->
    if (actionId == EditorInfo.IME_ACTION_SEARCH) {
        // 隐藏键盘
        (getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
            .hideSoftInputFromWindow(
                currentFocus?.windowToken,
                InputMethodManager.HIDE_NOT_ALWAYS
            )
        val keyword = ViewUtils.getText(binding.etSearchInput)
        search(keyword)
        return@OnEditorActionListener true
    }
    false
})

搜索一般来说服务端会给我们包装Solr的API,我们不会直接调用Solr的API,但考虑到直观,我还是决定给你们看一下Solr的搜索界面。

截屏2024-07-15 19.01.20.png Retrofit的实现,最少传一个q的参数。

@GET("select")
fun select(@Query("q") q: String) : Call<SolrResult<DoraSearchResult>>

*:*代表不添加搜索条件,搜索所有数据,但Solr默认给我们展示第一页的数据。这里冒号左边可以设置要搜索的属性,比如传参title:${keyword} description:${keyword},Solr会帮我们在title和description属性中匹配关键词,可以指定n多个属性。它的这个搜索不简简单单是对keyword整体的匹配。会先进行分词,再进行索引,这个我们放到后端讲。

服务端

Solr下载安装

下载地址:lucene.apache.org/solr

我这里也有旧版本sdk的备份。

wget https://dorachat-sdk.oss-cn-hongkong.aliyuncs.com/solr-8.11.3.tgz

解压

tar zxvf solr-8.11.3.tgz

修改SOLR_ULIMIT_CHECKS=false

vim solr.in.sh
启动Solr
./solr start -force

默认端口为8983

创建Solr核心
./solr create -c dorachat
查看Solr核心状态
./solr status
索引和搜索数据
步骤一:准备数据

在开始索引和搜索数据之前,你需要准备一些数据。

创建一个名为 data.json 的 JSON 文件,并填充一些示例数据:

{
    "title": "剪映技巧教程",
    "description": "第1集 认识剪映界面",
    "content": "https://doravideo.oss-ap-southeast-7.aliyuncs.com/%E5%89%AA%E6%98%A0%E6%8A%80%E5%B7%A7%E6%95%99%E7%A8%8B/01.%E7%AC%AC1%E9%9B%86%20%E8%AE%A4%E8%AF%86%E5%89%AA%E6%98%A0%E7%95%8C%E9%9D%A2.mp4",
    "category": "video",
    "downloadPoints": "0"
},
{
    "title": "剪映技巧教程",
    "description": "第2集 调整素材时长",
    "content": "https://doravideo.oss-ap-southeast-7.aliyuncs.com/%E5%89%AA%E6%98%A0%E6%8A%80%E5%B7%A7%E6%95%99%E7%A8%8B/02.%E7%AC%AC2%E9%9B%86%20%E8%B0%83%E6%95%B4%E7%B4%A0%E6%9D%90%E6%97%B6%E9%95%BF.mp4",
    "category": "video",
    "downloadPoints": "0"
},
{
    "title": "剪映技巧教程",
    "description": "第3集 多轨道逻辑介绍",
    "content": "https://doravideo.oss-ap-southeast-7.aliyuncs.com/%E5%89%AA%E6%98%A0%E6%8A%80%E5%B7%A7%E6%95%99%E7%A8%8B/03.%E7%AC%AC3%E9%9B%86%20%E5%A4%9A%E8%BD%A8%E9%81%93%E9%80%BB%E8%BE%91%E4%BB%8B%E7%BB%8D.mp4",
    "category": "video",
    "downloadPoints": "0"
}
步骤二:索引数据

使用以下命令将数据索引到 Solr 核心中:

 ./solr index -c dorachat -d data.json
步骤三:搜索数据

使用以下命令在 Solr 核心中搜索数据:

./solr search -c dorachat -q "剪映"

这将在 dorachat 核心中搜索包含关键字 "剪映" 的数据,并返回相应的结果。

配置 Solr

Solr 提供了丰富的配置选项,可以根据需求进行自定义和优化。

步骤一:配置文件

Solr 的配置文件位于 Solr 安装目录的 server/solr/configsets 目录中。你可以根据需要修改配置文件来调整 Solr 的行为。

步骤二:Schema 配置

Schema 是 Solr 中定义字段和字段类型的配置文件。你可以在 Schema 中定义字段的类型、索引属性和分析器等。

在 Solr 核心的配置文件中,可以找到 managed-schema 文件来编辑 Schema 配置。

<field name="title"type="text_zh_CN"indexed="true"stored="false"multiValued="true"/>
<field name="description"type="text_zh_CN"indexed="true"stored="false"multiValued="true"/>
<field name="content"type="text_zh_CN"indexed="true"stored="false"multiValued="true"/>
<field name="category"type="text_zh_CN"indexed="true"stored="false"multiValued="true"/>
<field name="downloadPoints"type="plong"indexed="true"stored="false"multiValued="true"/>
步骤三:重启 Solr 服务

在修改了配置文件后,你需要重启 Solr 服务使更改生效。使用以下命令来重启 Solr 服务:

./solr restart
中文分词器

代码:

package com.dorachat.lucene.analyzer.ik;

import java.util.Map;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.util.TokenizerFactory;
import org.apache.lucene.util.AttributeFactory;

public class IKTokenizer4Lucene7Factory extends TokenizerFactory {
    
    private boolean useSmart = false;

    public IKTokenizer4Lucene7Factory(Map<String, String> args) {
        super(args);
        String useSmartParm = (String)args.get("useSmart");
        if ("true".equalsIgnoreCase(useSmartParm)) this.useSmart = true;
    }

    public Tokenizer create(AttributeFactory factory) {
        return new IKTokenizer4Lucene7(this.useSmart);
    }
}
package com.dorachat.lucene.analyzer.ik;

import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
import org.apache.lucene.analysis.tokenattributes.TypeAttribute;
import org.wltea.analyzer.core.IKSegmenter;
import org.wltea.analyzer.core.Lexeme;
import java.io.IOException;

public class IKTokenizer4Lucene7 extends Tokenizer {
    
    // IK分词器实现
    private IKSegmenter _IKImplement;
    
    // 词元文本属性
    private final CharTermAttribute termAtt;
    // 词元位移属性
    private final OffsetAttribute offsetAtt;
    // 词元分类属性(该属性分类参考org.wltea.analyzer.core.Lexeme中的分类常量)
    private final TypeAttribute typeAtt;
    // 记录最后一个词元的结束位置
    private int endPosition;

    public IKTokenizer4Lucene7(boolean useSmart) {
        super();
        offsetAtt = addAttribute(OffsetAttribute.class);
        termAtt = addAttribute(CharTermAttribute.class);
        typeAtt = addAttribute(TypeAttribute.class);
        _IKImplement = new IKSegmenter(input, useSmart);
    }

    /*
     * (non-Javadoc)
     *
     * @see org.apache.lucene.analysis.TokenStream#incrementToken()
     */
    @Override
    public boolean incrementToken() throws IOException {
        // 清除所有的词元属性
        clearAttributes();
        Lexeme nextLexeme = _IKImplement.next();
        if (nextLexeme != null) {
            // 将Lexeme转成Attributes
            // 设置词元文本
            termAtt.append(nextLexeme.getLexemeText());
            // 设置词元长度
            termAtt.setLength(nextLexeme.getLength());
            // 设置词元位移
            offsetAtt.setOffset(nextLexeme.getBeginPosition(),
                    nextLexeme.getEndPosition());
            // 记录分词的最后位置
            endPosition = nextLexeme.getEndPosition();
            // 记录词元分类
            typeAtt.setType(nextLexeme.getLexemeTypeString());
            // 返会true告知还有下个词元
            return true;
        }
        // 返会false告知词元输出完毕
        return false;
    }

    /*
     * (non-Javadoc)
     *
     * @see org.apache.lucene.analysis.Tokenizer#reset(java.io.Reader)
     */
    @Override
    public void reset() throws IOException {
        super.reset();
        _IKImplement.reset(input);
    }
    
    @Override
    public final void end() {
        // set final offset
        int finalOffset = correctOffset(this.endPosition);
        offsetAtt.setOffset(finalOffset, finalOffset);
    }
}
package com.dorachat.lucene.analyzer.ik;

import org.apache.lucene.analysis.Analyzer;

public class IKAnalyzer4Lucene7 extends Analyzer {

    private boolean useSmart = false;

    public IKAnalyzer4Lucene7() {
        this(false);
    }

    public IKAnalyzer4Lucene7(boolean useSmart) {
        super();
        this.useSmart = useSmart;
    }

    public boolean isUseSmart() {
        return useSmart;
    }

    public void setUseSmart(boolean useSmart) {
        this.useSmart = useSmart;
    }

    @Override
    protected TokenStreamComponents createComponents(String fieldName) {
        IKTokenizer4Lucene7 tk = new IKTokenizer4Lucene7(this.useSmart);
        return new TokenStreamComponents(tk);
    }
}

以上代码依赖jar包dorachat-sdk.oss-cn-hongkong.aliyuncs.com/ikanalyzer-…。 将以上代码打成jar包,并将这两个jar包放到/solr-8.11.3/server/solr-webapp/webapp/WEB-INF/lib目录下,修改managed-schema,然后将属性类型配置为text_zh_CN。

  <fieldType name="text_zh_CN" class="solr.TextField">
      <analyzer>
          <tokenizer class="com.dorachat.lucene.analyzer.ik.IKTokenizer4Lucene7Factory" useSmart="true" />
      </analyzer>
  </fieldType>

最后重启Solr服务。

解释

解释就是掩饰,掩饰就是事实,是吧?Solr底层采用的是lucene的分词索引基础类库。要使用,首先需要创建一个核心,并导入一些数据。数据导入方式可以手动,也可以自动。自动的话是可以从数据库中导入。由于Solr天然并不支持中文的分词器,所以我们需要添加一个中文的分词器,来支持带有中文数据的属性的分词。

截屏2024-07-15 19.31.43.png

在conf目录编辑一些txt文件。 截屏2024-07-15 19.37.37.png

protwords.txt 分词库

synonyms.txt 同义词

stopwords.txt 停用词

分词库等中的词一行一个。注意了,中华文化博大精深。你断句没断好,可能意思就会千差万别、截然不同了。比如“叫破喉咙都没有人来救你”,你可以把“叫破、喉咙、没有”配置到词库,千万不要把“破喉咙”当成一个词配置进去。另外词语可能还包含多种含义,“花瓶、笔记本、绿茶、大饼、打飞机、炒冷饭”。 在同义词中配置:
下降=>下跌或者设置为下降,下跌。Solr在检索的时候就会给你转换,然后匹配。停用词很好理解,一般都是一些敏感词汇,Solr会直接过滤掉。

总结

本篇主要讲述了Solr搜索引擎的前后端基本使用,我们搜索的时候通常还会分类进行搜索,比如综合、热门、文件、文章等,这个还会牵扯到自定义搜索结果的权重算法,有兴趣的JY可以自行深入研究。