先放一张最终实现的效果图吧,免得没人看哈哈。 最终做的是学院网站的一个搜索引擎,支持精确查询和通配符查询。同时,提供了分页功能,每页展示15条数据。
对于每条查询结果,支持查询相似文档(相似度>=20%):
先给出最终工程的完整代码,下载地址
下面我们进入本次的正文部分:
什么是倒排索引?
在搜索引擎中每个文件都对应一个文件ID,文件内容被表示为一系列关键词的集合(实际上在搜索引擎索引库中,关键词也已经转换为关键词ID)。例如“文档1”经过分词,提取了20个关键词,每个关键词都会记录它在文档中的出现次数和出现位置。
得到正向索引的结构如下:
“文档1”的ID > 单词1:出现次数,出现位置列表;单词2:出现次数,出现位置列表;……
“文档2”的ID > 此文档出现的关键词列表。
一般是通过key,去找value。
当用户在主页上搜索关键词“华为手机”时,假设只存在正向索引(forward index),那么就需要扫描索引库中的所有文档,找出所有包含关键词“华为手机”的文档,再根据打分模型进行打分,排出名次后呈现给用户。 因为互联网上收录在搜索引擎中的文档的数目是个天文数字,这样的索引结构根本无法满足实时返回排名结果的要求。
所以,搜索引擎会将正向索引重新构建为倒排索引,即把文件ID对应到关键词的映射转换为关键词到文件ID的映射,每个关键词都对应着一系列的文件,这些文件中都出现这个关键词。
得到倒排索引的结构如下:
“关键词1”:“文档1”的ID,“文档2”的ID,……
“关键词2”:带有此关键词的文档ID列表。
倒排索引基本概念
- 文档(Document):一般搜索引擎的处理对象是互联网网页,而文档这个概念要更宽泛些,代表以文本形式存在的存储对象,相比网页来说,涵盖更多种形式,比如Word,PDF,html,XML等不同格式的文件都可以称之为文档。再比如一封邮件,一条短信,一条微博也可以称之为文档。我们使用文档来表征文本信息。
- 文档集合(Document Collection):由若干文档构成的集合称之为文档集合。比如海量的互联网网页或者说大量的电子邮件都是文档集合的具体例子。
- 文档编号(Document ID):在搜索引擎内部,会将文档集合内每个文档赋予一个唯一的内部编号,以此编号来作为这个文档的唯一标识,这样方便内部处理,每个文档的内部编号即称之为“文档编号”,用DocID来便捷地代表文档编号。
- 单词编号(Word ID):与文档编号类似,搜索引擎内部以唯一的编号来表征某个单词,单词编号可以作为某个单词的唯一表征。
- 倒排索引(Inverted Index):倒排索引是实现“单词-文档矩阵”的一种具体存储形式,通过倒排索引,可以根据单词快速获取包含这个单词的文档列表。倒排索引主要由两个部分组成:“单词词典”和“倒排文件”。
- 单词词典(Lexicon):搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。
- 倒排列表(PostingList):倒排列表记载了出现过某个单词的所有文档的文档列表及单词在该文档中出现的位置信息,每条记录称为一个倒排项(Posting)。根据倒排列表,即可获知哪些文档包含某个单词。
- 倒排文件(Inverted File):所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件即被称之为倒排文件,倒排文件是存储倒排索引的物理文件。
关于这些概念之间的关系,通过下图可以比较清晰的看出来:
实现步骤
1)选定数据源,如某类网站、某类期刊、某类会议。
2)(手工/自动)获取数据源中的文本信息,如将每个网页作为一篇文献,存为.txt 文档。
3)分词算法:中文文档,选用中文分词工具来实现。
4)排序算法:对提取的所有 items(信息项)进行排序。
5)词频算法:统计在每个文档中出现的每个 item 的词频 tf。
6)去重算法:计算出现每个 item 的文档个数 df,将重复出现的 item 进行去重处理,。
7)创建索引结构:建立字典结构和 PostingList 结构,存储 items 和 df、DocIDs 和 tf
代码实现
1.数据爬取
选用学院的网站作为数据来源,使用Jsoup框架编写爬虫,并对文件进行编号。
代码如下:
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class TestCrawler {
private static String url = "http://cse.seu.edu.cn";
private static String path = "articles";
private static Set<String> url_set = new HashSet<>();
private static Integer id = 0;
public static void main(String[] args) {
getArticleListFromUrl(url);
}
/**
* 获取文章列表
*/
public static void getArticleListFromUrl(String url) {
url_set.add(url);
getArticleFromUrl(url, "计算机科学与工程学院");//先保存本页面的文本
Document doc = null;
try {
doc = Jsoup.connect(url).userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36")
.timeout(3000).post();
} catch (IOException e) {
e.printStackTrace();
}
dealTagLink(doc.getElementsByTag("a")); //找到所有a标签
}
private static void dealTagLink(Elements elements) {
for (Element element : elements) {
String href = element.attr("href");
if (href.equals("http://cse.seu.edu.cn/cse_en")) { //不收集英文页面
continue;
}
if (href.contains("_upload") || href.contains("javascript") || href.contains("@") || href.endsWith("http")
|| href.endsWith("https")) {
continue;
}
if (!href.startsWith("http://cse.seu.edu.cn")) {
if (!url_set.contains(url + href)) {
url_set.add(url + href);
getArticleFromUrl(url + href, element.text());
}
} else if (href.startsWith("/") || href.startsWith("http")){
if (!url_set.contains(href)) {
url_set.add(href);
getArticleFromUrl(href, element.text());
}
}
}
}
/**
* 获取文章内容
* @param detailurl
* @param title
*/
public static void getArticleFromUrl(String detailurl, String title) {
try {
Document document = Jsoup.connect(detailurl).userAgent("Mozilla/5.0").timeout(3000).post();
dealTagLink(document.select("#container-content a"));//找到所有a标签
String text = Jsoup.parse(document.select("#container-content").html()).text();
saveArticle(text, title);
} catch (IOException e) {
}
}
/**
* 保存文章到本地
*/
public static void saveArticle(String content, String title) {
//文件名中不能有\、/、:、*、?、"、<、>、|
Pattern pattern = Pattern.compile("[\\s\\\\/:\\*\\?\\\"<>\\|]");
Matcher matcher = pattern.matcher(title);
title = matcher.replaceAll(""); // 将匹配到的非法字符以空替换
if (content.length() == 0) { //内容为空的文章不做存储
return;
}
title = id + "-" + title;
id++;
String savePath = path + "/" + title + ".txt";//保存到本地的路径和文件名
File file = new File(savePath);
// if (!file.getParentFile().exists()) {
// file.getParentFile().mkdirs();
// }
if (file.exists()) {
return;
}
try {
file.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
try {
FileWriter fw = new FileWriter(file, true);
BufferedWriter bw = new BufferedWriter(fw);
bw.write(content);
bw.flush();
bw.close();
fw.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
2.文档分词
中文文档,选用中文分词工具来实现。此处使用的是中科院的NLPIR中文分词工具
NLPIR下载地址-github
我们需要创建如图所示的结构:
此处我们定义 Item_ori 数据结构来存储最原始的 item 信息项
需要实现Comparable接口,并重写compareTo方法,为后续对item进行排序做准备。
注意:String默认使用的unicode编码直接对中文排序不符合我们的预期。我们想按照拼音来对中文排序需要使用 Collator.getInstance(Locale.CHINA) 这个比较器。
import java.text.Collator;
import java.util.Comparator;
import java.util.Locale;
public class Item_ori implements Comparable<Item_ori> {
public String term;
public Integer docId;
public Integer freq;
public Item_ori(String term, Integer docId) {
this.term = term;
this.docId = docId;
this.freq = 1;
}
@Override
public int compareTo(Item_ori o) {
Comparator<Object> CHINA_COMPARE = Collator.getInstance(Locale.CHINA);
int result = ((Collator) CHINA_COMPARE).compare(term, o.term);
if (result == 0) {
if (docId < o.docId) {
return -1;
} else if (docId > o.docId) {
return 1;
} else {
return 0;
}
} else {
return result;
}
}
}
分词算法:
/**
* 分词
* @param in_path 存储文档的目录路径
* @param list 存储文档中提取的所有 items(信息项)
*/
public void partWord(String in_path, LinkedList<Item_ori> list) {
File in_directory = new File(in_path);
File[] files = in_directory.listFiles();
long startTime = System.currentTimeMillis(); //获取开始时间
for (File file : files) {
StringBuilder stringBuilder = new StringBuilder();
int index = Integer.valueOf(file.getName().split("-")[0]);
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader(file));
String line;
while ((line = br.readLine()) != null) {
stringBuilder.append(line);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (br != null) {
try {
br.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
String content = stringBuilder.toString();
String result = NlpirMethod.NLPIR_ParagraphProcess(content, 0); //0表示不显示词性
// 储存分词后的结果
String[] strings = result.split(" ");
for (String str : strings) {
if (str.length() > 0) {
list.add(new Item_ori(str, index));
}
}
}
long endTime = System.currentTimeMillis(); //获取结束时间
System.out.println("分词运行时间:" + (endTime - startTime) + "ms"); //输出程序运行时间
}
3.对提取的 items(信息项)进行排序
利用jdk1.8的stream直接操作 LinkedList<Item_ori> list
/**
* 排序items
* @param list 文档中提取的所有 items(信息项)
*/
public LinkedList<Item_ori> sortItems(LinkedList<Item_ori> list) {
long startTime = System.currentTimeMillis(); //获取开始时间
//排序items
list = list
.stream()
.sorted()
.distinct()
.collect(Collectors.toCollection(LinkedList::new));
long endTime = System.currentTimeMillis(); //获取结束时间
System.out.println("排序运行时间:" + (endTime - startTime) + "ms"); //输出程序运行时间
return list;
}
4.统计在每个文档中出现的每个 item 的词频 tf
/**
* 词频算法:统计在每个文档中出现的每个 item 的词频 tf
* @param list 文档中提取的所有 items(信息项)
*/
public void wordFrequency(LinkedList<Item_ori> list) {
long startTime = System.currentTimeMillis(); //获取开始时间
Iterator<Item_ori> iterator = list.iterator();
Item_ori last = iterator.next();
Item_ori current;
while (iterator.hasNext()) {
current = iterator.next();
if (last.docId.equals(current.docId) && last.term.equals(current.term)) {
last.freq++;
iterator.remove();
continue;
}
last = current;
}
long endTime = System.currentTimeMillis(); //获取结束时间
System.out.println("词频算法运行时间:" + (endTime - startTime) + "ms"); //输出程序运行时间
}
5.计算出现每个 item 的文档个数 df,将重复出现的 item 进行去重处理
为表现如上图所示的索引结构,我们引入 Item 的数据结构,Item类的定义如下:
import java.util.LinkedList;
public class Item {
public String term;
public Integer docs;
public Integer freq_total;
public LinkedList<Item_ori> ori_item_list;
public Item(String term, Integer freq_total, Item_ori current) {
this.term = term;
this.freq_total = freq_total;
this.docs = 1;
ori_item_list = new LinkedList<>();
ori_item_list.add(current);
}
}
去重算法:
计算出现每个 item 的文档个数 df,将重复出现的 item 进行去重处理
同时创建索引结构:建立字典结构和 PostingList 结构,存储 items 和 df、DocIDs 和 tf
/**
* 去重算法:计算出现每个 item 的文档个数 df,将重复出现的 item 进行去重处理
* 同时创建索引结构:建立字典结构和 PostingList 结构,存储 items 和 df、DocIDs 和 tf
* @param list 文档中提取的所有 items(信息项)
* @return 去重后的 items(信息项)
*/
private LinkedList<Item> deduplAndCreateIndex(LinkedList<Item_ori> list) {
long startTime = System.currentTimeMillis(); //获取开始时间
LinkedList<Item> item_list = new LinkedList<>();
Item cur_item;
Iterator<Item_ori> iterator = list.iterator();
//单独处理第一个
Item_ori current = iterator.next();
cur_item = new Item(current.term, current.freq, current);
item_list.add(cur_item);
while (iterator.hasNext()) {
current = iterator.next();
if (current.term.equals(cur_item.term)) {
cur_item.docs++;
cur_item.freq_total += current.freq;
cur_item.ori_item_list.add(current);
} else {
cur_item = new Item(current.term, current.freq, current);
item_list.add(cur_item);
}
}
long endTime = System.currentTimeMillis(); //获取结束时间
System.out.println("去重算法运行时间:" + (endTime - startTime) + "ms"); //输出程序运行时间
return item_list;
}
6.倒排索引建立完成,输出字典结构和 PostingList 结构到文件
最终形成的倒排索引结构如图所示:
输出字典结构和 PostingList 结构到文件:
//输出字典结构和 PostingList 结构到文件
public static void outDictionary2File(LinkedList<Item> dictionary, String fileName) {
BufferedWriter out = null;
try {
File out_file = new File(fileName + ".txt");
out_file.createNewFile(); // 创建新文件,有同名的文件的话直接覆盖
out = new BufferedWriter(new FileWriter(out_file));
for (Item item : dictionary) {
String content = item.term + " : " + item.docs + " : " + item.freq_total + " , ";
for (Item_ori item_ori : item.ori_item_list) {
content += "[ " + item_ori.docId + " : " + item_ori.freq + " ] -> ";
}
out.write(content + "\r\n");
}
out.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
完整的调用函数
public static void main(String[] args) {
Experiment1 experiment1 = new Experiment1();
experiment1.exp1();
}
public LinkedList<Item> exp1() {
LinkedList<Item_ori> list = new LinkedList<>();
String in_path = "articles-84";
// String in_path = "articles-400";
//分词
partWord(in_path, list);
//排序:对提取的所有 items(信息项)进行排序
list = sortItems(list);
//词频算法:统计在每个文档中出现的每个 item 的词频 tf
wordFrequency(list);
//去重算法:计算出现每个 item 的文档个数 df,将重复出现的 item 进行去重处理
//同时创建索引结构:建立字典结构和 PostingList 结构,存储 items 和 df、DocIDs 和 tf
LinkedList<Item> dictionary = deduplAndCreateIndex(list);
// 输出字典结构和 PostingList 结构到文件
outDictionary2File(dictionary, "result/result_exp1");
return dictionary;
}
至此,倒排索引建立完成
下一篇文章 :【信息检索】Java简易搜索引擎原理及实现(二)新增停用词表 + 查询处理,我们将新增停用词表,同时对用户输入的查询词做基本的处理。
2019-6-13更新:发现学院的网站最近换成了https协议,原来的爬虫方式爬不下来了!需要https的证书或者现在有一个比较简单的方法,把调用Jsoup爬取的那一步改为:
doc = Jsoup.connect(url).userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36")
.validateTLSCertificates(false).timeout(6000).post();