Elasticsearch从入门到进阶——搜索引擎原理
目录
1 正排索引
2 倒排索引
3 搜索引擎完整流程
4 Java实现搜索引擎
4.1 定义数据结构
4.1.1 正排索引
4.1.2 倒排索引
4.1.3 权重类
4.1.4 文档类
4.2 索引制作
4.2.1 文档遍历
4.2.2 多线程索引制作
4.3 搜索流程
Elasticsearch是一个优秀的分布式搜索引擎组件,要了解其核心原理,就需要了解什么是搜索引擎、分布式环境下搜索引擎需要考虑的因素。这里,先来讲讲什么是搜索引擎:
1 正排索引
正排索引:简而言之就是文档id映射文档内容,即key(索引)是文档的唯一标识符,通常是文档id;value是文档内容。
当知道文档id,就可以根据id快速找到文档的内容。比如如下的结构即为正排索引:
| 文档id | 文档内容 |
| 1 | 我爱吃包子 |
| 2 | 我喜欢小狗 |
| 3 | 我爱吃牛肉 |
这种索引方式在大规模的数据下,如果想要搜索某些文档的内容,就需要遍历整个正排索引的数据结构,效率比较低,于是引入倒排索引来解决搜索问题:
2 倒排索引
倒排索引:就是把正排索引的映射关系颠倒,由文档内容映射文档id,只不过这里的内容不是完整的文档内容,而是文档包含的词。即倒排索引以文档中的词作为key(索引),以文档id作为value。
因此,依据概念,倒排索引最重要的两个部分是词典(词的集合)和倒排映射关系(倒排表)。而词典通常由分词器制作,其中的每个词都是倒排表的key;倒排表就是倒排索引的数据结构,每个key(词)对应包含该词的文档id列表。比如如下的结构即为倒排索引(将上述正排索引案例转化为倒排索引):
| 词 | 文档id列表 |
| 我 | [1,2,3] |
| 爱 | [1,3] |
| 吃 | [1,3] |
| 包子 | [1] |
| 喜欢 | [2] |
| 小狗 | [2] |
| 牛肉 | [3] |
倒排索引以关键词为索引key,当用户输入某些关键词进行查询时,就可以快速定位到包含该关键词的文档id列表,再根据文档id列表从正排索引获取文档内容,从而将相关文档返回给用户。
3 搜索引擎完整流程

(1)实际过程中,搜索结果会按文档和搜索词的相关性从高到低排序,因此倒排索引的value除了文档id,还应该有该词在该文档的相关性权重。相关性权重通常由机器学习、深度学习算法训练得到,这里不再赘述。
(2)而搜索词分词后有多个,可能多个词都会在同一个文档中存在,因此最后可能找到多个相同的文档,就需要对这些相同的文档进行合并,主要是合并文档的与词的相关性。
(3)为了保证索引不丢失,需要将索引持久化的磁盘。但是由于搜索引擎需要近乎实时的查询效率,因此搜索引擎启动时就要将索引需要从磁盘加载到内存中,内存的I/O速率远大于磁盘的I/O速率。
(4)索引制作是一个CPU密集型任务,而搜索引擎的业务是I/O密集型任务,因此索引制作过程往往不能影响搜索引擎的业务进行,通常把索引制作任务定义为定时任务,夜间流量低峰期定期执行。
4 Java实现搜索引擎
这里通过Java实现一个简单的搜索引擎:
4.1 定义数据结构
4.1.1 正排索引
private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
正排索引采用ArrayList作为数据结构,下标作为索引键,元素就是文档类。
4.1.2 倒排索引
private HashMap<String,ArrayList<Weight>> invertedIndex = new HashMap<>();
倒排索引采用HashMap作为数据结构,key就是词,value是{文档id、词的权重}的ArrayList。
4.1.3 权重类
//这个类把文档ID和文档与词的相关性的权重进行封装
public class Weight {private int docId;//weight表示文档与词的相关性,越大越相关private int weight;public int getDocId() {return docId;}public void setDocId(int docId) {this.docId = docId;}public int getWeight() {return weight;}public void setWeight(int weight) {this.weight = weight;}
}
权重类由docId属性和weight属性组成,docId表示文档id,weight表示词与文档的相关性,也就是权重,相关性越大值越大。
4.1.4 文档类
public class DocInfo {private int docId;private String title;private String url;private String content;public int getDocId() {return docId;}public void setDocId(int docId) {this.docId = docId;}public String getTitle() {return title;}public void setTitle(String title) {this.title = title;}public String getUrl() {return url;}public void setUrl(String url) {this.url = url;}public String getContent() {return content;}public void setContent(String content) {this.content = content;}
}
文档类时文档实体的类型,具体可根据要搜索的文档结构来进行修改,这里模拟的是浏览器页面的文档,搜索引擎返回的文档由url(链接,点击可跳转到页面)、title(页面标题)、content(页面内容的摘要)组成。
4.2 索引制作
4.2.1 文档遍历
提前通过爬虫或其它技术手段收集到页面文档,将其保存在本地目录下,然后对该目录进行遍历:
//枚举路径下的所有html文档//inputPath表示从该路径开始递归枚举//fileList存储递归结果private void enumFile(String inputPath, ArrayList<File> fileList) {File rootPath = new File(inputPath);//listFiles()能过获取到当前目录下包含的文件/目录(只能获取一级目录看不到子目录)File[] files = rootPath.listFiles();for (File f:files) {//根据f的类型来决定是否递归//f是普通文件就把f加入到fileList中//f是子目录就递归调用enumFile()来进一步获取子目录的文件if(f.isDirectory()){enumFile(f.getAbsolutePath(),fileList);} else {//只添加html扩展名文件(.html在字符串结尾)if(f.getAbsolutePath().endsWith(".html")){fileList.add(f);}}}}
遍历的过程主要是递归,根据是否是目录还是文件来决定是继续递归还是找到文件进行结果集的添加。
4.2.2 多线程索引制作
(1)制作索引
private ObjectMapper objectMapper = new ObjectMapper();//使用数组下标表示docId(正排索引)private ArrayList<DocInfo> forwardIndex = new ArrayList<>();//使用哈希表表示倒排索引//key:词 value:与词关联的一组文章private HashMap<String,ArrayList<Weight>> invertedIndex = new HashMap<>();//新建两个锁对象private Object locker1 = new Object();private Object locker2 = new Object();//类提供的方法://1.给定一个docID,在正排索引中查询文档的详细信息public DocInfo getDocInfo(int docId){return forwardIndex.get(docId);}//2.给定一个词,在倒排索引中查询与词相关联的文档//词和文档是存在一定的相关性public List<Weight> getInverted(String term){return invertedIndex.get(term);}//3.往索引中新增一个文档public void addDoc(String title,String url,String content){//正排索引和倒排索引都需要添加//构建正排索引DocInfo docInfo = buildForward(title,url,content);//构建倒排索引buildInverted(docInfo);}private void buildInverted(DocInfo docInfo) {class WordCnt{public int titleCount;//这个词在标题中出现的次数;public int contentCount;//这个词在正文中出现的次数;}//这个数据结构用来统计词频HashMap<String,WordCnt> wordCntHashMap = new HashMap<>();//1.针对标题分词List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();//2.遍历分词结果,统计词频for(Term term : terms){//判定term是否存在,不存在创建键值对存入wordCunHashMap,titleCount设为1String word = term.getName();//Ansj分词库不区分大小写(搜索引擎也不区分大小写)WordCnt wordCnt = wordCntHashMap.get(word);if(wordCnt == null){WordCnt newWordCnt = new WordCnt();newWordCnt.titleCount = 1;newWordCnt.contentCount = 0;wordCntHashMap.put(word,newWordCnt);}else{//存在就找到之前的值++wordCnt.titleCount += 1;}}//3.针对正文分词terms = ToAnalysis.parse(docInfo.getContent()).getTerms();//4.遍历分词结果,统计词频for(Term term : terms){//判定term是否存在,不存在创建键值对存入wordCunHashMap,titleCount设为1String word = term.getName();//Ansj分词库不区分大小写(搜索引擎也不区分大小写)WordCnt wordCnt = wordCntHashMap.get(word);if(wordCnt == null){WordCnt newWordCnt = new WordCnt();newWordCnt.titleCount = 0;newWordCnt.contentCount = 1;wordCntHashMap.put(word,newWordCnt);}else{//存在就找到之前的值++wordCnt.contentCount += 1;}}//5.把上述结果汇总到一个HashMap中//6.遍历HashMap更新倒排索引for(Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){//先根据词在倒排索引中查一下synchronized (locker1){//倒排拉链List<Weight> invertedList = invertedIndex.get(entry.getKey());if(invertedList == null){//如果为空就插入新的键值对ArrayList<Weight> newInvertedList = new ArrayList<>();//把新文档docInfo构造成Weight对象插入Weight weight = new Weight();weight.setDocId(docInfo.getDocId());//权重=词在标题中出现的次数*10+词在正文中出现的次数weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);newInvertedList.add(weight);invertedIndex.put(entry.getKey(), newInvertedList);}else{//如果非空就把当前这个文档构造出Weight对象,插入到倒排拉链后面Weight weight = new Weight();weight.setDocId(docInfo.getDocId());//权重=词在标题中出现的次数*10+词在正文中出现的次数weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);invertedList.add(weight);}}}}private DocInfo buildForward(String title, String url, String content) {DocInfo docInfo = new DocInfo();//新增的文档在正排索引中下标==docId(添加到末尾即长度)docInfo.setTitle(title);docInfo.setUrl(url);docInfo.setContent(content);//新增的文档在正排索引中下标==docId(添加到末尾即长度)synchronized (locker2){docInfo.setDocId(forwardIndex.size());forwardIndex.add(docInfo);}return docInfo;}//4.把内存中的索引结构保存到磁盘中public void save(){//使用两个文件分别保存正排和倒排long beg = System.currentTimeMillis();System.out.println("保存索引开始!");//1.先判断索引对应的目录是否存在,不存在就创建File indexPathFile = new File(INDEX_PATH);if(!indexPathFile.exists()){indexPathFile.mkdirs();}File forwardIndexFile = new File(INDEX_PATH + "forward.txt");File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");//ObjectMapper类的writeValue()可以进行序列化try {objectMapper.writeValue(forwardIndexFile,forwardIndex);objectMapper.writeValue(invertedIndexFile,invertedIndex);} catch (IOException e) {e.printStackTrace();}long end = System.currentTimeMillis();System.out.println("保存索引完成! 消耗时间:" + (end - beg) + "ms");}//5.把磁盘中的索引结构加载到内存中public void load(){long beg = System.currentTimeMillis();System.out.println("加载索引开始!");//1.设置加载索引的路径File forwardIndexFile = new File(INDEX_PATH + "forward.txt");File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");//2.加载(反序列化)//readValue(1,2)参数1表示读取的文件,参数2表示要转换的类型//Jackson提供了辅助工具:泛型类TypeReference<转化类型的泛型参数>// readValue()的参数2需要提供的是实例化对象,就可以创建匿名内部类// 这个类实现了TypeReference并再创建这个匿名内部类的实例// (创建这个实例的目的就是为了把转化类型的信息(比如正排索引类型ArrayList<searcher.DocInfo>)告诉该方法)try {forwardIndex = objectMapper.readValue(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>() {});invertedIndex = objectMapper.readValue(invertedIndexFile, new TypeReference<HashMap<String, ArrayList<Weight>>>() {});} catch (IOException e) {e.printStackTrace();}long end = System.currentTimeMillis();System.out.println("加载索引完成! 消耗时间:" + (end - beg) + "ms");}
注意1:索引制作需要用到多线程,涉及到多个线程同时对同一个正排索引或倒排索引进行修改,因此需要synchronized锁来保证线程安全。
注意2:对于同一个文档实例,必须先构建正排索引,再构建倒排索引,顺序不能改变。因为构建倒排索引需要文档id,只有先构建正排索引,文档id才能存在。
注意3:需要用到ansj分词库,因此需要提前引入ansj的依赖和停词表(网上很多)。
<dependency><groupId>org.ansj</groupId><artifactId>ansj_seg</artifactId> </dependency>注意4:关于词与文档的权重,采用较简单的方式,即以词频作为权重,一个词在文档中出现的次数越多,就认为该词与文档越相关。本实现仅以计数方式统计词频,实际上还可采用TF-IDF算法(词频-逆文档频率)来统计词频:
表示词频(Term Frequency),即某个单词(num_i)在该文档(N)中出现的频率。
其中,num_text表示包含当前词的文档个数,N_text表示总文档个数。即IDF表示逆文档频率 (Inverse Document Frequency),用于衡量某个词在所有词库中的重要程度。分子加1的目的是:使IDF值始终大于等于零,分母加1的目的是:防止出现分母为零的情况。
因此有如下公式:
根据上述公式容易看出,当一个词在不同的文档中出现的次数很少,在某个文档中出现的频率很高时,TF-IDF值就会很大,这个词就具有很好的类别区分能力。因此该编码就会将所有样本中共有的出现数量多的词降低权重,而将每个样本特有的、更重要的词提高权重,从而使词与文档之间的相关性更具有区分度(更能根据用户搜索关键词找到特定的、更相关的文档)。
(2)多线程制作索引流程
//多线程制作索引public void runByThread() throws InterruptedException {long beg = System.currentTimeMillis();System.out.println("索引制作开始!");//整个Parser类的入口//1.根据路径枚举所有的文档(html),子目录也获取到System.out.println("枚举文件开始!");ArrayList<File> fileList = new ArrayList<>();enumFile(INPUT_PATH,fileList);long endEnumFile = System.currentTimeMillis();System.out.println("枚举文件结束! 消耗时间:" + (endEnumFile - beg) + "ms");//2.针对上面罗列出来的文件路径列表,打开文件,读取文件内容,并进行解析并构建索引//统计解析任务个数并传给CountDownLatch对象CountDownLatch latch = new CountDownLatch(fileList.size());//引入线程池,设置4线程ExecutorService executorService = Executors.newFixedThreadPool(4);//任务不断向线程池添加(不等线程上一个执行结束),线程不断执行for(File f:fileList){//通过这个方法解析单个html文件executorService.submit(new Runnable() {@Overridepublic void run() {System.out.println("开始解析:" + f.getAbsolutePath());parseHTML(f);//countDown()每完成一个解析任务调用一次该方法latch.countDown();}});}//await()会阻塞,只有所有的解析任务完成(都调用一次countDown(),次数等于构造CountDownLatch对象的传的参数),才能阻塞结束latch.await();//手动关闭线程(线程池创建的线程都是非守护线程,其状态会影响进程的结束)executorService.shutdown();long endFor = System.currentTimeMillis();System.out.println("解析文件完毕! 消耗时间:" + (endFor - endEnumFile) + "ms");//3.把内存中构造的索引数据结构保存到指定文件index.save();long end = System.currentTimeMillis();System.out.println("索引制作完毕! 总计消耗时间:" + (end - beg) + "ms");}//html文档解析方法private void parseHTML(File f) {//1.解析出html的标题String title = parseTitle(f);//2.解析出html的URLString url = parseUrl(f);//3.解析出html的正文(描述摘自正文)//String content = parseContent(f);//普通版本String content = parseContentByRegex(f);//正则优化版本//4.把解析的信息添加到索引中index.addDoc(title,url,content);}//解析html的正文public String parseContent(File f) {//遍历每一个字符,以 < 和 > 控制拷贝数据的开关//try()括号内的打开文件会在结束后自动关闭文件try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f),1024 * 1024)) {//是否拷贝开关boolean isCopy = true;//保存结果StringBuilder content = new StringBuilder();while(true){//此处read()返回值是int不是char(int作为返回值是为了表示一些非法情况)int ret = bufferedReader.read();if(ret == -1){//文件读完了break;}char c = (char) ret;if(isCopy){//开关打开,拷贝字符if(c == '<'){//关闭开关isCopy = false;continue;}if(c == '\n' || c == '\r'){//把所有的换行符替换成空格c = ' ';}//其他字符拷贝到最终的StringBuilder中content.append(c);}else{//开关关闭,不拷贝if(c == '>'){//关闭开关isCopy = true;}}}return content.toString();} catch (IOException e) {e.printStackTrace();}return "";}//使用正则前需要将所有的内容读到字符串private String readFile(File f){try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f),1024 * 1024)) {//保存结果StringBuilder content = new StringBuilder();while(true){//此处read()返回值是int不是char(int作为返回值是为了表示一些非法情况)int ret = bufferedReader.read();if(ret == -1){//文件读完了break;}char c = (char) ret;if(c == '\n' || c == '\r'){//把所有的换行符替换成空格c = ' ';}content.append(c);}return content.toString();} catch (IOException e) {e.printStackTrace();}return "";}//使用正则表达式解析html的正文,实现去标签以及去除scriptpublic String parseContentByRegex(File f){//1.先把整个文件读取到String对象中String content= readFile(f);//2.替换掉script标签content = content.replaceAll("<script.*?>(.*?)</script>"," ");//3.替换掉普通的html标签content = content.replaceAll("<.*?>"," ");//4.合并多个空格content = content.replaceAll("\\s+"," ");return content;}//解析html的urlprivate String parseUrl(File f) {//获取url的固定前缀String part1 = "https://docs.oracle.com/javase/8/docs/api/";//获取url后缀String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());//拼接return part1 + part2;}//解析html的标题private String parseTitle(File f) {String name = f.getName();return name.substring(0,name.length() - ".html".length());}
注意1:解析的文档是html文件,因此存在url、标题、正文摘要三部分。而正文需要将标签过滤掉,以<>(标签)为标志进行过滤。如果需要解析其它类型的文档,则需要根据文档结构来调整解析方法。
注意2:多线程采用线程池的方式创建,结合CountDownLatch类(计数)来为每个线程分配任务,每执行一个任务,计数器就+1。只有所有的任务都执行完毕,计数器的结果和任务数量一致,latch.await()的阻塞才会被唤醒。
注意3:为避免服务器故障造成内存中的索引丢失,制作的索引需要及时持久化的本地磁盘。
制作的正排索引示例如下:
{"docId": 0,"title": "compact1-frame","url": "https://docs.oracle.com/javase/8/docs/api/compact1-frame.html","content": " Overview List (Java Platform SE 8 ) Java™ Platform Standard Ed. 8 All Classes All Packages All Profiles compact1 Packages java.io java.lang java.lang.annotation java.lang.invoke java.lang.ref java.lang.reflect java.math java.net java.nio java.nio.channels java.nio.channels.spi java.nio.charset java.nio.charset.spi java.nio.file java.nio.file.attribute java.nio.file.spi java.security java.security.cert java.security.interfaces java.security.spec java.text java.text.spi java.time java.time.chrono java.time.format java.time.temporal java.time.zone java.util java.util.concurrent java.util.concurrent.atomic java.util.concurrent.locks java.util.function java.util.jar java.util.logging java.util.regex java.util.spi java.util.stream java.util.zip javax.crypto javax.crypto.interfaces javax.crypto.spec javax.net javax.net.ssl javax.script javax.security.auth javax.security.auth.callback javax.security.auth.login javax.security.auth.spi javax.security.auth.x500 javax.security.cert "
}
制作的倒排索引示例如下:
"undermining":
[{"docId": 3894,"weight": 2
}, {"docId": 3896,"weight": 2
}, {"docId": 3898,"weight": 2
}, {"docId": 3906,"weight": 2
}]
4.3 搜索流程
//加载停词表的数据结构private HashSet<String> stopWords = new HashSet<>();//查询需要索引对象的实例(索引也得加载好)private Index index = new Index();public DocSearcher(){index.load();loadStopWords();}//完成搜索过程//输入:用户查询词//输出:搜索结果的集合public List<Result> search(String query){//(1)分词:针对用户输入的查询词进行分词。List<Term> ordTerms = ToAnalysis.parse(query).getTerms();List<Term> terms = new ArrayList<>();//针对分词表使用停词表进行过滤for(Term term : ordTerms){if(stopWords.contains(term.getName())){continue;}terms.add(term);}//(2)触发:对每一个分词结果去倒排索引中查找相关性文档的集合(调用Index类的getInverted())。//使用二维数组表示每一个分词结果的相关性文档的集合List<List<Weight>> termResult = new ArrayList<>();for(Term term : terms){String word = term.getName();List<Weight> invertedList = index.getInverted(word);if(invertedList == null){//说明这个词在索引中不存在continue;}termResult.add(invertedList);}//使用多路归并将相关性文档进行合并去重//不同分词结果中相同的文档进行权重的合并List<Weight> allTermResult = mergeResult(termResult);//(3)排序:对步骤(2)的结果按照相关性降序排序。allTermResult.sort(new Comparator<Weight>() {@Overridepublic int compare(Weight o1, Weight o2) {return o2.getWeight() - o1.getWeight();}});//(4)包装结果:根据排序后的结果对每一个文档依次查询正排索引(调用Index类的getDocInfo()),获取每一个文档的信息,包装成一定结构的数据返回出去。List<Result> results = new ArrayList<>();for(Weight weight : allTermResult){DocInfo docInfo = index.getDocInfo(weight.getDocId());Result result = new Result();result.setTitle(docInfo.getTitle());result.setUrl(docInfo.getUrl());result.setDesc(GenDesc(docInfo.getContent(),terms));results.add(result);}return results;}//描述一个文档在二维数组(相关性文档集合)中的位置static class Pos{public int row;public int col;public Pos(int row, int col) {this.row = row;this.col = col;}}//多路归并合并触发结果private List<Weight> mergeResult(List<List<Weight>> source) {//1.对每一行按照docId进行升序排序for(List<Weight> curRow : source){curRow.sort(new Comparator<Weight>() {@Overridepublic int compare(Weight o1, Weight o2) {return o1.getDocId()-o2.getDocId();}});}//2.借助优先级队列(docId越小越优先)进行多路归并排序List<Weight> target = new ArrayList<>();//2.1创建优先级队列并指定比较规则PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {@Overridepublic int compare(Pos o1, Pos o2) {//根据pos值找到docId后根据docId进行排序return source.get(o1.row).get(o1.col).getDocId() - source.get(o2.row).get(o2.col).getDocId();}});//2.2初始化队列,把每一行第一个doc放入队列for(int row = 0;row < source.size();row++){queue.offer(new Pos(row,0));}//2.3循环取队首(当前所有行中最小的元素)while(!queue.isEmpty()){Pos minPos = queue.poll();Weight curWeight = source.get(minPos.row).get(minPos.col);//2.4判断当前最小元素是否和上一次元素重复,是就合并权重,不重复就插入末尾if(target.size() > 0){//取出上次插入的元素Weight lastWeight = target.get(target.size() - 1);if(lastWeight.getDocId() == curWeight.getDocId()){lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());}else{target.add(curWeight);}}else{target.add(curWeight);}//2.5将当前元素所在行的下一个元素入队Pos newPos = new Pos(minPos.row,minPos.col + 1);if(newPos.col >= source.get(newPos.row).size()){//如果这行已经取完,就不取元素continue;}queue.offer(newPos);}return target;}//根据分词结果和正文生成摘要private String GenDesc(String content, List<Term> terms) {//遍历分词结果,查找正文中包含的分词int firstPos = -1;for(Term term : terms){//分词库自动转小写,因此正文也得转小写再查询String word = term.getName();//此处需要进行"全词匹配",即word是一个独立的词,而不是某个词的一部分//将(word)类型转化成wordcontent = content.toLowerCase().replaceAll("\\b" + word + "\\b"," " + word + " ");firstPos = content.toLowerCase().indexOf(" " + word + " ");if(firstPos >= 0){//找到了位置break;}}if(firstPos == -1){//所有的分词都不在正文中出现//词在标题中存在在正文中不存在是极端情况//可以直接返回空描述或者取正文前160个字符return content.length() > 160 ? content.substring(0,160) + "..." : content;}//从firstPos开始向前取60个字符String desc = "";int descBeg = firstPos < 60 ? 0 : firstPos - 60;if(descBeg + 160 > content.length()){desc = content.substring(descBeg);}else{desc = content.substring(descBeg,descBeg + 160) + "...";}//对查询词进行加<i>标签操作:使用replace()for(Term term : terms){String word = term.getName();//加空格是为了实现全字匹配,(?i)表示不区分大小写desc = desc.replaceAll("(?i) " + word + " ","<i> " + word + " </i>");}return desc;}//加载停词表public void loadStopWords(){try (BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH))){while(true){String line = bufferedReader.readLine();if(line == null){//读取结束break;}stopWords.add(line);}}catch (IOException e){e.printStackTrace();}}
注意1:需要对用户的搜索词进行分词,把分词结果保存在一个ArrayList中,然后遍历每个词进行倒排和正排的查询。
注意2:由于可能多个词在同一个文档中均出现,因此查询的文档存在重复(文档id重复,词的权重可能不同),就需要对文档进行合并。采用多路归并法:每一路(source二维数组的每一行)表示一个词对应相关文档的id权重列表,首先将每一路按文档id从小到大排序。然后使用优先级队列(排序优先级就是文档id从小到大),循环从队首取文档判断与结果集最后一文档是否是同一个文档,如果是同一个文档就与结果集中最后一个文档合并权重;否则就是其它文档,就进行结果集添加,队列每判断完一个文档,就从文档对应的那一行获取下个文档入队,直到队列为空。
注意3:从多路归并得到的就是无重复的文档id权重列表,进行文档相关性的排序,越相关的排在前。根据排序后的文档id从正排索引中查找文档内容。
注意4:文档内容非常多,不适合全部展现在搜索结果页面,因此就根据关键词在文档的位置,从前从后读取部分内容,得到展示的摘要。
上述只展示了搜索引擎的核心实现,关于接口如何定义、如何调用、搜索引擎架构(单机、分布式)等部分这里不再赘述。


