Redis还特别适用于解决基于搜索的问题,这类问题通常需要使用集合以及有序集合的交集、并集、和差集操作查找符合指定要求的元素。本章就来学习下,如何使用Redis进行搜索。假设一个对文档搜索的场景,如何使用Redis更快地进行搜索。基于搜索的应用程序相关代码位于 项目 的search模块中。
为了获得比扫描文档更快的搜索速度,我们需要对文档进行预处理,这个预处理步骤通常被称为建索引(indexing),而我们要创建的结构则被成为反向索引(inverted indexes)。选择使用Redis来创建反向索引的原因在于,Redis自带的集合和有序集合都非常使用于处理反向索引。
具体来说,反向索引会从每个被索引的文档里面抽取一些单词,并创建表格来记录每篇文章都包含了哪些单词。假设有两个文档,docA只包含标题《lord of the rings》,docB只包含标题《lord of the dance》。为这两个文档创建的反向索引如下:
key | set value |
---|---|
ind:lord |
docA docB |
ind:of |
docA docB |
ind:the |
docA docB |
ind:rings | docA |
ind:dance | docB |
在知道了索引操作的最终执行结构就是产生一些Reedis集合之后,我们接下来要了解的就是生成这些集合的具体方法。
为了给文档构建索引集合,首先需要对文档包含的单词进行处理。从文档里面提取单词的过程通常被称为语法分析(parsing)和标记化(tokenization),这个过程可以产生出一系列用于标识文档的标记(token),标记有时候又被称为单词(word)。为了演示简单,我们假设需要处理的文档只能由英文字母和单引号(‘)组成,并且每个字符至少有两个字符长。标记化的一个常见的步骤,就是移除内容中的非用词,非用词就是那些在文档中频繁出现但是却没有提供相应信息量的单词,对这些单词进行搜索将返回大量无用的结果。
// 索引文档 publicintindexDocument(String docid, String content){ //获取标记化并且去非用词后的单词 Set<String> words = tokenize(content); List<Object> results = stringRedisTemplate.execute(new SessionCallback<List<Object>>() { publicList<Object>execute(RedisOperations operations)throwsDataAccessException{ operations.multi(); for (String word : words) { operations.opsForSet().add("idx:" + word, docid); } return operations.exec(); } }); return results.size(); }
在索引里面查找一个单词是非常容易的,通过使用Redis集合操作以及一些辅助代码,可以对文档执行各种复杂的单词查询操作。
// 根据method,对集合进行交集、并集和差集计算并临时保存 privateStringsetCommon(String method,intttl, String key, String... otherKeys)throwsException{ // 组装其他set的key List<String> otherKeyList = new ArrayList<>(); for(String otherKey : otherKeys){ otherKeyList.add("idx:" + otherKey); } //生成临时标识符 String id = UUID.randomUUID().toString(); String destKey = "idx:" + id; //反射调用指定的方法 Method[] methods = stringRedisTemplate.opsForSet().getClass().getMethods(); for (Method m : methods){ if(m.getName().equals(method)){ Class[] parameterTypes = m.getParameterTypes(); if(parameterTypes[1].getName().equals("java.util.Collection")){ m.setAccessible(true); //反射调用方法 m.invoke(stringRedisTemplate.opsForSet(), "idx:" + key, otherKeyList, destKey); //30秒后过期 stringRedisTemplate.expire(destKey, ttl, TimeUnit.SECONDS); break; } } } return id; } // 集合求交集 publicStringintersect(String key,intttl, String... otherKeys)throwsException{ return setCommon("intersectAndStore", ttl, key, otherKeys); } // 集合求并集 publicStringunion(String key,intttl, String... otherKeys)throwsException{ return setCommon("unionAndStore", ttl, key, otherKeys); } // 集合求差集 publicStringdifference(String key,intttl, String... otherKeys)throwsException{ return setCommon("differenceAndStore", ttl, key, otherKeys); }
到目前为止,我们已经具备了建立索引和进行搜搜所需要的绝大部分工具,包括标记化函数、索引函数以及基本的交集、并集和差集计算函数。现在缺少的就是一个将文本查询语句转换成搜索操作的函数。为此,我们将实现一个搜索函数,它可以查找那些包含了所有给定单词的文档,并允许我们指定同义词以及不需要的单词。假设有这样一个查询字符串”content indexed +indexing -another”,我们要根据前缀做出不同的查询动作。不加前缀表示单纯的查找含有该单词的文档;’+’前缀表示和前面一个单词是同义词,需要求并集;”-“前缀表示需要查询不包含该单词的文档。
// 对查询字符串进行语法分析 publicQueryparse(String queryString){ Query query = new Query(); //查询单词集合 Set<String> current = new HashSet<>(); Matcher matcher = QUERY_RE.matcher(queryString.toLowerCase()); while (matcher.find()){ String word = matcher.group().trim(); //获取前缀,如果有则去掉 char prefix = word.charAt(0); if (prefix == '+' || prefix == '-') { word = word.substring(1); } //验证单词合法性 if (word.length() < 2 || STOP_WORDS.contains(word)) { continue; } //若前缀为'-',表示想查询不包含该单词的文档 if (prefix == '-') { query.unwanted.add(word); continue; } /** * 如果同义词集合非空,并且单词不带'+'前缀 * 创建查询单词集合 */ if (!current.isEmpty() && prefix != '+') { query.all.add(new ArrayList<>(current)); current.clear(); } current.add(word); } if (!current.isEmpty()){ query.all.add(new ArrayList<>(current)); } return query; } //语法分析并查询 publicStringparseAndSearch(String queryString,intttl)throwsException{ Query query = parse(queryString); if (query.all.isEmpty()){ return ""; } List<String> toIntersect = new ArrayList<>(); for (List<String> syn : query.all) { if(syn.size() > 1){ //如果查询单词列表有多个,则先求并集 String key = syn.get(0); syn.remove(0); String[] otherKeys = new String[syn.size()]; toIntersect.add(union(key, ttl, tsyn.toArray(otherKeys))); }else { //如果查询单词列表只包含一个,则直接使用 toIntersect.add(syn.get(0)); } } //交集计算结果 String intersectResult; if (toIntersect.size() > 1) { //求交集 String key = toIntersect.get(0); toIntersect.remove(0); String[] otherKeys = new String[toIntersect.size()]; intersectResult = intersect(key, ttl, toIntersect.toArray(otherKeys)); }else { intersectResult = toIntersect.get(0); } //求差集 if (!query.unwanted.isEmpty()) { intersectResult = difference(intersectResult, ttl, query.unwanted.toArray(new String[query.unwanted.size()])); } return intersectResult; }
虽然我们已经可以根据给定的单词对索引内的文档进行搜索,但这只是我们检索所需要信息的第一步。搜索程序取得多个文档后,通常还需要根据每个文档的重要性对它们进行排序。之前介绍过Redis的SORT命令可以对列表、集合或有序结合存储的内容进行排序。假设现在把文档的相关信息存储在一个散列中,数据格式如下:
如果要测试的话,这些信息自行加到redis中,下面重新定义一个含有排序功能的搜索函数。
publicSearchResultsearchAndSort(String queryString, String sort)throwsException{ //判断是否降序(默认升序) boolean desc = sort.startsWith("-"); if (desc){ sort = sort.substring(1); } //如果不是以时间或者id排序,则根据字母表顺序对元素进行排序 boolean alpha = !"updated".equals(sort) && !"id".equals(sort); //定义排序权重 String by = "kb:doc*->" + sort; //获取搜索后的缓存记录 String id = parseAndSearch(queryString, 300); List<Object> results = stringRedisTemplate.execute(new SessionCallback<List<Object>>() { publicList<Object>execute(RedisOperations operations)throwsDataAccessException{ operations.multi(); //获取搜索结果的数量 operations.opsForSet().size("idx:" + id); //构造排序参数 SortQuery<String> sortQuery = SortQueryBuilder.sort("idx:" + id) .by(by) .limit(0, 20) .alphabetical(alpha) .order(desc ? SortParameters.Order.DESC : SortParameters.Order.ASC).build(); operations.sort(sortQuery); return operations.exec(); } }); return new SearchResult( id, ((Long)results.get(0)).longValue(), (List<String>)results.get(1)); }