Java:倒排索引+分词+redis实现springboot搜索功能
·
实现简单搜索功能
备注
本人想要实现一个搜索功能,但没在网上找到满意的,就通过跟ai的不断拉扯和自己对ai的代码的修改和补充完善来完成的,可以满足基本的搜索功能,但还是有很多不足,如果有大佬觉得哪里不足请指点一下!
技术:倒排索引+分词+redis
语言:Java
实现步骤:
一、实现分词器
-
添加依赖(使用IKAnalyzer,支持中英文分词)
<!-- 分词器:IKAnalyzer 支持中英文 --> <dependency> <groupId>com.janeluo</groupId> <artifactId>ikanalyzer</artifactId> <version>2012_u6</version> </dependency> -
构建分词工具
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; } }说明:
- 这里没有自定义词典,需要的可以看一下开源网站:GitHub - renfei/ik-analyzer: IK Analyzer是一个开源的,基于java语言开发的轻量级的中文分词工具包
- 支持两种分词模式:细粒度分词(最细粒度切分)和智能分词(最大词长切分,即最大分词)。细粒度分词模式会将文本切分成尽可能小的词汇单元,而智能分词模式则会进行简单的歧义排除处理和数量词合并输出(如将 “张三说的确实在理” 智能分词为 “张三 | 说的 | 确实 | 在理”,而细粒度分词结果则为 “张三 | 三 | 说的 | 的确 | 的 | 确实 | 实在 | 在理”)
二、返回结果实体类(支持个性化定制)
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());
}
}
说明:
- 倒排索引:关键词 - > 包含该关键词的所有文章id (使用redis的ZSet来存储,将分词所在的不同位置给予不同的权重,如分词在标题,权重为3.0,分词在文章内容,权重为1.0,若两个位置都出现,则叠加权重。表明这个id的文章的相关性更强)
- 正排索引:id -> 该文章所有的关键词 (使用redis的set来存储,用于后面对文章的crud时对索引的添加和删除)
- 注意:代码后面提供了根据redis管道创建索引和添加索引的方法,用于加快索引的初始化速度,后面会提到。
- search功能实现:若只有一个分词,则直接查询该分词对应的id;若有多个分词,则要取分词对应的ids的交集,也就是所有分词都出现的文章id。(因为所有分词都出现的id更可能是用户想要的内容);若取不到交集,则退而求其次,取并集,将有关的内容都呈现出来。
- 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() + "条文章索引");
}
}
说明:
- 实现CommandRunner,实现启动项目就运行创建索引功能。
- 最开始没有使用管道,初始化时间较长,后面去ai查询了一下说可以用管道,就拿ai的代码修改了一下,能跑,初始化速度也快了很多。
- 管道:通过减少客户端与redis的通信次数来实现降低往返延迟时间。
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {...}):使用stringRedisTemplate的executePipelined方法,该方法会以管道的方式执行给定的RedisCallback中的逻辑。RedisCallback是一个接口,这里使用了Lambda表达式来实现其逻辑。connection是与Redis的连接对象。invertedIndexUtil.createIndexWithPipeline(connection, discussPost.getId(), discussPost.getTitle(), discussPost.getContent()):调用invertedIndexUtil工具类的createIndexWithPipeline方法,通过传入的connection连接对象以及DiscussPost对象的id、title和content来执行基于管道的索引创建操作。每个DiscussPost对象的相关信息都用于创建索引。(具体代码在上面部分已经写了,注意使用connection提供的与redis进行操作的方法)return null:executePipelined方法的RedisCallback需要返回一个Object,这里返回null,因为此操作主要目的是通过管道执行一系列索引创建操作,并不需要返回特定的结果。
总体来说,这段代码的作用是通过Redis管道批量为publishedDiscussPostList中的每个DiscussPost对象创建索引。 例如,假设publishedDiscussPostList中有三个DiscussPost对象,那么这三个对象的id、title和content都会依次通过管道传递给createIndexWithPipeline方法来创建索引,而不是一次一次单独与Redis交互,从而提高效率。 (补充:可以自己设置一次批量处理的数据量的大小,由于我的数据量少就没做处理,想实现可以ai)
更多推荐


所有评论(0)