实现简单搜索功能

备注

本人想要实现一个搜索功能,但没在网上找到满意的,就通过跟ai的不断拉扯和自己对ai的代码的修改和补充完善来完成的,可以满足基本的搜索功能,但还是有很多不足,如果有大佬觉得哪里不足请指点一下!

技术:倒排索引+分词+redis

语言:Java

实现步骤:

一、实现分词器

  1. 添加依赖(使用IKAnalyzer,支持中英文分词)

    <!-- 分词器:IKAnalyzer 支持中英文 -->
    <dependency>
        <groupId>com.janeluo</groupId>
        <artifactId>ikanalyzer</artifactId>
        <version>2012_u6</version>
    </dependency>
    
  2. 构建分词工具

    package com.nowcoder.www.ultis;
    
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    import org.wltea.analyzer.core.IKSegmenter;
    import org.wltea.analyzer.core.Lexeme;
    
    import java.io.StringReader;
    import java.util.ArrayList;
    import java.util.List;
    
    /**
     * @author Qianle
     */
    @Slf4j
    @Component
    public class WordSegmenter {
        public static List<String> segment(String text){
            //分词结果列表
            List<String> result = new ArrayList<>();
            //排除text不存在或者是空值的情况
            if(text == null || text.trim().isEmpty()){
                return result;
            }
            //调用分词器对文本进行分词
            try (StringReader stringReader = new StringReader(text)) {
                //true表示使用智能分词,false表示使用最细粒度分词
                IKSegmenter ikSegmenter = new IKSegmenter(stringReader,true);
                //分词过程中提取出来的最小词元单元
                Lexeme lexeme;
                while((lexeme = ikSegmenter.next()) != null){
                    //将词元加入分词集合
                    result.add(lexeme.getLexemeText());
                }
            }catch (Exception e){
                log.info(e.getMessage());
                throw new RuntimeException("分词失败",e);
            }
            return result;
        }
    }
    
    说明:
    1. 这里没有自定义词典,需要的可以看一下开源网站:GitHub - renfei/ik-analyzer: IK Analyzer是一个开源的,基于java语言开发的轻量级的中文分词工具包
    2. 支持两种分词模式:细粒度分词(最细粒度切分)和智能分词(最大词长切分,即最大分词)。细粒度分词模式会将文本切分成尽可能小的词汇单元,而智能分词模式则会进行简单的歧义排除处理和数量词合并输出(如将 “张三说的确实在理” 智能分词为 “张三 | 说的 | 确实 | 在理”,而细粒度分词结果则为 “张三 | 三 | 说的 | 的确 | 的 | 确实 | 实在 | 在理”)

二、返回结果实体类(支持个性化定制)

package com.nowcoder.www.dto;


import lombok.Data;

import java.time.LocalDateTime;

/**
 * @author Qianle
 */
@Data
public class SearchResultDto {
    /**
     * 文章id
     */
    private Long id;

    /**
     * 文章标题
     */
    private String title;

    /**
     * 内容预览
     */
    private String contentPreview;

    /**
     * 作者姓名
     */
    private String authorName;

    /**
     * 发布时间
     */
    private LocalDateTime createTime;

}

三、实现倒排索引

package com.nowcoder.www.ultis;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.ReactiveStringCommands;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * @author Qianle
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class InvertedIndexUtil {
    public final StringRedisTemplate stringRedisTemplate;
    /**
     * 索引前缀
     */
    public static final String INDEX_PREFIX = "search:index:";

    /**
     * 文章ID集合前缀
     **/
    public static final String DISCUSS_POST_IDS_PREFIX = "search:discuss_post:ids:";

    /**
     * 索引过期时间
     */
    public static final long INDEX_EXPIRE_DAYS = 7;

    public void createIndex(Long discussPostId, String title, String content) {
        //对标题和内容进行分词
        List<String> titleWords = WordSegmenter.segment(title);
        List<String> contentWords = WordSegmenter.segment(content);
        //为标题词赋予更高权重(3),内容词权重为(1)
        for (String word : titleWords) {
            addWordToIndex(word, discussPostId, 3.0);
        }
        for (String word : contentWords) {
            addWordToIndex(word, discussPostId, 1.0);
        }

        //记录文章包含的关键词,用于删除索引,正排索引,id -> word
        String discussPostIdsKey = DISCUSS_POST_IDS_PREFIX + discussPostId;
        titleWords.forEach(word -> stringRedisTemplate.opsForSet().add(discussPostIdsKey, word));
        contentWords.forEach(word -> stringRedisTemplate.opsForSet().add(discussPostIdsKey, word));
        stringRedisTemplate.expire(discussPostIdsKey, INDEX_EXPIRE_DAYS, TimeUnit.DAYS);
    }

    /**
     * 将词添加到索引
     */
    private void addWordToIndex(String word, Long discussPostId, Double score) {
        String key = INDEX_PREFIX + word;
        //使用zset存储,score用于排序
        stringRedisTemplate.opsForZSet().incrementScore(key, discussPostId.toString(), score);
        stringRedisTemplate.expire(key, INDEX_EXPIRE_DAYS, TimeUnit.DAYS);
    }

    /**
     * 根据关键词删除索引
     **/
    public void deleteIndex(Long discussPostId) {
        String discussPostIdsIndex = DISCUSS_POST_IDS_PREFIX + discussPostId;
        Set<String> words = stringRedisTemplate.opsForSet().members(discussPostIdsIndex);
        if (words != null && !words.isEmpty()) {
            for (String word : words) {
                String key = INDEX_PREFIX + word;
                stringRedisTemplate.opsForZSet().remove(key, discussPostId.toString());
            }
        }
        stringRedisTemplate.delete(discussPostIdsIndex);
    }

    /**
     * 搜索关键词
     * keyword 关键词
     * page 页码
     * pageSize 页码大小
     *
     * @return 文章ID列表(按相关度排序)
     */
    public List<Long> search(String keyWord, Integer page, Integer pageSize) {
        //对搜索词进行分词
        List<String> words = WordSegmenter.segment(keyWord);
        if (words.isEmpty()) {
            return List.of();
        }
        //第一个词的搜索结果
        String firstKey = INDEX_PREFIX + words.getFirst();
        int start = (page - 1) * pageSize;
        int end = start + pageSize - 1;
        //如果只有一个词,直接查询
        if (words.size() == 1) {
            Set<String> discussPostIdStrs = stringRedisTemplate.opsForZSet().reverseRange(firstKey, start, end);
            return convertToLongList(discussPostIdStrs);
        }
        //多个词的情况,取交集并按权重求和
        List<String> otherKeys = words.subList(1, words.size()).stream().map(word -> INDEX_PREFIX + word).toList();
        //临时存储交集结果的键
        String tempKey = "search:temp:" + System.currentTimeMillis();
        //计算多个词的value的交集并求和分数
        stringRedisTemplate.opsForZSet().intersectAndStore(firstKey, otherKeys, tempKey);
        //获取结果
        Set<String> discussPostIdStrs = stringRedisTemplate.opsForZSet().reverseRange(tempKey, start, end);
        //如果没有交集,则求并集并返回。
        if(discussPostIdStrs.isEmpty()){
            //计算多个词的value的并集
            stringRedisTemplate.opsForZSet().unionAndStore(firstKey, otherKeys, tempKey);
            //获取结果
            discussPostIdStrs = stringRedisTemplate.opsForZSet().reverseRange(tempKey, start, end);
        }
        //删除临时键
        stringRedisTemplate.delete(tempKey);
        return convertToLongList(discussPostIdStrs);

    }

    private List<Long> convertToLongList(Set<String> stringSet) {
        if (stringSet == null || stringSet.isEmpty()) {
            return List.of();
        }

        return stringSet.stream().map(Long::valueOf).collect(Collectors.toList());
    }

	/** 管道方式*/
    public void createIndexWithPipeline(RedisConnection connection, Long id, String title, String content) {
        //对标题和内容进行分词
        List<String> titleWords = WordSegmenter.segment(title);
        List<String> contentWords = WordSegmenter.segment(content);
        //为标题词赋予更高权重(3),内容词权重为(1)
        for (String word : titleWords) {
            addWordToIndexWithPipeline(connection, word, id, 3.0);
        }
        for (String word : contentWords) {
            addWordToIndexWithPipeline(connection, word, id, 1.0);
        }

        //记录文章包含的关键词,用于删除索引,正排索引,id -> word
        String discussPostIdsKey = DISCUSS_POST_IDS_PREFIX + id;

        for (String word : titleWords) {
            connection.setCommands().sAdd(discussPostIdsKey.getBytes(), word.getBytes());
        }
        for (String word : contentWords) {
            connection.setCommands().sAdd(discussPostIdsKey.getBytes(), word.getBytes());
        }
        stringRedisTemplate.expire(discussPostIdsKey, INDEX_EXPIRE_DAYS, TimeUnit.DAYS);
    }

    private void addWordToIndexWithPipeline(RedisConnection connection, String word, Long id, double score) {
        String key = INDEX_PREFIX + word;
        connection.zAdd(key.getBytes(), score, id.toString().getBytes());
    }


}
说明:
  1. 倒排索引:关键词 - > 包含该关键词的所有文章id (使用redis的ZSet来存储,将分词所在的不同位置给予不同的权重,如分词在标题,权重为3.0,分词在文章内容,权重为1.0,若两个位置都出现,则叠加权重。表明这个id的文章的相关性更强)
  2. 正排索引:id -> 该文章所有的关键词 (使用redis的set来存储,用于后面对文章的crud时对索引的添加和删除)
  3. 注意:代码后面提供了根据redis管道创建索引和添加索引的方法,用于加快索引的初始化速度,后面会提到。
  4. search功能实现:若只有一个分词,则直接查询该分词对应的id;若有多个分词,则要取分词对应的ids的交集,也就是所有分词都出现的文章id。(因为所有分词都出现的id更可能是用户想要的内容);若取不到交集,则退而求其次,取并集,将有关的内容都呈现出来。
  5. crud时对索引的操作:添加新的文章时,创建索引;删除文章时,删除索引;修改文章时,先删除索引,再创建新的索引。

四、实现搜索服务

package com.nowcoder.www.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.nowcoder.www.dto.SearchResultDto;
import com.nowcoder.www.entity.DiscussPost;
import com.nowcoder.www.mapper.UserMapper;
import com.nowcoder.www.service.DiscussPostsService;
import com.nowcoder.www.service.ISearchService;
import com.nowcoder.www.ultis.InvertedIndexUtil;
import com.nowcoder.www.ultis.RedisUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;


/**
 * @author Qianle
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class SearchServiceImpl implements ISearchService {
    private final InvertedIndexUtil invertedIndexUtil;
    private final DiscussPostsService discussPostsService;
    private final UserMapper userMapper;
    private final RedisUtil redisUtil;

    /**
     * 搜索文章
     */
    @Override
    public List<SearchResultDto> searchDiscussPosts(String keyWord, Integer page, Integer pageSize) {
        //1.从索引中获取匹配的文章id
        List<Long> discussPostIds = invertedIndexUtil.search(keyWord, page, pageSize);
        //防止集合为空集合
        if (discussPostIds.isEmpty()) {
            return List.of();
        }
        //2.根据id查询文章详情
        List<DiscussPost> discussPostList = discussPostsService.list(
                new QueryWrapper<DiscussPost>()
                        .in("id", discussPostIds)
                        .eq("status", 0)
        );
        //3.转换成dto并补充作者信息
        return discussPostList.stream().map(
                discussPost ->
                {
                    SearchResultDto searchResultDto = new SearchResultDto();
                    searchResultDto.setId(discussPost.getId());
                    searchResultDto.setTitle(discussPost.getTitle());
                    //截取部分内容作为预览
                    searchResultDto.setContentPreview(previewContent(discussPost.getContent()));
                    searchResultDto.setCreateTime(discussPost.getCreateTime());
                    //查询作者信息,获取作者姓名
                    searchResultDto.setAuthorName(userMapper.selectById(discussPost.getUserId()).getUsername());
                    return searchResultDto;
                }
        ).toList();
    }

    private String previewContent(String content) {
        if (content == null) {
            return "";
        }
        //去除html标签并截取前100个字符
        String plainContent = content.replaceAll("<[^>]*>}", "");
        return plainContent.length() > 100 ? plainContent.substring(0, 100) + "..." : plainContent;
    }
}
说明:

这里就比较容易,就是调用上面已经写好的倒排索引工具。补充一个知识点:

@RequiredArgsConstructor 注解是 Lombok 提供的一个实用注解,它的主要目的是自动生成包含所有 final 字段或 @NonNull 注解字段的构造方法,从而简化代码编写。Spring 支持通过构造方法注入依赖,@RequiredArgsConstructor 生成的构造方法恰好满足这一需求,使得这些依赖能够被 Spring 自动注入,无需额外添加 @Autowired 注解。简单来说,这个注解的核心价值就是用注解替代重复的构造方法代码,在不影响功能的前提下简化开发,尤其适合依赖较多的服务类。

五、初始化已有数据的索引

package com.nowcoder.www.runner;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nowcoder.www.entity.DiscussPost;
import com.nowcoder.www.service.DiscussPostsService;
import com.nowcoder.www.ultis.InvertedIndexUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * @author Qianle
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class IndexInitializerRunner implements CommandLineRunner {

    private final DiscussPostsService discussPostsService;
    private final InvertedIndexUtil invertedIndexUtil;
    private final StringRedisTemplate stringRedisTemplate;
    @Override
    public void run(String... args)  {
        //初始化已发布的文章的索引
        List<DiscussPost> publishedDiscussPostList = discussPostsService.list(
                new LambdaQueryWrapper<DiscussPost>().eq(DiscussPost::getStatus,0)
        );
        //没有需要初始化文章的处理
        if (publishedDiscussPostList.isEmpty()) {
            log.info("没有需要初始化索引的文章");
            return;
        }
        //创建索引
        stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for(DiscussPost discussPost : publishedDiscussPostList){
                // 通过管道执行索引创建操作
                invertedIndexUtil.createIndexWithPipeline(
                        connection,
                        discussPost.getId(),
                        discussPost.getTitle(),
                        discussPost.getContent()
                );
            }
            return null;
        });

        log.info("已初始化" + publishedDiscussPostList.size() + "条文章索引");

    }
}
说明:
  1. 实现CommandRunner,实现启动项目就运行创建索引功能。
  2. 最开始没有使用管道,初始化时间较长,后面去ai查询了一下说可以用管道,就拿ai的代码修改了一下,能跑,初始化速度也快了很多。
  3. 管道:通过减少客户端与redis的通信次数来实现降低往返延迟时间。
  4. stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {...}):使用stringRedisTemplateexecutePipelined方法,该方法会以管道的方式执行给定的RedisCallback中的逻辑。RedisCallback是一个接口,这里使用了Lambda表达式来实现其逻辑。connection是与Redis的连接对象。
  5. invertedIndexUtil.createIndexWithPipeline(connection, discussPost.getId(), discussPost.getTitle(), discussPost.getContent()):调用invertedIndexUtil工具类的createIndexWithPipeline方法,通过传入的connection连接对象以及DiscussPost对象的idtitlecontent来执行基于管道的索引创建操作。每个DiscussPost对象的相关信息都用于创建索引。具体代码在上面部分已经写了,注意使用connection提供的与redis进行操作的方法)
  6. return nullexecutePipelined方法的RedisCallback需要返回一个Object,这里返回null,因为此操作主要目的是通过管道执行一系列索引创建操作,并不需要返回特定的结果。

总体来说,这段代码的作用是通过Redis管道批量为publishedDiscussPostList中的每个DiscussPost对象创建索引。 例如,假设publishedDiscussPostList中有三个DiscussPost对象,那么这三个对象的idtitlecontent都会依次通过管道传递给createIndexWithPipeline方法来创建索引,而不是一次一次单独与Redis交互,从而提高效率。 (补充:可以自己设置一次批量处理的数据量的大小,由于我的数据量少就没做处理,想实现可以ai)

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐