认识搜索引擎
像百度搜索这样,输入要搜索的内容,点击搜索,就会出现若干条结果,每条结果包含标题,内容描述,展示url,图片,图标等相关的内容。如下图输入"恰似故人归"点击百度一下,就会出现如下的页面。 搜索引擎的本质就是输入一个查询词,得到若干个搜索结果,其中包含了标题,描述,展示url和点击url。
搜索的核心思路
我们把每一个网页称为一个文档,每一次的搜索就是在所有文档中查找查询词,检查文档中是否包含要查询的词或字符串。但是细想这种方法直接暴力且开销巨大,随着文档的增多,搜索的时间也随之延长,而搜索引擎往往对效率要求非常高,试想如果你的每一次搜索都需要1分钟时间才会有相应的搜素结果你还会用这个搜索引擎吗? 因此我们想到倒排索引,这是一种专门针对搜素引擎场景而设计的数据结构。
倒排索引
文档:被检索的html页面 正排索引:一个文档中包含了那些词,描述一个文档的基本信息,将文档中的词进行分词处理并存储。 倒排索引:一个词被那些文档所引用,描述一个词的基本信息,存储了这个词都存在在那些文档中,这个词在文档中的重要程度。
项目介绍
下面我们对Java API文档实现一个搜索引擎。 首先我们需要在官方页面下载Java API文档到本地,此处附上下载地址:Java API 文档下载 主要实现下面三个模块:
- 索引模块:扫描下载的文档,分析数据内容构建正排索引和倒排索引,并保存到文件中。
- 搜索模块:加载索引,根据输入的查询词,基于正排+倒排索引进行检索得到检索结果。
- web模块:编写一个简单的页面,展示搜索结果。点击搜素结果跳转到对应的线上Java API文档页面
关于分词
上面我们提到的正排索引,倒排索引首先都需要对文档中的内容进行分词处理,下面我们使用现成的ansj分词库进行分词操作。 首先我们需要在所创建项目的pom.xml中插入如下代码。
<dependency>
<groupId>org.ansj</groupId>
<artifactId>ansj_seg</artifactId>
<version>5.1.6</version>
</dependency>
下面通过如下示例演示分词操作:
public class test1 {
public static void main(String[] args) {
String s="与君初相识恰是故人归";
List<Term> terms= ToAnalysis.parse(s).getTerms();
for (Term term:terms) {
System.out.print(term.getName()+" ");
}
}
}
输出:
实现索引模块
实现Parser类
- 根据指定路径枚举出路径中所有的文件,此过程要把所有子目录中的文件都获取到
- 针对获取到的文件路径,打开文件读取文件内容,并进行解析,构建索引
- 把在内存中构建好的索引数据结构保存的到指定的文件中
enumFile方法实现读文件
使用ArrayList< File > fileList用来存放所有文件的具体路径,封装enumFile方法递归读取文件,参数分别为读文件的根目录,存放所有文件路径的数组fileList。 首先将根目录的字符串转换为File类方便读取,通过file.ListFiles()方法获取当前目录下的文件,此方法获取到的是一个File类型的数组,通过遍历数组使用isDirectory判断其是否是目录,是目录就递归遍历下一次,否则就将其放入到fileList中
private void enumFile(String inputPath, ArrayList<File> result) {
File rootFile=new File(inputPath);
File[] files=rootFile.listFiles();
for (File f:files) {
if(f.isDirectory()) {
enumFile(f.getAbsolutePath(),result);
} else {
if(f.getAbsolutePath().endsWith(".html")) {
result.add(f);
}
}
}
}
parseHTML方法实现解析文件
分别使用parseTitle()方法解析标题,parseUrl()方法解析url,parseContent()方法解析正文
private void parseHTML(File file) {
String title=parseTitle(file);
String url=parseUrl(file);
String content=parseContent(file);
index.addDoc(title,url,content);
}
parseTitle方法实现
通过获取HTML文件名获取标题,通过File类中的getname方法获取文件名,但此时的文件名还包含.html后缀使用substring方法对字符串截取。
private String parseTitle(File file) {
String title=file.getName().substring(0,file.getName().length()-".html".length());
return title;
}
parseUrl方法实现
此处我们希望用户点击搜素结果能够跳转到对应的线上文档的页面,这里就需要我们获取的是一个线上的url,根据对线上和线下文档url的对比我们不难发现其后半部分是一致的内容,而线上文档的前半部分是固定不变的。所以我们根据本地文档路径获取其后半部分,并于其线上文档的前半部分固定字符串进行拼接以此获取线上文档对应的url。
private String parseUrl(File file) {
String s1=file.getAbsolutePath().substring(INPUT_PATH.length());
String s2="https://docs.oracle.com/javase/8/docs/api/";
return s2+s1;
}
parseContent方法实现
依次读取HTML中的每个字符,针对取出的字符进行判定。如果字符不是<,就直接把当前字符进行拷贝,如果字符是<,那么从<位置开始直到遇到>为止包含在内的所有字符都不进行拷贝。
private String parseContent(File file) {
try(FileReader fileReader=new FileReader(file)) {
boolean isContent=true;
StringBuilder stringBuilder=new StringBuilder();
while (true) {
int ret=fileReader.read();
if(ret==-1) {
break;
}
char c= (char) ret;
if(isContent) {
if(c=='<') {
isContent=false;
continue;
}
if(c=='\n'||c=='\r') {
c=' ';
}
stringBuilder.append(c);
} else {
if(c=='>') {
isContent=true;
}
}
}
return stringBuilder.toString();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
Paraer类整体代码
package com.example.searchengines;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Parser {
private static final String INPUT_PATH = "D:/jdk-8/docs/api";
private Index index=new Index();
public static void main(String[] args) throws InterruptedException {
Parser parser=new Parser();
parser.run();
}
private void run() {
ArrayList<File> result=new ArrayList<>();
enumFile(INPUT_PATH,result);
for (File file:result) {
System.out.println("解析 "+file.getAbsolutePath());
parseHTML(file);
}
System.out.println("解析完成");
index.save();
}
private void parseHTML(File file) {
String title=parseTitle(file);
String url=parseUrl(file);
String content=parseContent(file);
index.addDoc(title,url,content);
}
private String parseContent(File file) {
try(FileReader fileReader=new FileReader(file)) {
boolean isContent=true;
StringBuilder stringBuilder=new StringBuilder();
while (true) {
int ret=fileReader.read();
if(ret==-1) {
break;
}
char c= (char) ret;
if(isContent) {
if(c=='<') {
isContent=false;
continue;
}
if(c=='\n'||c=='\r') {
c=' ';
}
stringBuilder.append(c);
} else {
if(c=='>') {
isContent=true;
}
}
}
return stringBuilder.toString();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
private String parseUrl(File file) {
String s1=file.getAbsolutePath().substring(INPUT_PATH.length());
String s2="https://docs.oracle.com/javase/8/docs/api/";
return s2+s1;
}
private String parseTitle(File file) {
String title=file.getName().substring(0,file.getName().length()-".html".length());
return title;
}
private void enumFile(String inputPath, ArrayList<File> result) {
File rootFile=new File(inputPath);
File[] files=rootFile.listFiles();
for (File f:files) {
if(f.isDirectory()) {
enumFile(f.getAbsolutePath(),result);
} else {
if(f.getAbsolutePath().endsWith(".html")) {
result.add(f);
}
}
}
}
}
实现Index类
- 给定一个docId,在正排索引中,查询文档的详细信息
- 给定一个词,在倒排索引中,查询那些文档和这个词关联
- 往正排索引和倒排索引中新增一个文档
- 把内存中的索引数据结构保存到磁盘中
- 把磁盘中的索引数据结构加载到内存中
public class Index {
private static final String INDEX_PATH="D:\\jdk-8";
private ArrayList<DocInfo> forwardIndex=new ArrayList<DocInfo>();
private HashMap<String,ArrayList<Weight>> invertedIndex=new HashMap<>();
public DocInfo getDocInfo(int docId) {
}
public ArrayList<Weight> getInverted(String term) {
}
public void addDoc(String title,String url,String content) {
}
public void save() {
}
public void load() {
}
Weight类
创建Weight类,用来表示一个文档的权重信息。将文档id和文档与词的相关性权重进行的一个包装。
package com.example.searchengines;
import lombok.Data;
@Data
public class Weight {
private int DocId;
private int weight;
}
DocInfo类
DocInfo类中主要包含了文档的id,标题,url,内容相关的信息。
package com.example.searchengines;
import lombok.Data;
@Data
public class DocInfo {
private int DocId;
private String title;
private String url;
private String content;
}
实现getDocInfo和getInverted方法
public DocInfo getDocInfo(int docId) {
return forwardIndex.get(docId);
}
public ArrayList<Weight> getInverted(String term) {
return invertedIndex.get(term);
}
实现addDoc方法
往索引中插入新文档,我们需要同时往正排索引和倒排索引中插入新文档,buildForward()方法实现往正排索引中插入,buildIndex()方法实现往倒排索引中插入。
public void addDoc(String title,String url,String content) {
DocInfo docInfo=buildForward(title,url,content);
buildIndex(docInfo);
}
buildForward()方法实现
直接将其插入forwardIndex数组中即可,此时forwardIndex数组的长度就是DocId。
private DocInfo buildForward(String title,String url,String content) {
DocInfo docInfo=new DocInfo();
docInfo.setTitle(title);
docInfo.setUrl(url);
docInfo.setContent(content);
docInfo.setDocId(forwardIndex.size());
forwardIndex.add(docInfo);
}
return docInfo;
}
buildIndex()方法实现
使用buildIndex()方法进行倒排索引的插入,需要知道词与文档Id的映射,首先需要对文档标题和正文进行分词,结合分词结果计算其权重进行倒排索引的插入。其主要分为以下六个步骤
- 针对标题进行分词
- 遍历分词结果统计每个词出现的次数
- 针对正文进行分词
- 遍历分词结果统计每个词出现的次数
- 把上面结果汇总到一个HashMap中,权重=标题中出现的次数*10+正文中出现的次数(此处计算是一个拍脑门想法也可以自己设置)
- 遍历HashMap得到词和权重的映射关系,更新到倒排索引中
使用WordCnt内部类表示词在标题中出现的次数和在正文中出现的次数
private void buildIndex(DocInfo docInfo) {
class WordCnt {
public int titleCount;
public int contentCount;
public WordCnt(int titleCount,int contentCount) {
this.titleCount=titleCount;
this.contentCount=contentCount;
}
}
HashMap<String,WordCnt> wordCntMap=new HashMap<>();
List<Term> terms= ToAnalysis.parse(docInfo.getTitle()).getTerms();
for (Term term:terms) {
String word=term.getName();
WordCnt newWordCnt=wordCntMap.get(word);
if(newWordCnt==null) {
wordCntMap.put(word,new WordCnt(1,0));
} else {
newWordCnt.titleCount++;
}
}
terms=ToAnalysis.parse(docInfo.getContent()).getTerms();
for (Term term:terms) {
String word=term.getName();
WordCnt wordCnt=wordCntMap.get(word);
if(wordCnt==null) {
wordCntMap.put(word,new WordCnt(0,1));
} else {
wordCnt.contentCount++;
}
}
for (Map.Entry<String,WordCnt> entry:wordCntMap.entrySet()) {
Weight weight=new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
ArrayList<Weight> invertedList=invertedIndex.get(entry.getKey());
if (invertedList==null) {
invertedList=new ArrayList<>();
invertedIndex.put(entry.getKey(),invertedList);
}
invertedList.add(weight);
}
}
实现save方法 保存索引到文件
需要把内存中的索引结构变成一个"字符串",然后写入文件 此操作称为序列化。使用JSON格式进行序列化。
首先在pom.xml中插入Jackson库
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.2.2</version>
</dependency>
保存索引到文件中,生成两个文件forward.txt和inverted.txt,通过JSON格式表示
public void save() {
System.out.println("保存索引开始");
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");
try {
objectMapper.writeValue(forwardIndexFile,forwardIndex);
objectMapper.writeValue(invertedIndexFile,invertedIndex);
} catch (IOException e) {
e.printStackTrace();
}
}
实现load方法 加载索引到内存中
public void load() {
long beg=System.currentTimeMillis();
System.out.println("加载索引开始");
File forwardIndexFile=new File(INDEX_PATH+"forward.txt");
File invertedIndexFile=new File(INDEX_PATH+"inverted.txt");
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");
}
实现搜索模块
实现DocSearcher类,此类负责搜素功能,使用search方法进行根据查询词,完成搜素过程。使用Result类表示一次搜素的结果。
创建DocSearcher类
public class DocSearcher {
public List<Result> search(String query) {
}
}
创建Result类
表示搜素结果
package com.example.searchengines;
import lombok.Data;
@Data
public class Result {
private String title;
private String url;
private String desc;
}
实现search方法
参数是用户给出的查询词,返回值是搜素结果的集合
- 分词 针对查询词进行分词处理
- 针对分词结果查倒排
- 针对上面结果按照权重进行降序排序
- 针对排序的结果查正排构造出返回的结果
分词操作
List<Term> terms= ToAnalysis.parse(query).getTerms();
查倒排
Index index=new Index();
index.load();
ArrayList<Weight> allResult=new ArrayList<>();
for (Term term:terms) {
String word=term.getName();
ArrayList<Weight> list=index.getInverted(word);
if(list==null) {
continue;
}
allResult.addAll(list);
}
根据权重进行降序
allResult.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o2.getWeight()-o1.getWeight();
}
});
返回结果
需要注意的是此时返回的内容是正文的一个描述,因此我们使用GenDesc方法对正文内容进行一个截取操作。
List<Result> results=new ArrayList<>();
for (Weight weight:allResult) {
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;
GenDesc实现
- 找到分词在正文中第一次出现的位置
- 进行截断操作,从第一次出现位置往前找90个字符作为起始位置,然后从起始位置往后找130位置作为终止位置,注意此处选取数字都是随意决定的自己可以按照喜好修改。
在查找位置时需要注意,经过分词结果都转换为小写字母了,所有需要将正文内容转换为小写后进行查询。
private String GenDesc(String content, List<Term> terms) {
int firsPos=-1;
for (Term term:terms) {
String firsWord=term.getName();
firsPos=content.toLowerCase().indexOf(" "+firsWord+" ");
if(firsPos>0) {
break;
}
}
if(firsPos==-1) {
return "";
}
String desc="";
int descBeg=firsPos<90?0:firsPos-90;
if(descBeg+130>content.length()) {
desc=content.substring(descBeg,content.length());
} else {
desc=content.substring(descBeg,descBeg+130)+"...";
}
return desc;
}
验证DocSearcher
public static void main(String[] args) {
DocSearcher docSearcher=new DocSearcher();
List<Result> results=docSearcher.search("ArrayList");
for (Result result:results) {
System.out.println("================");
System.out.println(result);
System.out.println("=================");
}
}
观察结果我们发现如下图一些不符合要求的结果: 这是因为在Parser类中的parseHTML方法中解析HTML对应的正文时 只是去掉了标签本身, 而像 script 这种标签没去掉其中的内容.因此下面我们基于正则表达式去除script标签内容,HTML标签本身即多余的空格。 正则表达式中的特殊符号:
- .表示匹配一个非换行字符(不是\n,\r)
- *代表前面的字符可以不出现,也可以出现一次或者多次(0次、或1次、或多次)
- .*匹配非换行字符出现若干次
- ?表示“非贪婪匹配”匹配到一个符号条件的最短结果
- +代表前面的字符必须至少出现一次(1次或多次)
private String parseContentByRegular(File file) {
String content=readFile(file);
content=content.replaceAll("<script.*?>(.*?)</script>"," ");
content=content.replaceAll("<.*?>"," ");
content=content.replaceAll("\\s+"," ");
return content;
}
private String readFile(File file) {
try(BufferedReader bufferedReader=new BufferedReader(new FileReader(file))) {
StringBuilder stringBuilder=new StringBuilder();
while (true) {
int ret=bufferedReader.read();
if(ret==-1) {
break;
}
char c= (char) ret;
if(c=='\n'||c=='\r') {
c=' ';
}
stringBuilder.append(c);
}
return stringBuilder.toString();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
实现web模块
实现DescSearcherController
通过SpringBoot提供web服务,通过get方法提供web接口,返回json格式的数据. 请求:GET/searcher?query=[查询词] HTTP/1.1 相应:HTTP/1.1 200 OK [ { title:“这是标题”, url:“这是url”, desc:“这是描述”, } ]
@RestController
@ResponseBody
public class DescSearcherController {
private static DocSearcher searcher=new DocSearcher();
private ObjectMapper objectMapper=new ObjectMapper();
@RequestMapping("/searcher")
public String search(@RequestParam("query") String query) throws JsonProcessingException {
List<Result> results=searcher.search(query);
return objectMapper.writeValueAsString(results);
}
}
实现标红逻辑
显示搜索结果时,为了描述中关键词的方便查看,针对描述中的关键词进行标红处理。 主要思路是在服务器查询的时候,找到其中关键词,并在关键词上套上一层标签,并在前端设置一个红色的样式。 修改DocSeacher类下的GenDesc方法。 需要注意的是一个描述中可能存在多个关键词,需要使用replacAll来替换。 在GenDesc方法中加入下面代码。
for (Term term:terms) {
String word=term.getName();
desc=desc.replaceAll("(?i) "+word+" ","<i>"+word+"</i>");
}
处理暂停词
首先需要下载停用词表,网上一搜就可以搜到,然后将停用词表放到文件中,其中每个词占一行.
加载停用词
修改DocSearcher类,用HashSet存放停用词。
public class DocSearcher {
private static final String STOP_WORD="D:\\doc_searcher_index\\stop_word.txt";
private HashSet<String> stopWordDict=new HashSet<>();
}
创建loadStopWord方法,加载停用词
private void loadStopWord() {
try(InputStream inputStream=new FileInputStream(STOP_WORD)){
Scanner scanner=new Scanner(inputStream);
while (scanner.hasNextLine()) {
String line=scanner.nextLine();
stopWordDict.add(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
在DocSearcher中调用loadStopWord
public DocSearcher() {
index.load();
loadStopWord();
}
修改search方法,将分词结果存放在oldTerm中,遍历oldTerm,将不是停用词的其余所有词加入到terms中。只需要在search方法中新增如下代码。
List<Term> oldTerm=ToAnalysis.parse(query).getTerms();
List<Term> terms= new ArrayList<>();
for (Term term:oldTerm) {
if(stopWordDict.contains(term)) {
continue;
}
terms.add(term);
}
实现权重合并
当我们搜素的查询词包含多个单词时,我们希望同时包含多个单词的结果可以靠前展示,这时就需要对其权重进行调整。 首先把分词结果按照docId进行升序排序,将docId相等的进行权重相加。此处类似于合并两个链表的操作,但是要注意的是,分词结果不一定只分为两部分,有可能是很多部分,此时就是N个数组合并。 在search方法中排序之前,使用mergeResult方法进行合并,并将结果保存在allTokenResult中,后续的排序和返回操作的对象都为allTokenTesult。
mergeResult实现
- 针对每一行按照id进行升序排序
- 借助优先级队列,针对行进行合并。
static class Pos {
public int row;
public int col;
public Pos(int row,int col) {
this.row=row;
this.col=col;
}
}
private ArrayList<Weight> mergeResult(ArrayList<ArrayList<Weight>> allResult) {
for (ArrayList<Weight> list:allResult) {
list.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o1.getDocId()-o2.getDocId();
}
});
}
ArrayList<Weight> target=new ArrayList<>();
PriorityQueue<Pos> queue=new PriorityQueue<>(new Comparator<Pos>() {
@Override
public int compare(Pos o1, Pos o2) {
Weight w1=allResult.get(o1.row).get(o1.col);
Weight w2=allResult.get(o2.row).get(o2.col);
return w1.getDocId()-w2.getDocId();
}
});
for (int i = 0; i <allResult.size() ; i++) {
queue.offer(new Pos(i,0));
}
while (!queue.isEmpty()) {
Pos minPos=queue.poll();
Weight curWeight=allResult.get(minPos.row).get(minPos.col);
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);
}
Pos newPos=new Pos(minPos.row,minPos.col+1);
if(newPos.col>=allResult.get(newPos.row).size()) {
continue;
}
queue.offer(newPos);
}
return target;
}
|