网上的搜索引擎有很多,比如常用的百度,搜狗,360搜索,头条新闻客户端。
这些公司做出来的搜索引擎是一个非常大型的项目,需要很高的技术门槛,所以早期只有百度在做搜索引擎。
这些公司的搜索引擎都属于全网搜索。其高门槛体现在:如何把全网相关网页信息抓取下来并且还要对全网数据进行保存并建立相关的后端的索引模块,这是一个非常大型的工作,并且后期搜索的时候还要对客户的关键字排序、设置显示网页的相关优先级的问题、网页与网页之间关联度的问题等。
所以我们自己实现一个完整的搜索引擎是不可能的,不过我们可以写一个简单的搜索引擎——站内搜索。
站内搜索最典型的代表就是我们使用的cplusplusC++的标准文档。其与全网搜索最大的不同之一就是搜索的数据更垂直。也就意味着数据量更小。
搜索引擎的效果是怎么样的?我们以百度为例,搜索一下腾讯官网后效果如下:
可以发现搜索引擎搜索的结果核心分为3块:
1.网页的title:可以让我们知道网页时干嘛的,并且有一个点击的功能,点击标题,就可以跳转到目标网址
2.网页内容的摘要描述
3.目标网页对应的网址url
我们的搜索引擎就以这三块内容为标准进行展示。
图片,广告?广告时搜索引擎的一种盈利方式,baidu可以出售自己的关键字,谁的钱多,谁的内容就更靠前。搜头疼-》推广,广告
大部分商业公司的盈利方式是采用竞价排名的方式获利的。
为什么要做boost库的搜索引擎?我们可以进入boost库的官网(boost.org)查看一些
可以发现boost库的官网是没有正真意义上的搜索功能的,我们没有办法搜索某个词,虽然官方提供了boost相关的一些方法,标准库中的一些接口,但是我们想看到官方文档成本比较高,所以我们可以自己做一个站内搜索。
数据在搜索引擎是如何流动的?
下面就是我们的项目工作的流程
首先从全网爬取到网页,存储在服务器的磁盘中,然后对网页进行处理,建立索引。当客户端对我们发送请求时,我们根据客户的关键字进行检索,然后得到相关网页后构建出一个新的网页返回给用户。因为我们国家对爬虫有相关的法律规定,所以我们获取网页的方式就改为合法的下载网页信息,所以我们的项目要实现的部分就是左边红圈的部分。
该项目需要用到的技术栈:C/C++、C++11、STL、准标准库Boost(文件处理)、jsoncpp(数据交换)、cppjieba(分词)、cpp-httplib(构建http服务器)、html5、css、js、jQuery、Ajax
项目环境:Centos7 云服务器、vim/gcc(g++)/Makefile、vs2022或vs Code。
这里是对正排和倒排的原理性的介绍,让我们了解正排倒排的特点以及他们在搜索引擎中承担什么角色?
正排索引:从文档ID找到文档内容(文档内的关键字)
假设有两个文档:
文档ID | 文档内容 |
---|---|
1 | 关羽今天吃了一斤大力丸 |
2 | 关羽砍死了外星人 |
我们搜索时是使用关键字搜索的,首先要对目标文档进行分词(目的:为了方便建立倒排索引和查找)
- 文档1:关羽今天吃了一斤大力丸->关羽 /今天/吃/一斤/大力丸/一斤大力丸
- 文档2:关羽砍死了外星人->关羽/砍死/外星人
停止词(stopwords):了、的、吗、a、the…一般我们在分词的时候可以不考虑。因为这些词的出场频率太高了,如果我们把这些词保留下来,那他们在搜索时区分唯一性的价值也不大,并且会增加我们建立索引的成本乃至搜索的成本。
倒排索引:根据文档内容,分词,整理不重复的各个关键字,对应联系到文档ID的方案
关键字(具有唯一性的) | 文档ID、weight(权重) |
---|---|
关羽 | 文档1、文档2 |
今天 | 文档1 |
吃 | 文档1 |
一斤 | 文档1 |
大力丸 | 文档1 |
一斤大力丸 | 文档1 |
砍死 | 文档2 |
外星人 | 文档2 |
模拟一次查找的过程:
用户输入:关羽->在倒排索引中查找->提取出文档ID(1,2)->根据正排索引->找到文档的内容->title+connect(desc)+url文档结果进行摘要->构建响应结果。
我们发现如果用户输入了关羽,那文档1和文档2都有这个关键字,我是先显示文档1还是文档2呢?所以每一个文档都会有一个权值(weight)
boost 官网:https://www.boost.org/
点击DOCUMENTATION可以看到各种boost相关的库
直接选择最新的
这里就是它按照字典序列排序的内容
这是文档的内容,怎么下载?
回到首页,下载哪里就有最新版的
选择boost_1_79_0.tar.gz
1.在云服务器上建立好项目文件夹
2.在文件夹下输入rz命令,把下载好的boost库上传到云服务器(
上传的时候有可能会出现乱码,这是因为我们传的文件太大导致的,可以加一个-E选项
rz -E
上传完成后,进行解包解压
解包解压:tar xzf boost_1_79_0.tar.gz
完成后会有一个boost_1_79_0的目录,里面保存的就是所有的boost内容,这里面就是我们在官网里看到的所有内容
我们在网页上查的手册,是在一个boost版本下的/doc/html文件里面。也就是说我们搜索的时候我们只用boost库html里的文件内容。这就是标准库的各种boost组件对应的手册内容,有些也在别的文件里面,暂时先不考虑
有了数据源,在项目里创建一个data目录,把数据拷贝进data目录下的input目录里。
mkdir -p data/input
input里面放的就是数据源,也就是我们要搜索的html文档,可以拷贝进去
cp -rf boost_1_79_0/doc/html/* data/input/
拷贝完成后,boost库对我们就没用了,input里面就包含了各种各样的需要我们做检索,建立索引的各种网页信息了。后续搜索结果是以data/input作为数据源,建立索引,然后自己拼接一些url,构建一个跳转url就可以。
数据建立好以后,我们就要建立我们的第一个模块,构建索引的模块
在工作目录下
touch parser.cc
编写parser文件对网页信息进行去标签动作(数据清洗)
做数据处理,那一定要有原始数据,然后把它变成去标签之后的数据
去标签
什么是标签?
用nano随便进入一个.html文件
Chapter 45. Boost.YAP
Home
Libraries
People
FAQ
More
Copyright © 2018 T. Zachary Laine
.........
用<>括起来的就叫做html的标签,标签会被浏览器解释呈现出不同的形态,也就是我们看到的网页信息。但是这个标签对我们进行搜索是没有价值的,所以我们要把他们处理掉。一般标签都是成对出现的。成对出现的标签,比如< head>…< /head>、还有一类是只有<>括起来,这两类我们去标签时都要考虑。
去标签就相当于是一个数据清洗的工作,数据清洗完的结果就可以放在data目录下的raw_html文件
查看我们下载了多少html文件:ls -Rl | grep -E '*.html' | wc -l
我们的目标是想把每个文档都去标签,然后写入到同一个文件中,每个文档的内容不换行!用特殊字符‘\3’区分不同文档
为什么用\3?下面是一张ACSII码表
可以看到有的字符属于控制字符,是不可显示的,有的字符是打印字符。我们获得的文档内容基本都属于打印字符。3对应^C,是不可显示的,所以也就不会污染我们形成的新文档,用别的控制字符也可以
我们要把处理完的内容放在data目录的raw_html目录下,那我们就要有一个文件存放数据,可以用一个raw.txt文件存放处理的结果
3.编写Parser基本结构
首先,我们一定会涉及到读取文件的操作,读取文件之前必须待先把所有带路径的文件名全部罗列出来,然后一个文件一个文件的读。
所以可以先定义一个source_path,表示所有源html文件的路径,定义一个raw_txt表示最终结果要被写入的文件路径+文件名。
要读取文件,那我就要知道文件名和文件路径,所以第一步,我们可以先根据源文件的目录,列举出所有的文件名+路径——我们用函数EnumFile实现
第二步,我们已经拿到了所有的文件名和对应的地址,就可以一个一个的读取文件,读取文件的目的,就是对文件进行去标签,然后提取出我们需要的内容,那我们就可以定义一个结构体表示每一个文件被解析后的格式,然后把这些被处理过后的文件存在一个vector中组织起来。——我们用函数ParseHtml实现
第三步,文件解析完成,我们就可以把解析好的文件存放到我们指定的raw.txt文件中了,这时就要注意我们自己定义的区分文档的方法。——我们用函数SaveHtml实现
1 #include <iostream>
2 #include <string>
3 #include <vector>
4
5 //目录下面放的是所以的html网页
6 const std::string source_path="data/input/";//数据源目录
7 const std::string raw_txt="data/raw_html/raw.txt";//处理后的文件
8
9 //定义文件被解析的格式
10 typedef struct Format{
11 std::string title; //文档标题
12 std::string describes;//文档内容介绍
13 std::string url; //文裆网页
14 }Format_t;
15
16 int main()
17 {
18 std::vector<std::string> file_name_list;//用来存放所有的文件名和其路径
19 //第一步
20 //递归式的把每个html文件名+路径保存到file_name_list中。方便后期对一个一个的文件进行读取
E> 21 if(!EnumFile(source_path,&file_name_list));
22 {
23 std::cout<<"Enum error"<<std::endl;
24 return 1;
25 }
26 //第二步
27 //根据file_name_list读取每个文件,并按照内容进行解析
28 std::vector<Format_t> result;//用来保存解析后的结果
E> 29 if(!ParseHtml(file_name_list,result)){
30 std::cout<<"parse error"<<std::endl;
31 return 2;
32 }
33 //第三步
34 //把解析完成的各个文件的内容写入到raw.txt中,按照'\3'作为每个文档的分隔符
E> 35 if(!SaveHtml(result,&raw_txt)){
36 std::cout<<"save error"<<std::endl;
37 return 3;
38 }
39 return 0;
40 }
4.枚举文件名模块的编写
枚举文件名我们使用的是EnumFile函数。
因为C++和STL对文件操作的支持不太好,所以要完成这样的动作,需要使用到boost库的file system模块。
boost开发库的安装
sudo yum install -y boost-devel
这就安装完成了
可以上boost官网查看手册了解boost的相关接口怎么用。注意,我们安装的是1.53的版本,所以要查1.53版本的手册。我们重点要找的是Filesystem
点进去后就是一个filesystem的教程,但是这里面只有一部分的函数。如果想要更详细的了解,可以在下面随便点击一个函数,就可以看到file system库相关的接口说明了
头文件:
首先我们要定义一个path对象,然后从这个对象的地方开始遍历,那我就要先判断这个路径是否合法,使用的方法叫做exists。
递归遍历文件,可以定义一个boost库的recursive_directory_iterator对象,可以让我们像用迭代器一样来遍历文件。
遍历拿到的文件可能有各种各样的类型,所以我们对这些文件名进行一下筛选,使用的方法是is_regular_file表示是否是常规文件
常规文件中,我们只要文件路径后缀为html的文件,所以还需要进行一次过滤,可以用迭代器里的path()方法用来提取当前路径字符串,然后用extension()方法提取路径的后缀,判断是否符合要求
拿到所以符合的html文件以后,就要把这些文件push到file_name_list中,因为现在是一个path对象,所以要push就要先用path的string方法把对象转换成一个string。
这系列操作完成后,先不急往下写,先测试一下代码有没有问题,可以把每条即将插入到file_name_list的路径打印一下。
以下是EnumFile方法的实现
因为我们使用的boost库,所以编译时除了要加-std=c++11之外,还要加上以下两条
-lboost_system
-lboost_filesystem
编译完成,就可以查看到我们的文件是依赖boost库的
完成之后,运行parser,如果成功打印了所有的html文件,就说明当前代码是没问题的。
查看打印的文件数量:./parser | wc -l
如果还不放心,可以查看打印了多少文件。
接下来就是解析html的编写
5.html文件解析模块的编写
解析html文件我们使用的函数是ParseHtml
要解析文件,肯定要对文件的内容进行遍历,把内容读出来,然后再解析。我们ParseHtml的功能就是通过我们上上面拿到的文件名+路径,对每个文件进行解析,解析成Format_t的形式存放到叫result的vector中。Format_t包含文档的标题,内容和url。
首先,遍历file_name_list中的文件名+路径。
第一步:读取文件,可以用一个ReadFile函数把读取文件的所有内容
为了方便管理,可以定义一个Tool.hpp作为工具集,在里面定义一个FileTool类,而ReadFile就可以写成该类的静态成员函数。
读取出来的文件可以放在一个string中,那我们就可以再项目目录下创建一个util.hpp文件,里面存放我们使用过的工具
第二步:解析指定的文件,提取title
怎么找到文档的标题?
一般title只有一个,是双标签之间的内容
我们就可以写一个ParseTitle函数从读取的文件中解析出title
提取文档内容,本质就是去标签,只保留网页内容的部分
第四步:解析指定的文件路径,构建url
下面就是ParseHtml的基本结构
这里向result里push解析结果时,这个结果可能会比较大,而直接push会发生一次拷贝,所以可以使用C++的move,以减少拷贝,那么这句代码就可以这样改写
result->push_back(std::move(fmt));
主要包含4个函数,ReadFile:文件读取、ParseTitle:解析标题、ParseDsecribles:解析内容、ParesUrl:解析并构建url。因为这些函数基本都只在我当前文件使用所以我把它们写成静态的。
下面分别对这四个内容进行实现
(1).html文件读取代码的编写
可以使用C++的文件操作对传入的文件名进行读取。
首先初始化一个ifstream对象,以in的方式打开路径对应的文件。
打开完成后,用getline读取输入流中的内容,这里可以哟用while循环,在判断的地方读取,虽然getline的返回值是一个引用对象,而while判断的是bool类型,但是可以这样写的原因是因为返回的对象中重载了强制类型转换。所以就可以通过getline返回引用的方式判断文件结尾。
(2).解析title代码的编写
要解析title,其实就是在整个文档里搜索< title> 关键字和< /title>关键字,可以通过string的find方法找到两个关键字的位置,然后第一个title的位置向后移动该关键字的长度,就是我们需要部分的第一个元素的位置,再组合上第二个关键字的起始位置,就可以形成一个左闭右开区间,从而提取出title。
代码如下:
(3).去标签代码的编写
要把文档中双标签,单标签,也就是凡是< xxx >和< /xxx >内部的xxx的内容全部去掉,正常的标签上的数据都保留。
文档读取到string上,就是一个个的字符,而我们字符向后遍历的过程中,要么就是在读取标签的内容,要么就是在读取我们需要的内容,所以实际实现的过程中我们要基于一个简易的状态机来编写。
定义LABEL(标签)和CONTENT(内容)两个状态。
文件的第一个字符肯定是<,所以初始状态为LABEL。
当我的状态是LABEL遍历文件内容时,只要碰到了>,就意味着当前的标签被处理完了,就可以把状态置为CONTENT,
当前标签结束,下一个位置可能是正常内容,也可能是下一个标签的开始,所以当状态为CONTENT时,要判断当前字符是否是<,如果是,则表示即将进入LABEL状态。如果不是<,则可以把当前字符push进参数describe中。
这里有一点需要注意,我们不想保留原始文件的\n,因为我们想用\n作为html解析之后的文本分隔符,所以这里我们不读\n。
(4).构建URL代码的编写并测试
我们的文档是从官网上下载下来的,而官网的url的路径和我们的路径其实是有一定关联的。
这是我从boost库的官方文档随便打开的一个文件,可以看到他的url是这样的:
而在我们下载的boost文档中,我们也可以在文档里面的doc/html目录下找到该文件
把这些地址罗列下来:
官网:https://www.boost.org/doc/libs/1_79_0/doc/html/accumulators.html
下载的路径:boost_searcher/boost_1_79_0/doc/html/accumulators.html
在项目里的路径:data/input/accumulators.html
//我们把boost文档的html目录下的内容都拷贝进了data/input/目录下
所以我们要拼接的url应该分为两个部分:
url_head = https://www.boost.org/doc/libs/1_79_0/doc/html;
url_tail = [data/input](删除掉)/accumulators.html->/accumulators.html
url= url_head+url_tail;
所以接下来我们的任务就是我们要构建出一个url,那ParserUrl的参数就一定要有我们在外面构建的结构的url成员,并且这是一个输出型参数。
url_head的部分是不变的,所以我们可以把这部分直接写在代码里
要截取我们自己路径下的后半部分,我们可以在文件的路径名截,而所有文件的路径名我们已经在第一步列举文件名是存放在了file_name_list中,把这里的路径传进函数然后用string的substr方法直接构建出url_tail,然后拼接起来即可。
文件解析模块的测试
以上我们就完成了文件解析模块的编写,现在可以对该模块进行测试。可以写一个PrintFmt,把我们解析的结果打印出来
但是如果我们全部打印的化,我们预计打印的量会非常大,也不方便我们观察,所有我们可以设计打印一个或者几个观察以下就可
这是我打印出来的一个文档:
可以看到内容方面是正常的,已经不存在标签的内容了,再用url去官网验证以下
可以基本确认我们的代码是没有问题的。
完成了文件列举和文件解析,下一步就是把我们解析的结果写入到我们的目标文件中。
6.文件写入模块的编写
现在我们要做的就是把在存储在result的所有解析好的文件的内容写入到我们的文件中,数据源和目标文件我们都有了,那其实就是文件操作,但是有几点要注意以下
我们之前的规定是每个文件之间用’\3’间隔,但是我们解析出来的文件包含三个部分,如果把他们放在一起,写的时候没问题,但是读取的时候就很麻烦,那我写的时候,就可以在每一个部分的后面增加标识,而string里面有一种方法叫getline,那为了我们操作方便,我们可以更改一下标识的意义,让’\n’作为文件与文件的间隔,'\3’作为文件中每个部分的间隔。
文档保存的内容:title/3describes/3url/ntitle/3describes/3url/n......
剩下就是编码工作
首先要打开我们的目标文件,这里我们可以用二进制方式写入,因为二进制方式的最大特点是写入什么,文档里就保存什么,用别的方式也可以
以上我们就完成了对Parser模块的编写,去验证一下raw.txt文件
8171行,对应8171个文件,打开这个文件我们就可以看到这里面就是我们解析之后的结果
六.编写建立索引的模块Index
1.搭建索引代码基本结构
建立索引,那我们就要把正排索引和倒排索引都建立好,所以我们的index里面一定要有正排索引和倒排索引的结果!,可以用一个类来组织索引。
成员变量:
正排索引是通过文档id找到文档,那我们就可以用数组来组织正排索引的结构,数组下标表示文档id,然后用我们定义的文档结构struct Format表示文档,这里的文档结构可以因为要与文档id关联,所以还要在原来三个元素的基础上再加上文档id,而为了防止文档id出现不必要的错误,可以用更大的数据比如uint64_t表示文档id。
倒排索引是通过关键词找文档id,但是一个关键词可能出现在很多文档id中,如何表达他们的先后,就还需要一个值代表该关键词下的该文档id权重,这三个数据也可以用一个结构体组织起来,可以定义为倒排节点,整个倒排索引就可以用一个KV结构的哈希表组织起来,K代表用来查找关键字,V可以是一个数组,里面就是某一个关键字能够查找出来的所有的文档id、id的权值等信息组织起来的一个个节点,可以把这个结构定义为倒排拉链。
成员方法
一定要有的两个方法,获得正排索引和获得倒排索引,
正排索引就是通过文档id找到文档内容,所以参数就是文档id,返回的就是该文档id对应的文档,还是用我们前面定义的结构表示文档。
要获得正排索引的方式也非常简单,因为我们构建索引肯定是连续构建的,所以我们只需要判断我们用来查找的文档id是否合法,如果合法,那我们直接从我们的正排索引返回id对应的文档即可,如果不合法,就返回空,并打印原因。
倒排索引就是通过关键字找文档id,所以参数就是要查找的关键字,而关键字与文档id的关系我们已经在上面把他们定义成了倒排拉链,所以我们这里返回的就是我们构建的该关键字的倒排拉链。
实现方法也比较简单,直接用查找方法看能否在哈希表中找到该关键字的倒排拉链,找到就返回,找不到就返回空。
最后,我们还需要的一个方法就是建立索引,建立索引需要的肯定就是我们经过Parser处理后的数据了,可以返回一个bool值判断是否建立成功,其实现比较复杂,后面再详细说
结构如下图
(这里的index类名最好首字母大写:Index,方便区分类与对象)
2.建立索引代码的编写
首先我们就要把我们之前处理好的文件打开,因为我们之前已经定义好了文件之间用’\n’分隔,所以直接getline就可以读取到一个文件的信息
建立正排索引,我们直接把读取到的文档的对应的内容解析出来,填写到对应文件的结构中,然后把解析的文档数据push进我们的正排索引即可
而建立倒排索引,需要对我们的每一个文档的title,describe相关内容进行分词,分词的操作我们可以自己完成,也可以使用cppjieba完成分词。
我们的操作就是不断的getline,然后先构建正排。正排完成后
(1).正排索引
需要根据我们输入的一行文件内容进行构建,我们构建正排的本质就是构建出一个Format_t,然后把里面的值填好,把这个构建好的数据插入到正排的vector中,而我们正好可以用vector的下标充当我们的文档id
第一步:解析读到的文件内容,字符串切分
我们要把读到的字符串切分成title,describe,url三个部分,那就可以用一个vector把被切好的三个部分组织起来,那我们现在还需要一个字符串切割的功能,可以定义一个专门负责字符串切割的函数,可以把它放在之前写好的工具集文件中,定义一个类代表字符串工具,里面加入一个方法专门进行字符串切割
第二步:把切分好的字符串填充到Format_t中
第三步:插入到正排索引中
插入时可以用move减少拷贝,提高效率。插入完成后,我们在BuildIndex方法中计划的方法是用刚刚构建好的Format不断的构建倒排。所以我们返回的应该是我们刚刚构建好的Format。
其结构如下:
字符串切分代码的编写
字符串切分的工作可以我们自己写,也可以用boost库中的boost split函数完成切分,其头文件为< boost/algorithm/string.hpp>
不建议使用strtok接口,虽然也可以完成,但是它会对原始字符串做修改,所以这里使用split
如何使用?
boost::split(result, target,boost::is_any_of("sep"), boost::token_compress_on);
result:分割出来以后是多个结果,所以是vector类型
target:要被分割的内容
is_any_of(""):凡是这里的任何一个字符都作为分隔符
token_compress_on:选项,选择是否需要压缩(比如多个分隔符相连,相邻两个分隔符之间没有内容,
不压缩就认为内容是空,就会保留一个空,压缩就不保留),不写就是默认,等于token_compress_off
(2).倒排索引
上一步正排完成了对字符串的解析,而这个解析就包含了文件的title,describe,url,file_id的内容,我们现在的任务就是通过这些信息,完成从关键词到倒排拉链的映射,就需要我们对title,describe的内容进行分析,比如统计一下词频等
倒排索引的基本原理
首先我们先列出与倒排相关的几个数据结构
//我们拿到的文档内容
typedef struct Format
{
std::string title; //文档标题
std::string describe;//文档去标签之后的内容
std::string url; //文档url
uint64_t file_id; //文件id
}Format_t;
//把倒排的关键字和文档id,权重捆绑在一起
struct InvertNode
{
std::string key_word;
uint64_t file_id;
int weight;
};
//倒排拉链
typedef std::vector InvertList;
//关键字与倒排拉链的映射关系
std::unordered_map inverted_index;
InvertNode表示的就是关键词与文档之间的关系,我们倒排的最终目的就是要通过文档内容,建立一个或多个InvertNode!!
因为一个文档的标题和内容都可能包含很多词,而每一个词都可能在很多文档中出现
但是由于我们是一个文档一个文档建立的,索引我们每一次建立的应该是当前文档里所有的关键词与当前文档的关系。
而与我们搜索强相关的是就是标题和内容,
第一步:我们需要对title和describe都要先分词
第二步:词频统计
如果一个词在文档中的出现次数特别多,那它被搜索时也应该高优先级的搜索出来。也就是词和文档的相关性。
相关性实际在衡量时有多种维度,所以大多数的搜索引擎的相关性设置上非常复杂,并且需要有大量的积累,所以我们这里就简单的用词频设计相关性,一个词的出现频率越高,其相关性就越高。但是我们认为:在标题中出现的词,其相关性会比内容中出现的词更高一些。相关性就是我们设计Format中的weight。
cppjieba的使用
我们分词使用的工具就是cppjieba
这是我下载下来的cppjieba的内容,其test里面有一个demo.cpp,里面就是使用方法
这里面有它各种各样的分词方法,我们使用的是CutForSearch
在目录/cppjieba/dict目录下存放的就是我们jieba库的词库
词库就是决定我们分词的标准,比如那些属于同一种词,如何分。所以使用它时这些词库也要能够被找到,所以这个dict目录就要能够在我们的程序中被看到,可以使用软连接的方法在项目目录下建立对应的软连接
ln -s ~/mycode/cppjieba/dict dict
前面的cppjieba之前的部分是我的jieba库保存的地方
除了dict目录,include目录下的cppjieba里面包含的头文件也要让我们的程序看到,也可以建立对应的软连接
ln -s ~/mycode/cppjieba/include/cppjieba cppjieba
在使用之前有一点需要注意,我们待自己把cppjieba目录下的deps里的limonp拷贝到include目录的cppjieba目录下,否则就会编译报错。
cd cppjieba: cp -rf deps/limonp include/cppjieba/
因为在这个项目中除了建立倒排索引需要分词,在我们搜索的时候也需要分词,所以我们可以把jieba分词封装到我们的工具集中。
所以我们就可以直接在工具集中编写一个类JiebaTool,首先包含头文件 “cppjieba/jieba.hpp”;
其使用方法可以借鉴demo,首先要定义一个jieba对象,然后还要包含词库,要注意词库的的路径不能出错(根据自己的情况写),否侧编译可以通过但是运行就会报错!因为我们刚刚已经在当前目录下建立好了软连接,所以词库的路径如下:
const char* const DICT_PATH = "./dict/jieba.dict.utf8";
const char* const HMM_PATH = "./dict/hmm_model.utf8";
const char* const USER_DICT_PATH = "./dict/user.dict.utf8";
const char* const IDF_PATH = "./dict/idf.utf8";
const char* const STOP_WORD_PATH = "./dict/stop_words.utf8";
首先要有一个jieba对象,然后调用该对象的CutForSearch方法,我们把这些参数传进来就可以,而为了不让我们每一次分词都创建一个jieba对象,可以把该对象定义成静态,如下:
倒排索引代码的编写
在我们循环遍历文件建立正排时,建立完正排的结果都会交给我们,那我们的倒排就可以拿到文档,首先我们要先对标题和内容部分进行分词,并且分词的时候进行词频统计。
那就可先定义一个结构,里面有两个int成员用来表示一个关键词分别在标题于文档中的出现次数,以便于后面我们设置权重。而关键词与词频的映射关系可以用哈希表建立,方便我们用关键词去查找并计数。
然后我们就可以着手用我们封装的jieba分词对文档的标题和内容进行分词,分词完成后遍历分词的结果,然后统计在两个地方出现次数。
在此之前,还有一点需要注意,我们可以观察一下,搜索引擎在搜索的时候其实是不区分大小写的,所以我们也不区分大小写,那我就可以建立索引的时候就可以统一大小写,并且将来客户输入关键字搜索时,我也要先统一大小写再搜索,可以使用boost库的to_lower方法实现,并且呈现上我不修改文档内容,只是在搜索算法上统一使用小写搜索。
词频统计完成后,我们就要着手建立倒排索引了,我们设计的倒排索引是一个哈希表,是关键词与哈希桶之间的映射。哈希桶中存放的我们叫做节点,节点表示的就是关键词,文档id,和该关键词在该文档中的权重,那我们现在就可以用我们当前文档的信息,构建出当前文档的所有关键词与当前文档id映射和权值,构建好了以后,可以使用unordered_map的[]的特性,直接用我们构建的关键字在倒排索引中寻找该关键字对应的倒排拉链,然后把构建好的节点插入到该拉链上,循环此过程,就可以完成当前文档的所有关键词倒排索引的建立。
七.编写搜索引擎模块Searcher
1.index单例与searcher基本代码结构
既然index里面已经提供了build方法,那我在searcher里面首先就是执行build任务,build任务的本质就是把磁盘上已经去标签化的文档以索引的形式加载到内存中,首先这是比较大的,再其次我们需要有一个调用的过程,而搜索引擎的索引只有一份就够了,所以我们可以把Index设计成单例模式,让searcher直接获取单例就可以了
index要build,是需要那个被处理过的文档的,所以Seacher的初始化首先就需要得到这个文档然后传给index,让index去建立索引。
然后就是Search查找代码的编写,首先我们要拿到用户输入的查找词,一定是一个字符串,然后我们的工作就是通过这个语句,构建出一个json串返回给用户。一共分为4步,首先要对用户的查找内容进行分词,找到里面的关键词,然后根据这些关键字在索引里查找,找到文件id与其权值,然后按照权值将文件进行降序排列,再通过查找的结果,构建一个json串,以便给用户返回结果。
这些是基本方法,后面还会新增方法
如何把index设计成单例
把Index的构造函数私有。拷贝构造,赋值重载直接delete
然后成员变量处增加一个static的Index指针,在类外初始化,然后编写GetInstance方法获取单例,考虑到线程安全问题,还要再加一个静态的锁,所以这个锁也要在类外初始化,然后双判断,如果指针为空就new一个对象,不为空直接返回对象,完成编写
然后在Seacher模块里,只需要用类域直接调用GetInstance就可以获得单例index,然后使用index的Build方法构建索引
2.Search查找代码的编写
首先要对用户搜索的内容进行分词,那就可以引入我们的工具集,调用里面我们写好的jieba分词,把分词的结果用一个vector组织起来
第二步,要根据关键词,找到他们对应的倒排拉链,如果该关键词找不到对应的倒排拉链,则表示该次没有在文件中出现过,继续找下一个词即可,而为了方便我们排序,我们可以把每一个词对应的倒排拉链组合在一起,组成一个包含搜索语句里所有关键词的一个大的倒排拉链,
有了整体的倒排拉链,我们再根据这里面的相关性,继续降序排序,可以使用sort即可。
排好序之后我就要根据这个组合拉链里的每一个元素去找相应的文档内容,然后通过jsoncpp对文档内容进行序列化与反序列化,
安装jsoncpp
sudo yum install -y jsoncpp-devel
头文件
json里有3个类
Value:序列化与反序列化的中间类,要先把原始数据转化成Value类,然后才能转化,可以用append方法把多个Value对象顺序添加到一起
Reader:反序列化,把一个string类型转化成Value类型
Writer:分为两种FastWriter和StyledWriter,使用writer方法就可以把一个Value类型转换成string类型通过返回值返回,第一种是转成了一个一行的字符串,传输比较快,第二种它虽然变成了字符串,但是看起来还是原来的样子
编译时要加-ljsoncpp
通过遍历组合拉链,然后使用正排索引找到每个节点对应的文档内容,再根据每个文档内容,构建Value类型的对象,把title,describe,url都放进去,这里的间接我们只要一部分,不全要,所以需要处理,然后把所以文件的value对象全都append到一个整体的Vaule对象中,直接序列化这个整体的对象即可,因为我们的文档是排好序的,所以将来使用的时候直接直接从头开始用就可以了。
(1).获取摘要
如果把文档内容都加上,那内容太多了,所以这里可以编写一个方法,用来根据文档内容获取文档的摘要
理论上获取摘可以用一个特别简单粗暴的方法,直接获取文档内容的前一部分,但是前一部分不一定有我们的搜索关键字,最好能在标题和摘要里都凸显一下关键字,所以形成摘要还需要我们传入关键字。我们现在是根据倒排拉链在进行序列化,而倒排拉链的节点里面就包含了该文档的关键字,以次来获取该文档的摘要
我们规定获取摘要的内容在文档中第一的出现的关键字的前50个到后150个一共200和字节之间,
搜索关键词位置时,不可以用find,因为find是区分大小写的,而我们建立索引的时候是使用小写建立的索引,并且没有修改原来的内容,如果用find搜,可能会出现大小写不匹配而找不到的情况,所以这里使用C++的一个可以忽略大小写的查找接口search
下面是代码:
八.调试使用的命令行式的搜索
这里可以先对上面的三个模块进行一下命令行测试
首先先创建一个Searcher对象,然后调用其InitSearcher方法初始化。这个初始化就会先获取单例的Index对象,然后根据我们传的处理好的结果建立索引。
然后我们就可以使用一个while循环,让用户输入搜索的内容,然后调用Search方法进行搜索,把结果打印出来。
为了方便观察,可以在Searcher模块获取单例index和索引建立成功后分别打印一条提示,然后在Index模块的BuildIndex函数中,设计一个计数器,然后每建立50个索引就打印一次提示。这样就可以看到代码运行的情况
我如何才能检测打印出来的内容是不是按照我们设置的相关性顺序打印的?
可以在searcher模块里面构建Value时加上权值,然后打印的时候就可以看到
我们可以发现,我们打印出来的权值确实是按顺序排序的,但是如果你用连接去官方文档找,然后自己计算一下会发现打印出来的权值可能会和自己计算的有一些差异,这有两个原因,首先是我们使用的jieba分词工具的分词方法可能和浏览器中分词方法不同,这就会导致最后计算权值时有一些差异,其次,我们的解析模块解析内容时,是先把整个文件都读到了内存,然后又对整个内容进行去标签,而这个去标签后的内容里面也是包含标题的!所以如果一个关键词在标题中出现了,那么它也一定会在内容中出现,也就是说标题中出现过的关键词统计权值时的值会比正常计算多一个。
实际的调试可以根据不同的情况进行,
九.编写http_server模块
1.引入cpp-httplib库
可以用来部署一个http服务,下载方法可以在gitee上直接搜索就可以找到
链接:https://gitee.com/sumert/cpp-httplib?_from=gitee_search
这个库只有一个头文件httplib.h
注意事项:cpp-httplib在使用的时候需要使用较新版本的gcc,而centos 7默认的gcc版本是4.8.5,直接用要么编译不通过,要么就运行时报错,centos为了确保工具集的稳定性,yum默认支持的工具一般都比较老,所以要获得新的编译器要是有scl工具集进行安装。
首先要安装scl源
sudo yum install centos-release-scl scl-utils-build
安装新版本的gcc,7以及7以上都可以
sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++
上面两步做完就可以使用了,可以在目录:/opt/rh/下看到安装的内容
启动
scl enable devtoolset-7 bash
gcc -v
这个命令行启动只在本次会话有效,重新登录以后又会回到老版本,要用新版本就待再启动,如果不想每次登录都启动一次,可以设置登录的时候就执行一次启动命令,可以用vim把该命令写在下面这个文件的后面
~/.bash_profile
这是登录的时候默认会执行的一个登录脚本,这就可以保证每次启动的时候都会执行这个命令。(最好不要写在全局)
然后就是安装cpp-httplib
最新的cpp-httplib在使用时,如果gcc版本不是特别新,也可能导致运行时错误的问题。建议使用cpp-httplib 0.7.15
在gitee标签里找到历史版本,就可以找到这个版本,可以直接把安装包下载下来。直接拖拽到终端或者使用rz -E命令,把安装包转移到云服务器上
解压:unzip cpp-httplib-v0.7.15.zip
然后可以把库拷贝到项目文件中,也可以建立软连接把库引入到项目文件下
cpp-httplib库的使用方法非常简单,在gitee仓库下面就有各种使用方法
这就完成了一个简单的http server,浏览器访问/hi资源就会返回Hello World!
web服务器需要有一个web根目录保存网页资源,所以还要在项目目录下新建一个wwwroot目录,在里面可以遍历index.html首页信息,然后使用httplib::Server类对象的set_base_dir()方法,()里面填上字符串形式的根目录的路径就可以设计根目录了。
2.http_server代码的编写
首先创建Searcher对象,然后调用InitSearcher方法获取单例index与建立索引。
然后创建Server对象用来构建服务,先调用set_base_dir方法设置web根目录。
然后调用Get方法,第一个参数设置成/s ,表示搜索,第二个参数可以用lambda表达式,第一个参数是Request类型(我简称为req),表示请求,第二个参数是Response类型(我简称为rsp),表示响应。
可以用req对象的has_param检查对端是否输入了word参数,如果没有参数,使用rsp对象调用set_content方法给用户返回一条提示信息。
获取到参数以后,可以使用req对象的get_param_value方法以string形式获得word参数的值,获取到之后服务端也可以打印一下。
得到用户的搜索内容,就可以调用search对象的Search方法进行搜索任务了,这里lambda表达式要使用前面定义的变量,要先在捕获列表把search对象捕获进来,调用Search方法前,创建一个字符串存储结果json串,然后rsp对象调用set_content方法给客户端返回一个json串,json串的格式为application/json
然后server对象调用listen方法,把我们的IP和开放的端口设置好即可。
Makefile也要做一下修改
这时候我们再把服务跑起来,在浏览器访问我们的IP+端口,在后面加上/s,如果直接访问,那就回给我们返回没有参数的提示,要加参数,就在后面加上一个?,然后添加word=[要搜索的内容],这时候再访问就可以看到服务端给浏览器返回的搜索结果了。
以上就完成了本项目所有的后端代码的编写。
十.编写前端模块
vscode工具的使用
编写前端我们使用的工具是vscode。
vscode是微软开发的一款编辑器,可以直接百度vscode的官网加载,如果下载很慢的化可以把下载链接替换成国内的镜像,可以参考这篇文章:https://www.zhihu.com/search?type=content&q=vscode%20%E4%B8%8B%E8%BD%BD%E6%85%A2
可以在本地新建文件夹写,写好之后上传到云服务器,也使用remote-ssh插件远程连接云服务器直接把内容写在云服务器上
因为vscode和云服务器是可以同步的,所有在编写前端网页时,可以让服务器一直跑着,然后把网页同步上去,就可以直接在浏览器访问或刷新,比较方便
1.编写html
要包括的内容:标题,输入框,按钮(可以点击),显示搜索的内容的标题,简介和url。
<body>
<div class="container">
<div class="search">
<input type="text" value="输入搜索关键字...">
<button>搜索一下button>
div>
<div class="result">
<div class="item">
<a href="#">标题a>
<p>摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要p>
<i>https://www.baidu.com/i>
div>
<div class="item">
<a href="#">标题a>
<p>摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要p>
<i>https://www.baidu.com/i>
div>
<div class="item">
<a href="#">标题a>
<p>摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要p>
<i>https://www.baidu.com/i>
div>
<div class="item">
<a href="#">标题a>
<p>摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要p>
<i>https://www.baidu.com/i>
div>
<div class="item">
<a href="#">标题a>
<p>摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要p>
<i>https://www.baidu.com/i>
div>
<div class="item">
<a href="#">标题a>
<p>摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要摘要p>
<i>https://www.baidu.com/i>
div>
div>
div>
body>
2.编写css样式
去除内外边距
<style>
/*去掉网页的默认内外边距*/
*{
margin:0;
/* 设置外边距 */
padding:0;
/* 设置内边距 */
}
/* boody内容和网页呈现吻合 */
html,
body{
height: 100%;
}
/*以.开头:类选择器 */
.container{
width: 800px; /*设置div的宽度 */
margin:0px auto; /*如果是两个,第一个代表上下,第二个是左右,auto左右会自动对其*/
margin-top:15px; /*设置外边距的上边距,让元素和网页上部有一定距离*/
}
/*复合选择器,选中container下的search*/
.container .search{
width: 100%; /*宽度与父标签保持一致*/
height: 52px; /*高度设置为52像素点*/
}
/*先选中input标签,再设置标签的属性:标签选择器*/
.container .search input{
float: left;/*设置left浮动,可以把两个盒子之间的距离清0*/
width: 650px;
height: 35px; /*input在进行高度设置时,没有考虑边框,这是内部的高度*/
border: 1px solid black; /*设置边框大小,样式与颜色*/
border-right: none; /*去掉右边框*/
padding-left: 10px; /*设置搜索框内的内容与搜索框左侧的距离*/
color:#ccc; /*设置默认字体的颜色*/
font-size: 15px; /*设置字体大小*/
}
/*先选中button标签,再设置标签的属性:标签选择器*/
.container .search button{
float: left;
width: 100px;
height: 37px;
background-color: #0b8b0d; /* 设置按钮背景颜色*/
color: #fff; /*设置字体颜色*/
font-size: 17px; /*设置字体大小*/
font-family:'Courier New', Courier, monospace;/*设置字体*/
}
/* 设置result显示内容的属性*/
.container .result{
width: 100%; /*完全继承父父标签*/
}
.container .result .item{
margin-top: 15px;/*设置外边距的上边距*/
}
.container .result .item a{
display: block; /*设置成块级元素,可以独占一行*/
text-decoration: none;/*去掉标题的下横线*/
font-size: 18px;/*把标题字体修改大一些*/
color: #0b8b0d;/*设置标题颜色*/
}
/*设置鼠标放在a上的动态效果*/
.container .result .item a:hover{
text-decoration: underline;/*光标事件,鼠标放在标题可以有一个加下横线的结果*/
}
.container .result .item p{
margin-top: 5px;/*p标签的上边距,让内容和标题之间有一定距离*/
font-size: 14px;
}
.container .result .item i{
display: block; /*设置成块级元素,可以独占一行*/
font-style: normal;/*取消斜体风格*/
color: #ccc;/*设置颜色*/
font-size: 14px;
}
style>
3.编写js前后端交互
使用原始的js成本比较高,推荐使用JQuery。
搜索jquery CDN,找到一个js的外部链接,粘贴到head中
发起网页请求时,除了可以获得首页,浏览器还会自动获得这个js文件,这就是jquery库
因为我们的目的是根据搜索结果生成动态的网页,所以上面的CSS中的测试网页就可以全注释掉了
<script>
function Search()
{
//alert("helli js");/*弹窗*/
//1.提取数据
//$可以理解为jQuery的别称
let aim=$(".container .search input").val();//拿到input里的参数
console.log("aim = "+aim);//console是浏览器的对话框,可以用来查看js数据
//2.发起http请求
//ajax属于jquary中的一个和后端进行数据交互的函数
$.ajax({
type:"GET",//GET方法
url:"/s?word="+aim,
success: function(data){//拿到的结果就在data里
console.log(data);
BuildHtml(data);//用从后端获取的结果,构建一个新网页
}
})
}
function BuildHtml(data)
{
//获取html中的result标签
let result_lable=$(".container .result");
//清空历史搜索结果
result_lable.empty();
//遍历json串
for( let elem of data)
{
// console.log(elem.title);
// console.log(elem.url);
let a_lable=$("",{
text:elem.title,
href:elem.url,//链接到url
target:"_blank"//跳转到新页面
});
let p_lable=$(""
,{
text:elem.describe+"..."
});
let i_lable=$("",{
text:elem.url
});
let div_lable=$("",{
class:"item"
});
a_lable.appendTo(div_lable);
p_lable.appendTo(div_lable);
i_lable.appendTo(div_lable);
div_lable.appendTo(result_lable);
}
}
</script>
十一.项目的优化
1.搜到重复内容问题
用上面的代码,在搜索时可能会出现重复结果。
因为我们的searcher模块在搜索获取倒排拉链时,使用的是一个一个分好的词搜的,如果我搜索的内容的有好几个词都出现在了文档A中,那在建立倒排拉链时这几个关键词都可以关联到这个文档A上,那就会各自在自己的倒排拉链上绑定这个文档,最终我们对内容进行排序时会把拉链合并起来,然后排序,那这时候这个合并的拉链里面就会包含好几个该文档A,将来搜索就会搜出好几遍该文档对应的网页,造成重复的问题。
解决思路:
1.把搜索到的id相同的文档合并
2.被重复搜索的文档的权值累加,作为新的权值
3.建立倒排时的节点一个文档id只对应一个word,这里的word需要特殊处理
首先在searcher模块定义一个用来显示的倒排拉链节点,把这个节点作为组成最终返回给客户的整体倒排拉链的节点。里面包含的元素就是file_id,weight,还有关键词,这个关键词就不能是一个词了,导致文件被重复搜索的原因就是在同一个文件中的几个关键字都对应了一遍file_id,形成了多个节点,所以这里用一个vector,同一个文件的所有关键词只生成一个节点,那搜索时也就不会出现重复文档了。
具体去重的方法可以使用一个unoredred_map,通过file_id去重,里面的V就是我们各个定义的新节点,然后searcher在通过分词的结果使用每一个关键词找到倒排拉链时,就不能直接把拉链push到组合拉链了,要先根据拉链中的文档id,在map中找到新节点的位置,然后对新节点进行修改,最终遍历以便所有通过倒排索引找出来的倒排拉链,把其中的每一个节点都进行一次去重,得到的unordered_map就是去重过后的搜索结果,再把这个结果转移到组合拉链中,再进行排序,构建json串等工作,这样就达到了去重的目的。
并且这里的序列化方法也可以改成FastWriter,StyledWriter是为了让我们好调试,现在所有的代码都已经完成,为了效率就可以改成FastWriter了。
2.添加日志
可以编写一个log.hpp文件,首先可以定义一些宏常量,表示日志的等级,比如NORMAL代表正常,WORNING代表警告,DEBUG代表调试,ERROR代表错误等等。
然后我们先写一个log函数,功能要可以打印日志等级,日志时间,日志内容和日志的文件名和行数。时间可以由函数获取,剩下的4个则需要用参数传进来。
获取时间的方法可以先用time函数获取时间戳,然后用strftime函数把时间转化成字符串存在缓冲区,然后把缓冲区的内容打印出来就可以。
调用函数可以写成一个宏,只需要两个参数,等级和内容,因为等级我们定义成了常量,所以要把输入的参数用#变成字符串,内容自己输入对应的内容即可,剩下两个参数文件和行数可以使用宏__FILE__和__LINE__得到,接下来在各个文件中包含我们的log.hpp文件,然后我们就可以在我们上面的代码中调用就可以,以index模块中我们查看建立索引进度的代码为例:
原代码:std::cout<<"当前已经建立索引的文档: "<
这样修改我们之前打印消息的地方,重新编译,效果如下:
当然除了这些地方,也可以在代码别的位置加入你想要打印到日志中的内容。
代码如下:
日志补充完成后,可以运行以下命令:
nohup ./http_server > log.txt 2>&1 &
启动进程时以守护进程的方式让服务运行起来,并且会把日志信息打印到log.txt文件中,部署服务到Linux。
十二.项目的扩展方向
1.我们建库使用的是doc目录下的html文件里的内容,除了这里,别的地方其实也还有,所以想要也可以建立整站搜索,只是这样可能对我们的服务器占用会比较大。更狠的话你可以把boost的所有版本都下载下来,然后所有的内容都建立正排倒排。
2.我们获取数据源的方式是通过下载,也可以在合法的范围内使用爬虫爬取一些资源建库,使用信号等方式,设计一个在线更新方案。
3.我们用的很多现成的组件,可以自己设计对应的方案
4.在搜索引擎中,添加竞价排名(拼接结果时可以把广告的权值设计的特别大)
5.搜索词的热词,可以做一下热词统计,当用户输入热词的前缀时,可以把热词都显示在搜索框中供用户选择。智能显示搜索关键词。(可以使用字典树或优先级队列)
6.设置登录注册,引入对MySQL的使用。