目录
1.项目简介
我们学习C++或许会用到Boost这样非常成熟的第三方库,所以时常回去Boost官网看文档,但我们发现官网是没有站内搜索功能的,所以我们的项目就是为Boost官网设计一个搜索引擎,实现对一部分文档的搜索功能,,就如同大型的搜索引擎如百度一样,我们都是在搜索框中输入搜索关键字,点击搜索,就会展示搜索结果给我们,一般搜索会有很多结果,每个结果都是标题、摘要和链接,百度搜索的效果如下图
我们点击标题就可以跳转到我们想去的页面了。当然我们不可能实现百度这样规模的搜索引擎,我们只能是做一个Boost的站内搜索这样简单搜索查看相关文档的搜索引擎,下面展示我们项目实际的效果。
点击标题就能跳转到Boost官网相应的页面进行浏览。
2.所用技术和开发环境
技术栈:C/C++、C++11、STL标准库、Boost准标准库、Json序列表工具、cppjieba第三方库分词工具、cpp-httplib网络库,前端方面的简单使用、jQuery、Ajax
项目环境:Centos 7云服务器,vim/g++/makefile、vscode
3. 宏观原理讲解
一般自然都是服务器启动后,我们使用电脑或手机在浏览器中访问我们的服务器
一般我们进行搜索就是要请求某个网页资源,我们要看文档,图片、音频或下载资源,这些都在html文件中已经编排好了,这里我们就要上Boost官网提前把相应的html文件下载下来,就整个网站的html文档资源数量也是相当大的,考虑我们机器性能的因素,我们就下boost_1_79_0/doc/html目录下的html文件,该目录下总共也有8172个html文件,所以我们就是根据搜索关键字返回该目录下的相关资源。
4.正排索引和倒排索引概念
我们规定一个html文件就对应一个文档,文档中肯定包含了标题和内容,同时我们要形成链接,通过标题、内容摘要和链接构成了一个搜索结果,构建响应返回。正排索引就是我们定义一个结构体类型,成员数据包括文档id、文档标题、文档内容和url,每个文档对应一个正排索引,在内存中建立正排索引就是形成一个该结构体类型的大数组。
正排索引:使用id找到对应文档
文档id | 文档内容 |
---|---|
1 | 中国现在动作类型的电影越来越少 |
2 | 让更多的电影人创造出更多中国的骄傲 |
倒排索引:对文档内容进行分词,整理不重复的关键字,对应联系到文档id
关键字 | 文档id,权重 |
---|---|
中国 | 文档1,文档2 |
动作 | 文档1 |
电影 | 文档1,文档2 |
类型 | 文档1 |
骄傲 | 文档2 |
我们就是要先对原始的所有html进行数据清洗,形成原始内容,放在一个raw.txt中可以用换行符分割每个文档的内容,内容中我们又可以用特殊符号如’\3’分割我们截取的标题、正文和url,然后又对这个raw.txt按行读取,形成每个文档的正排结构体,建立正排索引,之后就是对正排结构体的标题、内容做分词形成倒排索引, 之后对用户的搜索关键字分词后,按分词结果获取对应倒排拉链,按关键字权重排序,通过id找到文档内容,获取标题,形成摘要在和url一起构建相应结果返回。所以我们项目大致有数据清洗、去标签的parser模块、建立索引的index模块、对用户关键字分词检索对应内容的搜索模块,还有工具模块、日志模块,下面一一进行讲解
主要文件
parser.cc
tool.hpp
index.hpp
searcher.hpp
search_http.cc
log.hpp
各模块层次关系
5.parser数据清洗模块
我们把在boost官网boost_1_79_0/doc/html目录下的所有文件都放在了我们本地该项目当前目录./data/html/这个目录下。
为了后续建立索引,我们要对该目录下所有的html文件进行数据清洗,形成的文件raw.txt也放在项目data/raw/目录下。
这里我们就要对所有该目录下的html进行读取,加载到内容中做处理,读取文件我们就要知道对应的路径加文件名,所有这里使用一下boost库中的文件操作方法,没有安装boost库就需要安装一下。
#include<iostream>
#include<string>
#include<vector>
#include<boost/filesystem.hpp> //引入boost库中的文件操作方法
#include"tool.hpp" //一下公共方法如读写文件字符串分割我们在工具类中实现
using namespace std;
//在parser.cc 中首先确定两个路径
const string src_html="data/html/"; //我们所有html文件的存放路径
const string dest_raw="data/raw/raw.txt"; //最后清洗完的结果放在指定文件中
typedef struct htmldoc //定义对单个文档内容进行管理的结构体,
{ //在后面将所有文档对应的该结构体内容写进我们的raw.txt
string title; // 这样就完成了清洗工作,清洗工作我们去掉所有html的标签,保存标题、正文,
string content; //url 去标签是为了保存元素内容方便后面对标题和正文做分词工作,获取url
string url; //方便后面搜集访问时跳转到官网指定页面浏览
}htmldoc;
//主要实现三个大方法,第一,使用boost库文件操作方法递归式的穷举该目录
//所有的后缀为html的文件路径,路径是从当前目录开始显示,我们保存所有的
//文件路径,得到路径后我们就可以按个读取html文件使用htmldoc进行管理了
//第二 承接上面的方法,我们现在有了文件路径,开始解析每一个html文件,
//形成一个vector<htmldoc>的数字,这样就拿到了文件清洗的内容了
//第三 就是保存vector<htmldoc>的内容到raw.txt中 三个函数先声明如下
//约定 输入性参数const 类型&
//输出型参数 类型*
bool EnumDoc(const string&src_html,vector<string>*filelist);
bool ParserFile(const vector<string>&files_list,vector<htmldoc>*result);
bool SaveFile(const string&dest_raw,const vector<htmldoc>&results);
int main()
{
vector<string>files_list; //穷举文件路径放在该数组中
if(!EnumDoc(src_html,&files_list)) //执行方法,失败就返回不执行后续方法了
{
cerr<<"enum filename fail"<<endl;
return ;
}
vector<htmldoc>results; //方法二处理形成的内容保存在该数组中
if(!ParserFile(files_list,&results)) //同样失败就提前终止
{
cerr<<"parser file fail"<<endl;
return 2;
}
if(!SaveFile(dest_raw,results)) //最后一步形成raw.txt文件
{
cerr<<"save file fail"<<endl;
return 3;
}
return 0;
}
//把src_html路径目录下的所用html文件名加路径都保存在一个vector中
bool EnumDoc(const string&src_html,vector<string>*filelist)
{
namespace fs=boost::filesystem; //这里用到boost库中的文件操作方法
fs::path root_path(src_html); //用路径字符串创建一个path对象,参数就是我们的目录路径
if(!fs::exists(root_path)) //这个函数就是判断该路径存不存在 参数是路径对象
{
cerr<<src_html<<"not exists"<<endl;
return false;
}
fs::recursive_directory_iterator end; //定义一个迭代器不指向谁,相当于空指针,用来判断我们遍历文件是否到了结尾
for(fs::recursive_directory_iterator itera(root_path);itera!=end;++itera)
{ //这里就递归式的获取文件路径
if(!fs::is_regular_file(*itera)) //这个方法用来判断指向的路径是不是一个正常文件
continue;
if(itera->path().extension()!=".html") //path方法返回path对象,对象的成员方法extension截取文件的后缀以字符串方式返回
continue; //这里整体就在判断迭代器指向的文件是不是html文件
// cout<<itera->path().string()<<endl; //测试预期效果的打印步骤
filelist->push_back(itera->path().string()); //保存html路径字符串
}
//cout<<(*filelist)[0]<<endl;
return true;
}
测试一下我们找到的html文件路径名
bool ParserFile(const vector<string>&files_list,vector<htmldoc>*result)
{ //挨个解析文件中我们再细分了三个解析的函数,分别是解析标题,内容,拼接url 另外在工具类中实现
for(const string& str:files_list) //读文件函数
{ //1从files_list中拿到一个文件路径,首先把这个文件读取到一个字符串中
string docontent;
if(!lcy::ReadFile::readfile(str,&docontent))
{
cerr<<"read file fail"<<endl;
continue;
}
htmldoc doc_t;
if(!parsertitle(docontent,&doc_t.title))
{
continue;
}
if(!parsercontent(docontent,&doc_t.content))
{
continue;
}
if(!parserurl(str,&doc_t.url))
{
continue;
}
// cout<<doc_t.title<<endl;
// cout<<doc_t.content<<endl;
// cout<<doc_t.url<<endl;
// break;
result->push_back(std::move(doc_t)); //保存每个html结构体 这里可以减少拷贝 使用移动构造
}
return true;
}
//这里就先展示在util.hpp中实现的读文件方法
class ReadFile //就是简单的使用了C++ 中文件操作类
{
public:
static bool readfile(const string&path,string* fe)
{
ifstream in(path,std::ios::in);
if(!in.is_open())
{
cerr<<"open file"<<path<<"fail"<<endl;
return false;
}
string line;
while(getline(in,line)) //getline 返回值是istream引用对象 因为重载了强制类型转化,可转换为bool
{
*fe+=line;
}
in.close();
return true;
}
};
//拿到一个html全部内容的字符串后,我们创建一个htmldoc对象,先解析出标题
//现在又回到我们parser.cc中来实现三个解析文档的函数
bool parsertitle(const string&filedoc,string*strtit)
{
size_t pos=filedoc.find("<title>");
if(pos==string::npos)
return false;
size_t begin=pos+strlen("<title>"); //在文档字符串中找到标题,截取出标题, 以title标签定位标题的位置截取出来
size_t end=filedoc.find("</title>");
if(end==string::npos)
return false;
if(begin>end)
return false;
*strtit=filedoc.substr(begin,end-begin);
// if(*strtit=="for test")
// cout<<"for test"<<endl;
return true;
}
//解析出标题就放在了htmldoc.title中
bool parsercontent(const string&filedoc,string*strcon) //这里就是把所有标签去掉保存剩下的内容
{ //注意 去掉标签后的结果我们当成内容,也包括了标题,在建立索引时,统计关键字分别在标题和内容中
enum status{lable,content}; //出现的次数,标题中出现了的关键字在统计它在内容中出现的次数时也加上了他在标题中出现的次数
status stu=lable;
for(char ch:filedoc) //对内容字符串的每个字符做判断字符要么是处于标签中否则就是内容中,'<'是标签的开启标志
{ //'>'这个是标签的结束标志 我把要提取处于内容中的字符,用一个枚举表示两种状态,然后遍历
switch(stu)
{
case lable:
if(ch=='>')
stu=content;
break;
case content:
if(ch=='<')
stu=lable;
else
{
if(ch=='\n')
ch=' '; //这里我们去掉所有的'\n' 因为后面我们要用'\n'来分割在raw.txt中的每个htmldoc
*strcon+=ch;
}
break;
default:
break;
}
}
return true;
}
//原本看文档的路径是"https://www.boost.org/doc/libs/1_79_0/doc/html/"+下一部分文件路径名
//我们现在把文件都下载到本地是"data/html"+下一部分文件路径名
//这里拼接的方式就是用"https://www.boost.org/doc/libs/1_79_0/doc/html/"作为头部
//尾部就是"data/html"+下一部分文件路径名去掉"data/html"
//头部加尾部就是官网中的路径"https://www.boost.org/doc/libs/1_79_0/doc/html/"+下一部分文件路径名
bool parserurl(const string&file_path,string*strurl)
{ //路径我们是要去官网的,所以要拼接官网路径和我们下载下来的html的路径
string url_head="https://www.boost.org/doc/libs/1_79_0/doc/html/";
string url_tail=file_path.substr(src_html.size());
*strurl=url_head+url_tail;
return true;
}
完成这三步,我们就完成了一个文档数据清洗,把这个htmldoc保存到数组中
bool SaveFile(const string&dest_raw,const vector<htmldoc>&results)
{
#define GAP '\3'
ofstream oft(dest_raw,std::ios::out|std::ios::binary);
if(!oft.is_open())
{
cerr<<"open "<<dest_raw<<" file fail"<<endl;
return false;
}
for(auto &e:results)
{
string line=e.title;
line+=GAP;
line+=e.content;
line+=GAP;
line+=e.url; //我们约定用'\3'作为分割一个文件结构体title content url的分隔符,最后换行符就分隔一个这样的结构体
line+='\n';//后面在index.hpp模块我们就按照约定拿到每个清洗后文档的标题,内容和url
oft.write(line.c_str(),line.size());
}
oft.close();
return true;
}
这里我们可以不用二进制的方式写raw.txt,看一下,完成数据清洗后的文本内容
也不是很清晰,不过每个文档肯定以’\n’分割了,每个文档的内容以’\3’分割,至此就完成了parser模块的编写,形成了要交给索引模块index.hpp处理的raw.txt文件。
6.建立索引模块index.hpp
我们要将raw.txt的内容读取到内存中,定义正排索引元素结构,倒排索引元素结构,最后是形成将每个文档管理起来的正排元素数组,倒排元素的结构,我们是用一个关键字、该关键字在该文档中出现的权值,和文档id一起关联起来,同时也用一个数组存储倒排元素结构,不过是一个关键字,对应可能出现在多个文档中,我们最后还要用一个哈希来组织倒排索引,一个关键字为key,这个关键字出现在多个文档中对应就有一个倒排元素数组为value,这个数组中元素的关键字都是这个key,以这样的方式管理后,最终我们就能通过用户提交的关键字拿到对应的倒排数组,通过倒排数组元素文档id拿到正排元素构建响应结果,不过最后要注意可能用户的多个关键字出现在同一个文档中,我们返回的结果列表中有重复的,后面设计去重的方法。
#pragma once
#include<iostream>
#include<string>
#include<fstream>
#include<unordered_map>
#include"tool.hpp"
#include<mutex>
#include"log.hpp"
using namespace std;
//我们把这些实现都放在我们自己的命名空间lcy中,
struct forwarddoc //正排索引数据元素
{
string title; //标题
string content;
string url;
uint64_t id;
} ;
struct reversedoc //倒排索引数据元素
{
string word;
uint64_t id;
int weight;
};
typedef vector<reversedoc> redoc; //注意这里我们重命名了倒排元素数组,下面要用<string,redoc> 这样的kv结构管理倒排索引,我们就实现一个index的单例类
class index
{
private:
index(const index&)=delete;
index& operator=(const index&)=delete;
index()
{}
static mutex mtx;
static index*ind;
vector<forwarddoc>forwdarray; //正排索引使用数组作为数据结构,正好用下标作为文档id
unordered_map<string,redoc>reversearray; //倒排索引,一个关键字可以对应多个文档,所以用哈希表一个关键字对应一组文档
public:
static index* getsingle() //获取单例对象的一般方式
{
if(ind==nullptr)
{
mtx.lock();
if(ind==nullptr)
{
ind=new index;
}
mtx.unlock();
}
return ind;
}
};
index* index::ind=nullptr; //静态对象 类外初始化
mutex index::mtx;
在类中公有成员方法除了 getsingle,我们还要实现建立索引的buildindex、通过文档id获取正排元素的getfordoc、通过关键字获取倒排元素数组的getredoc,我们在私有作用域下在实现一个建立正排索引的方法Buildforwardindex,一个建立倒排索引的方法Buildreverseindex,然后在buildindex中我们就是传入parser模块形成的raw.txt文件的路径,读取raw.txt,对每个文档调用Buildforwardindex和Buildreverseindex,更新我们的正排索引和倒排索引。
bool buildindex(const string&text_src) //根据前面解析文件生成的文档来构建正排和倒排索引
{
ifstream inf(text_src,std::ios::in|std::ios::binary);
if(!inf.is_open())
{
//cerr<<"open "<<text_src<<" file fail"<<endl;
LOG(ERROR,"打开去标签文档失败");
return false;
}
string line;
int count=0;
while(getline(inf,line)) //一行行的读取正好读取我们先前规定的一行包含一个文件的标题 内容 链接,一个文件的这些内容加id构建一个正排索引结构数据
{
forwarddoc*doc=Buildforwardindex(line);
if(nullptr==doc)
{
cerr<<"build "<<line<<" index fail"<<endl;
continue;
}
Buildreverseindex(*doc);
++count;
// if(count%50==0)
//cout<<"已经为"<<count<<"个文档建立了索引"<<endl;
LOG(NORMAL,"已经为"+to_string(count)+"个文档建立了索引");
}
return true;
}
在buildindex方法中读取一行就是我们先前处理的一个文档的清洗内容,我们就把该内容传给私有方法Buildforwardindex,在该方法中就给这个文档创建正排元素变量加入到我们正排元素数组中
private:
forwarddoc* Buildforwardindex(const string&line)
{
vector<string>results;
lcy::Catstring::catstring(&results,line,"\3");//把读取内容中的标题 内容 url切割分开,按原先规定好的'\3'为分隔符
if(results.size()!=3)
{
//cerr<<"line content error"<<endl;
LOG(ERROR,"line content error");
return nullptr;
}
forwarddoc doc;
doc.title=results[0]; //分割后的内容依次赋给正排个字段
doc.content=results[1];
doc.url=results[2];
doc.id=forwdarray.size(); //该正排在数组中的下标位置就作为文档id
forwdarray.push_back(std::move(doc)); //移动赋值提高效率
return &forwdarray.back();
}
就是按先去约定的’\3’分割内容,我们在工具类中实现一个以特定分割符分割字符串的函数
class Catstring
{
public:
static void catstring(vector<string>*results,const string&line,const string&gep)
{
// size_t p1=line.find('\3'); //使用stl 中的方法切割
// if(p1==string::npos)
// return ;
// results->push_back(line.substr(0,p1));
// size_t p2=line.find('\3',p1+1);
// if(p2==string::npos)
// return ;
// results->push_back(line.substr(p1+1,p2-p1-1));
// results->push_back(line.substr(p2+1));
// 2 直接只用boost库中的split切割
boost::split(*results,line,boost::is_any_of(gep),boost::algorithm::token_compress_on);
}
};
Buildforwardindex中给文档建立玩一个正排索引就加入数组中,同时又把这个正排索引的地址返回,这是因为,一个文档建立了正排索引,紧接着就是拿这个正排元素去更新倒排索引,索引这个地址在buildindex判断不为空,下一步就是传给Buildreverseindex更新倒排索引,在倒排索引中我们就要在使用一个叫cppjieba的分词工具,给文档的标题和内容分别分出所有的关键字,cppjieba工具获取 git clone https://gitcode.net/mirrors/yanyiwu/cppjieba.git,执行完这条命令后就在我们的当前目录下有了一个cppjieba的目录,里面就有我们用要到的Jieba.hpp,还有和分词有关的各种词库,还要注意我们要先cd cppjieba, 然后执行cp -rf deps/limonp include/cppjieba/,把limonp拷到
cppjieba的include/cppjieba/目录下,这要我们此时Jieba.hpp中的分词方法才不会报错,我们把cppjieba这个大目录放在我们指定的目录下,在我们的项目中使用软链接的方式引入,我们要用到Jieba.hpp 和一些词库,下面简单演示一些该分词工具的用法。
#include "ind/cppjieba/Jieba.hpp"
#include<iostream>
#include<string>
using namespace std;
const char* const DICT_PATH = "./dict/jieba.dict.utf8"; //这里引入各种词库,来初始化jieba对象
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";
int main(int argc, char** argv) {
cppjieba::Jieba jieba(DICT_PATH, //实例化一个jieba对象
HMM_PATH,
USER_DICT_PATH,
IDF_PATH,
STOP_WORD_PATH);
vector<string> words;
//vector<cppjieba::Word> jiebawords;
string s;
//string result;
s = "在大学里学的不是知识而是一种叫自学的能力,当我真正走上工作岗位时才深刻的体会到这句话的含义";
cout << "[demo] CutForSearch" << endl;
jieba.CutForSearch(s, words); //使用成员函数CutForSearch将我们传入的句子进行分词,将结果放在一个vector<string>中
cout << limonp::Join(words.begin(), words.end(), "/") << endl; //这里就是把分完的每个词中间加/打印出来
return EXIT_SUCCESS;
}
我们就把分词的方法也实现在工具类中,我们在直接分词上进行了优化,对于分词后分出的暂停词,如 is、a、that、what、嗯、啊这些出现频率很高但实际没有体系搜索意图的词不记录在我们的倒排索引中,这要就不至于当用户提高的搜索中分出这些暂停词时,返回大量无意义的搜索结果。这里我们也实现一个分词的单例类,穷举所有暂停词放在哈希中,对分词结果遍历,出现在暂停词中我们就删除该词。
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分词,分词结果原样返回, 我们可以再次改进,去掉例如 is that a 啊 嗯 呀 这样的暂停词,如果不去掉的话, 用暂停词去搜索,会返回大量的结果,看场景需要选择是否去掉暂停词
class Cutword //改进的分词类中我们把分词结果中的暂停词去掉,就是在类中作修改,暴露给外面的接口不变
{ //jieba 库 命名空间 cppjieba
private:
cppjieba::Jieba jieba;
unordered_set<string>us; //把穷举的暂停词放到哈希表中,分词后拿每个词和哈希表中的词比较,在表中就是暂停词,否则不是
static Cutword* cut;
public:
static void cutword(const string&s,vector<string>*results)
{
getsingle()->cutwordplay(s,results);
}
private:
Cutword()
:jieba(DICT_PATH, HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH)
{}
Cutword(const Cutword&)=delete;
Cutword& operator=(const Cutword&)=delete;
void cutwordplay(const string&s,vector<string>*results) //这里就是去掉暂停词
{
jieba.CutForSearch(s,*results);
for(auto it=results->begin();it!=results->end();)
{
if(us.find(*it)!=us.end())
{
it=results->erase(it);
}
else
{
it++;
}
}
}
static Cutword* getsingle()
{
static mutex mtx;
if(cut==nullptr)
{
mtx.lock();
if(cut==nullptr)
{
cut=new Cutword;
cut->initjieba();
}
mtx.unlock();
}
return cut;
}
void initjieba() //在jieba库穷举了暂停词的文件中把所有暂停词读取出来
{
ifstream in(STOP_WORD_PATH);
if(!in.is_open())
{
LOG(ERROR,"load stop word error");
return ;
}
string line;
while(getline(in,line))
{
us.insert(line);
}
in.close();
}
};
Cutword* Cutword::cut=nullptr;
}
现在我们就在Buildreverseindex定义个记录关键字词频的结构体,以关键字的词频为依据计算权重,最后又是通过权重给搜索的结果列表进行排序构建响应返回。
bool Buildreverseindex(const forwarddoc&doc)
{
struct word_cnt //词频记录
{
int title_cnt;
int content_cnt;
word_cnt()
:title_cnt(0)
,content_cnt(0)
{}
};
unordered_map<string,word_cnt>word_map; //建立词和词频的映射关系
vector<string>title_words;
lcy::Cutword::cutword(doc.title,&title_words); //用jieba分词对文档标题分词
// if(doc.id==6087) for debug
// {
// for(auto &str:title_words)
// cout<<"title:"<<str<<endl;
// }
for(auto e:title_words)
{
boost::to_lower(e);
word_map[e].title_cnt++; //搜索不区分大小写,统一转成小写统计词频
}
vector<string>content_words;
lcy::Cutword::cutword(doc.content,&content_words); //同理对文档内容分词
// if(doc.id==6087) for debug
// {
// for(auto &str:content_words)
// cout<<"content:"<<str<<endl;
// }
for(auto e:content_words)
{
boost::to_lower(e);
word_map[e].content_cnt++; //词频统计 这样一个文档中的词就统计出来了
}
//for dubug 打印一个jieba分词对一个文档标题 和内容分词后的结果
// if(doc.id==6087)
// cout<<word_map["split"].title_cnt<<" "<<word_map["split"].content_cnt<<endl;
const int x=10;
const int y=1;
for(auto &e:word_map) //每个文档解析出的关键字在这里创建倒排元素,不断更新词和词对应的倒排数组redoc的哈希表
{
reversedoc elem;
elem.id=doc.id;
elem.word=e.first;
elem.weight=x*e.second.title_cnt+y*e.second.content_cnt;
redoc& relist=reversearray[e.first]; //拿到对应关键字关联的倒排数组,将新建立的这个元素加入数组
relist.push_back(std::move(elem));
}
return true;
}
上面对我们的数据源建立号索引了,我们就要通过用户给的关键字,返回倒排数组,然后通过id返回正排元素,下面就是index类提高给外部的获取正排索引和倒排拉链的函数
forwarddoc* getfordoc(uint64_t Id) //正排索引通过文档id获取文档内容
{
if(Id>=forwdarray.size())
{
// cerr<<"id out range error"<<endl;
return nullptr;
}
return &forwdarray[Id];
}
redoc* getredoc(const string& Word) //倒排索引通过一个关键字返回一组倒排的数据元素
{
auto it=reversearray.find(Word);
if(it==reversearray.end())
{
// cerr<<"key word not found"<<endl;
return nullptr;
}
return &it->second;
7.编写搜索模块searcher.hpp
该模块我们包含index.hpp文件获取索引的单例对象,这里实现一个Searcher类,成员数据用一个指针指向index单例对象,实现一个搜索初始化的函数,里面就调用index的建立索引函数,完成索引的建立
class Searcher
{
public:
void initsearch(const string&input) //用我们在parser.cc中解析生成的文档作路径输入
{
ind=lcy::index::getsingle(); //这里用我们在index.hpp中的单例对象index对文档进行索引建立
// cout<<"获取单例对象成功"<<endl;
LOG(NORMAL,"获取单例对象成功") ; //打印日志的方式
ind->buildindex(input);
// cout<<"初始化索引成功"<<endl;
LOG(NORMAL,"初始化索引成功");
}
Searcher(){}
~Searcher(){}
private:
lcy::index* ind;
};
在该类中还要实现我们的搜索服务函数search,就是获取用户输入的搜索query,我们构建响应结果,使用jsoncpp序列化工具将结果序列化为字符串,返回给search_http.cc,然后在上层的这个模块通过网络交互数据,返回结果给用户,还要实现一个形成摘要的函数getabstrack放在私有作用域下,在search中调用它形成一个搜索结果元素的一部分,一个元素包含标题、摘要和url。
void search(const string&query,string* json_str)
{
vector<string>words;
lcy::Cutword::cutword(query,&words); //对输入的搜索词也进行分词,存入数组words
vector<lcy::reversedoc>rever_all_list;
for(auto &word:words) //拿到用户输入的分词后分出的关键字,去找对应的倒排数组,插入到rever_all_list
{
boost::to_lower(word); //不区分大小写统一转成小写
lcy::redoc*relist=ind->getredoc(word);
if(relist==nullptr)
{
continue; //索引中没找到关键词的倒排拉链就继续搜索下一个关键词
}
rever_all_list.insert(rever_all_list.end(),relist->begin(),relist->end());
}
if(rever_all_list.empty())
{
// cout<<"没有找到任何相关的东西"<<endl;
LOG(NORMAL,"没有找到任何相关的东西");
}
else
{
sort(rever_all_list.begin(),rever_all_list.end(),[](const lcy::reversedoc&e1,const lcy::reversedoc&e2){
return e1.weight>e2.weight;
});
Json::Value root; //已经将有关键字的倒排拉链按相关性排序好了,选择拿倒排元素id构建正排元素
for(auto &doc:rever_all_list)
{
Json::Value elem;
lcy::forwarddoc* fdoc=ind->getfordoc(doc.id); //将所有的正排元素序列化输出
elem["title"]=fdoc->title; //形成标题
elem["filedoc"]=getabstrack(fdoc->content,doc.words[0]); //形成摘要 摘要中要包含关键字 修正后就去倒排元素中第一个关键字
elem["url"]=fdoc->url; //形成链接
// elem["ID"]=(int)doc.id; //这里id 和weight 是为了测试打印
// elem["weight"]=doc.weight;
root.append(elem); //形成json数据元素elem的数组
}
// Json::StyledWriter writer; //测试时用StyledWriter打印结构规划编译观察
Json::FastWriter writer; //更快
*json_str=writer.write(root); //序列化
}
}
这里直接拿每个关键字对应的倒排元素数组进行拼接,拼接成一个大的倒排元素数组,我们知道元素结构是文档id、关键字和权值,但这里形成的大数组可能存在id重复的情况,比如我们搜索"学习Linux",分词是分出了学习 和Linux ,那么可能有一个文档中即包含了学习这个关键字,也包含了Linux这个关键字,当我们使用第一个关键字拿到所有倒排元素加入数组中,一定把这篇文档也加入了,使用Linux这个关键字拿到对应所有倒排元素里面又包含了这篇文档,那么最后显示给用户的就出现了重复,这时不必要的,我们要对最后返回的结果进行去重。我们的策略就是定义一个专门用在这里对索引出来的倒排数组,把id相同的元素进行合并,他们关键字不同,我们在这个结构中就定义一个vector 把关键字都加入,同时把原先的两个权值加在一起,这就进行了文档相同的合并。
struct reversedocprint //用于修正多个关键字存在同一个文档中时显示多份相同文档的修正倒排元素结构
{
uint64_t id;
vector<string>words;
int weight;
reversedocprint()
:id(0)
,weight(0)
{}
};
//之前是直接将倒排元素reversedoc放在数组中排序,现在我们就用这里定义的结构体对vector<reversedocprint>排序。所以修正后的search如下
void search(const string&query,string* json_str)
{
vector<string>words;
lcy::Cutword::cutword(query,&words); //对输入的搜索词也进行分词,存入数组words
// 修正前rever_all_list中可能存在id相同的倒排元素lcy::redoc rever_all_list; //定义倒排元素数组,将分词后的关键词索引出对应的倒排拉链,拉链中所有倒排元素加入rever_all_list,
vector<reversedocprint>rever_all_list; //修正后每个倒排元素id各不相同,不会存在返回相同文档的情况了
unordered_map<uint64_t,reversedocprint>um; //对每个关键字返回的倒排拉链进行去重,用相同id时,就把weight累加,关键字加入数组
for(auto &word:words)
{
boost::to_lower(word);
lcy::redoc*relist=ind->getredoc(word);
if(relist==nullptr)
{
continue; //索引中没找到关键词的倒排拉链就继续搜索下一个关键词
}
for(auto &e:*relist)
{ //对每个关键字返回的拉链进行去重,id对应reversedocprint放到哈希表中
auto &iter=um[e.id];
iter.id=e.id;
iter.weight+=e.weight;
iter.words.push_back(e.word);
}
//rever_all_list.insert(rever_all_list.end(),relist->begin(),relist->end()); 不能再这样直接插入可能造成相同id的倒排拉链了
} //最后对数组中倒排元素按用weight表示的相关性降序排列
for(auto &e:um)
{
rever_all_list.push_back(std::move(e.second));
}
if(rever_all_list.empty())
{
// cout<<"没有找到任何相关的东西"<<endl;
LOG(NORMAL,"没有找到任何相关的东西");
}
else//对于输入的语句比如,我是一名程序员 分词 我/ 是 /一名/ 程序员 可能都出现在100号文档中,
{ //每个关键词都返回一个倒排拉链,把索引倒排拉链元素放在一个倒排拉链中按相关性排序这个拉链中就有4分id都为100倒排元素,按id返回正排元素就会返回4份一样的,要去重
// sort(rever_all_list.begin(),rever_all_list.end(),[](const lcy::reversedoc&e1,const lcy::reversedoc&e2){
sort(rever_all_list.begin(),rever_all_list.end(),[](const reversedocprint&e1,const reversedocprint&e2){
return e1.weight>e2.weight;});
Json::Value root; //已经将有关键字的倒排拉链按相关性排序好了,选择拿倒排元素id构建正排元素
for(auto &doc:rever_all_list)
{
Json::Value elem;
lcy::forwarddoc* fdoc=ind->getfordoc(doc.id); //将所有的正排元素序列化输出
elem["title"]=fdoc->title; //形成标题
elem["filedoc"]=getabstrack(fdoc->content,doc.words[0]); //形成摘要 摘要中要包含关键字 修正后就去倒排元素中第一个关键字
elem["url"]=fdoc->url; //形成链接
// elem["ID"]=(int)doc.id; //这里id 和weight 是为了测试打印
// elem["weight"]=doc.weight;
root.append(elem); //形成json数据元素elem的数组
}
// Json::StyledWriter writer; //测试时用StyledWriter打印结构规划编译观察
Json::FastWriter writer; //更快
*json_str=writer.write(root); //序列化
}
}
形成摘要的方式我们就默认去找到reversedocprint的关键字数组中的第一个关键字,在对应文档中找到该关键字,截取它前50个字节和后120个字节的内容为摘要
private:
string getabstrack(const string&conte,const string&word)
{
//在正文中截取一段作为摘要,必须包含关键字,我们截取关键字前面50个字节和后面120个字节的这段内容作为摘要
//要注意无符号整数的坑 ,可以直接换成int
// int pos=conte.find(word); string find查找就存在这种问题换search //我们的文本中关键字是大小写都存在的 ,但是搜索的关键字为了建立索引统一转成了小写,
// if(pos==string::npos) //存在关键字本身存在于文本中但是因为大小写未匹配,返回none1的情况, 要处理这点
auto iter=std::search(conte.begin(),conte.end(),word.begin(),word.end(),[](int x,int y){
return std::tolower(x)==std::tolower(y);
});
if(iter==conte.end())
return "none1";
int pos=std::distance(conte.begin(),iter);
int begin=0;
int end=conte.size()-1;
if(pos-50>begin)begin=pos-50; //这里不注意pos是无符号整数时的比较在一些情况原本不会返回none2就会返回none2
if(pos+120<end)end=pos+120;
if(begin>end)
{
return "none2";
}
string desc=conte.substr(begin,end-begin);
desc+="......";
return desc;
}
到这里我们就编写完了搜索模块
8.编写search_http.cc模块
在该模块我们引入网络库cpp-httplib,同时编写我们的主页,能基本展示前端效果,我们直接在gitee上搜索cpp-httplib下载到我们的云服务器上特定的目录下,同样在我们的项目目录下建立软链接
在该文件中写明raw.txt的路径和我们的主页路径,用一个httplib::Server的对象,通过Get方式获取用户输入的搜索字符串,初始化一个Searcher对象,调用搜索初始化方法,完成一连串的初始化建立索引的工作,然后调用search服务获得结果的json字符串,返回给用户,我们在前端完成显示布局和与后端的交互工作。
#include"searcher.hpp"
#include"log.hpp"
#include"cpphttplib/httplib.h" //根目录路径 我们在根目录下编写一个html文件,
const string&htmlsrc="./wwwroot"; //当请求我们的ip+port时就自动返回该网页
const string&rawtxt="./data/raw/raw.txt";
int main()
{
mysearch::Searcher sear;
sear.initsearch(rawtxt); //初始化我们解析boost官网的那些html文件后形成了原始文档,
httplib::Server ser;
ser.set_base_dir(htmlsrc.c_str()); //这里就是设置根目录了,没有设置根目录的话,是请求不到说明内容的,就只有ip+port/s这样直接请求特定资源了
ser.Get("/s",[&sear](const httplib::Request&req,httplib::Response&rsp){//这里第一个参数就是我们设置一个除了ip+port外要指名一个路径,这里和前端编写配合
if(!req.has_param("word")) //这里我就是设置了用户搜索提交上来的关键字是和"word"对应的,这在前端编写设置了,
{ //这里我们就用has_param 这个函数检查用户是否提交了搜索关键字
rsp.set_content("必须输入搜索关键词","text/plain;charset=utf-8");
return ; //没有就直接返回提示信息给用户
}
string word=req.get_param_value("word"); //这个函数就是获取用户提交的关键字
//cout<<"用户在搜索"<<word<<endl;
LOG(NORMAL,"用户在搜索:"+word);
string json_str;
sear.search(word,&json_str); //开始交给后端解析关键字,返回结果给用户
rsp.set_content(json_str,"application/json"); //我们就是返回的一个json数据元素数组,前端就解析这个数组
});
LOG(NORMAL,"服务已经启动");
ser.listen("0.0.0.0",8010);
return 0;
}
打印日志功能
#pragma once
#include<iostream>
#include<string>
#include<ctime>
#define NORMAL 1
#define WARNING 2
#define DEBUG 3
#define ERROR 4
#define LOG(LEVEL,MESSAGE) log(#LEVEL,MESSAGE,__FILE__,__LINE__) //这里第二个参数带# 表示将宏参数就原样以字符串的方式传递
void log(std::string level,std::string message,std::string file,int line)
{
std::cout<<">>"<<level<<"<<"<<">>"<<time(nullptr)<<"<<"<<">>"<<message<<"<<"<<">>"<<file<<"<<"<<">>"<<line<<"<<"<<std::endl;
}
前端编写
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
<style>
*{
margin:0;
padding:0;
}
html,
body{
height: 100%;
}
.container{
width: 800px;
margin:0px auto;
margin-top: 16px;
}
.container .search{
width:100%;
height: 52px;
}
.container .search input{
float:left;
width:600px;
height:50px;
border:1px solid black;
border-right: none;
padding-left: 18px;
color:gray;
font-size:16px;
}
.container .search button{
float:left;
width:160px;
height: 52px;
background-color: #4e6ef2;
color:#fff;
font-size:20px;
font-family: 'Times New Roman', Times, serif;
}
.container .result{
width:100%;
}
.container .result .item{
margin-top:18px;
}
.container .result .item a{
display:block;
text-decoration: none;
font-size:20px;
}
.container .result .item a:hover{
text-decoration: underline;
}
.container .result .item p{
font-size:18px;
margin-top: 1px;
font-family:'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
}
.container .result .item i{
display: block;
font-style: normal;
color:darkgreen;
}
</style>
<title>boost 搜索引擎</title>
</head>
<body>
<div class="container">
<div class="search">
<input type="text" value="请输入搜索关键字……">
<button onclick="search()">搜索一下</button>
</div>
<div class="result">
<!-- 这里我们就是用BuildHtml函数动态的构建显示结果 -->
</div>
</div>
<script>
function search()
{
// alert("hello js!");
let query=$(".container .search input").val(); //提取搜索框输入的内容
console.log("query = "+query); //在浏览器中记录 query=关键字
//发起http请求将提取的内容交给后端处理
$.ajax({
type: "GET", //请求的方式
url: "/s?word=" + query, //这里我们就是拼接我们要访问的资源的路径
success: function(data){ //成功了 服务端就会把请求处理的结果放在data中
console.log(data); //我们就把data 解析到显示界面
BuildHtml(data);
}
});
}
function BuildHtml(data){ //这里就是我们根据返回的data动态构建的显示结果函数
let result_lable=$(".container .result");
result_lable.empty();
for(let elem of data){ //前端拿到json元素数组,把每个elem拿出来构建一个个的显示单元
let a_lable = $("<a>",{ //每个单元就是一个标题
text: elem.title,
href: elem.url,
target: "_blank" //跳转到新页面
});
let p_lable= $("<p>",{ //一个摘要
text: elem.filedoc
});
let i_lable=$("<i>",{ //一个url显示
text: elem.url
});
let div_lable = $("<div>", { //这里我们获取样式属性就行了
class: "item"
}); //这里的关系就是一个大的盒子div 表示结果result 里面又是多个item
a_lable.appendTo(div_lable); //每个item 中就是 a标签显示标题 p标签显示摘要,i标签显示url
p_lable.appendTo(div_lable); //这里就是把a p i三个标签的内容添加到item这个div中
i_lable.appendTo(div_lable);
div_lable.appendTo(result_lable); //每一个item有要加入到result这个大div中
}
}
</script>
</body>
</html>