搜索引擎
我们经常使用各种浏览器进行搜索,百度,谷歌,火狐等等,那么到底什么是搜搜引擎呢?
搜索引擎是一类系统或者软件的统称,作用是从文档的集合中查找(检索)出匹配信息需求(查询)的文档,信息需求是由单词、问题构成的。
官方描述:
搜索引擎实现:
根据多个关键词(空格间隔、分词)进行网页搜索,显示搜索结果(标题、网页描述、url)
例如:分词:我/是/中国/人民/解放军/,最后展示出来由这些关键词组成的页面权重最高的界面。
获取海量数据
我们大家都听过的网络爬虫,就是不断的从互联网上抓取海量数据,搜索引擎实现所需要的数据就是来源于此。但是我们实现的这个小型搜索引擎裁剪了如何去爬取网页资源的过程:只搜索oracle官网上的Java jdk文档,搜索docs.oracle.com/javase/8/docs/api 路径下的资源,不进行在线网页爬取,直接取本地下载好的html文件作为相对路径,以api为相对路径,找thml资源的相对路径。
从HTML文件中提取文本
遍历本地下载好的JDK官方文档api路径下的所有的以html结尾的文件。
在本地磁盘上保存遍历后的html文件,一个html文件解析为一个docInfo文档,docInfo有4个属性:
在本地文件中保存的url为oracle官网的地址加上api目录下的每一个html的相对位置。
核心流程:
- 枚举出文档目录下所有的 html 文件
- 遍历每个文件, 把文件格式进行转换
- 把最终结果写入到一个结果文件中.
中文分词
Java开源中文分词器有11个左右,分别是word分词器,Ansj分词器,Stanford分词器,FudanNLP分词器,Jieba分词器,Jcseg分词器,MMSeg4j分词器,IKAnalyzer分词器,Paoding分词器,smartcn分词器,HanLP分词器。
我们搜索引擎项目使用的中文分词器是Ansj中文分词器,它是一个开源的 Java 中文分词工具,基于中科院的 ictclas 中文分词算法,比其他常用的开源分词工具(如MMseg4j)的分词准确率更高,目前实现了中文分词、中文姓名识别 、用户自定义词典、关键字提取、自动摘要、关键字标记等功能,适用于对分词效果要求高的各种项目。
使用之前要先添加ansj的maven依赖(目前最高版本是5.1.6):
<dependency>
<groupId>org.ansj</groupId>
<artifactId>ansj_seg</artifactId>
<version>5.1.6</version>
</dependency>
调用方式有4种:分别是基本分词-BaseAnalysis,精准分词-ToAnalysis,nlp分词-NlpAnalysis和面向索引的分词-IndexAnalysis。具体使用方式以及区别详见Java开源中文分词器ansj 这篇文章,在此项目中我们使用的是ansi的ToAnalysis进行分词,因为他总体上来说分词效果还不错,初次使用的话,也不易出错。
public class TestAnsj {
public static void main(String[] args) {
String str = "一个傻子来到北京," +
"后来去学习计算机," +
"他最后去了网易杭研大厦";
List<Term> terms = ToAnalysis.parse(str).getTerms();
for (Term term : terms) {
System.out.print(term.getName() + "/");
}
}
}
分词结果:
一个/傻子/来到/北京/,/后来/去/学习/计算机/,/他/最后/去/了/网易/杭/研/大厦/
创建索引
这一步骤是要将在本地保存的遍历的所有的html文件的结果集做成索引,包括构建正排索引和倒排索引。
构建正排索引
就是根据本地保存的数据,将其加载到java内存中。本地文件种保存的就是解析后的每一个html文件的id,title,url和content。
public static void buildForwardIndex() {
try {
FileReader fr=new FileReader(Parser.RAW_DATA);
BufferedReader br=new BufferedReader(fr);
int id=0;//定义行号设置为docInfo的id
String line;
while((line=br.readLine())!=null){
//一行对应一个DocInfo对象,类似数据库中的一行数据对应java对象
if(line.trim().equals("")) continue;//最后一行空行不处理
DocInfo doc=new DocInfo();
doc.setId(++id);
String[] parts=line.split("\3");//每一行按\3间隔符进行切分
doc.setTitle(parts[0]);
doc.setUrl(parts[1]);
doc.setContent(parts[2]);
//添加到正排索引
FORWARD_INDEX.add(doc);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
构建倒排索引
从java内存中正排索引信息来构建,首先要计算标题和正文中的关键词的权重,在倒排索引中不断添加同一个关键字所在不同的文档中的id,最后形成一个倒排拉链。
public static void buildInvertedIndex(){
for(DocInfo doc:FORWARD_INDEX){
//一个doc。分别对标题和正文进行分词,每一个分词生成一个weight对象,需要计算权重
//第一次出现的关键词,要new Weight对象,之后出现要获取之前相同关键词对象,更新权重
Map<String,Weight> cache=new HashMap<>();
List<Term> titleFencis= ToAnalysis.parse(doc.getTitle()).getTerms();
for(Term titleFenci:titleFencis){//标题分词,遍历处理
//获取标题分词对应的weight
Weight w=cache.get(titleFenci.getName());
if(w==null){//如果没有就创建一个,放到map中
w=new Weight();
w.setDoc(doc);
w.setKeyWord(titleFenci.getName());//关键词
cache.put(titleFenci.getName(),w);
}
//标题分词,权重+10
w.setWeight(w.getWeight()+10);
}
//正文分词处理:逻辑和标题的分词处理相同
List<Term> contentFencis=ToAnalysis.parse(doc.getContent()).getTerms();
for(Term contentFenci:contentFencis){
Weight w=cache.get(contentFenci.getName());
if(w==null){
w=new Weight();
w.setDoc(doc);
w.setKeyWord(contentFenci.getName());
cache.put(contentFenci.getName(),w);
}
//正文分词,权重+1
w.setWeight(w.getWeight()+1);
}
//把临时保存的,api数据(keyWord+weight)保存到倒排索引
for(Map.Entry<String,Weight> e:cache.entrySet()){
String keyWord=e.getKey();
Weight w=e.getValue();
//更新保存到到倒排索引里面Map<String,List<Weight>>多个文档,同一关键词,保存在一个List中
//先在倒排索引中获取已有的值,获取的是一个List
List<Weight> weights=INVERTED_INDEX.get(keyWord);
if(weights==null){
weights=new ArrayList<>();
INVERTED_INDEX.put(keyWord,weights);
}
//System.out.print(keyWord+":("+w.getDoc().getId()+","+w.getWeight());
weights.add(w);
}
}
}
搜索模块处理
根据查询词, 进行搜索, 得到搜索结果集合,结果集合中包含若干条记录, 每个记录中包含搜索结果的标题, 描述, url。
- 对查询词进行分词
- 对每个分词结果查找倒排索引, 得到一个倒排拉链
- 针对结果集合进行排序, 按权重降序排序即可
- 构造返回结果
loadOnStartup=0参数表明在tomcat启动的时候,要执行里面的初始化方法,构造正排索引和倒排索引。
@WebServlet(value = "/search",loadOnStartup = 0)
public class SearchServlet extends HttpServlet {
@Override
public void init(ServletConfig config) throws ServletException {
//初始化工作
Index.buildForwardIndex();
Index.buildInvertedIndex();
System.out.println("init complete");
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setCharacterEncoding("UTF-8");
resp.setContentType("application/json");//ajax请求,返回的数据类型
//构造返回给前端的内容,之后再序列化为json字符串
Map<String,Object> map=new HashMap<>();
//解析请求数据
String query=req.getParameter("query");//搜索框的内容
List<Result> results=new ArrayList<>();
try{
//根据搜索内容,处理搜索业务
//校验请求数据:搜索内容
if(query==null||query.trim().length()==0){
map.put("ok",false);
map.put("msg","搜索框为空");
}else {
//1.根据搜索内容,进行分词,遍历每个分词
for(Term t: ToAnalysis.parse(query).getTerms()){
String fenci=t.getName();//搜索框的分词
//如果分词是没有意义的分词,就跳过它
//TODO:定义一个数组,包含没有意义的关键词if(isValid)continue;返回boolean值
//2.每个分词,在倒排中查找对应的文档(一个分词对应多个文档)
List<Weight> weights=Index.get(fenci);
//3.一个文档转换为一个result(不同分词,存在相同文档,需要合并)
for(Weight w:weights){
//转换weight为result
Result r=new Result();
r.setId(w.getDoc().getId());
r.setTitle(w.getDoc().getTitle());
r.setWeight(r.getWeight());
r.setUrl(w.getDoc().getUrl());
//自己决定:文档内容超过60个长度,超过的部分就隐藏为....
String content=w.getDoc().getContent();
r.setDesc(content.length()<=60?content:(content.substring(0,60)+"..."));
//TODO:合并操作暂时不实现,需要在List<Result>
//(1)找已有的,判断docId相同,直接在已有的Result权重上加现有的
//(2)不存在,直接放进去
results.add(r);
}
}
//4.合并完成之后,对List<results>权重进行排序:权重降序排列
results.sort(new Comparator<Result>() {
@Override
public int compare(Result o1, Result o2) {
//return Integer.compare(o1.getWeight(),o2.getWeight());//权重升序
return Integer.compare(o2.getWeight(),o1.getWeight());
}
});
map.put("ok", true);
map.put("data", results);
}
}catch(Exception e){
e.printStackTrace();
map.put("ok",false);
}
PrintWriter pw=resp.getWriter();//获取输出流
//设置响应体内容,map对象序列化为json对象
pw.println(new ObjectMapper().writeValueAsString(map));
}
}
倒排索引=词典+倒排文件
倒排索引是由单词的集合“词典”和倒排列表的集合“倒排文件”构成的。
搜索界面
实现的比较简单,仅仅实现了一个搜索框和搜索按钮。
这个项目做的比较粗糙,有以下可以改进的地方:
1.查询界面的分页功能;
2.前端查询操作是否成功,决定展示的是业务数据还是错误消息;
3.后端可以实现自定义异常处理;
4.查询效率:可以再多做一层缓存,将一些经常搜索的内容进行二次缓存;
5.对于多个关键词,将搜索出来的结果进行合并之后再排序;
6.前端界面搜索内容为空进行判断。