【API文档搜索引擎】下
API文档搜索引擎
- 1. 编写搜索引擎模块 Searcher
- 2. 编写 http_server 模块
- 3. 编写前端模块
- 4. 添加日志
- 5. 补充 去掉暂停词
- 6. 项目扩展方向
1. 编写搜索引擎模块 Searcher
这一模块主要提供建立索引,以及收到用户的发起的http请求通过Get方法提交的搜索关键字,然后对关键字进行分词,先在倒排索引中查找到对应关键字的文档ID,然后在正排索引中根据文档ID,找到多个相关文档内容,拼接返回给用户。
class Searcher{private:Index *index; //供系统进⾏查找的索引public:Searcher(){}~Searcher(){}public:void InitSearcher(const std::string &input){ //1. 获取或者创建index对象 //2. 根据index对象建⽴索引 } //query: 搜索关键字 //json_string: 返回给⽤⼾浏览器的搜索结果 void Search(const std::string &query, std::string *json_string) { //1.[分词]:对我们的query进⾏按照searcher的要求进⾏分词 //2.[触发]:就是根据分词的各个\"词\",进⾏index查找 //3.[合并排序]:汇总查找结果,按照相关性(weight)降序排序 //4.[构建]:根据查找出来的结果,构建json串 -- jsoncpp } };
这里使用到了Josn库,Jsoncpp 库用于实现 json 格式的序列化和反序列化。
安装Json库
#更新源sudo apt-get update#安装sudo apt-get install libjsoncpp-dev
//Searcher.hpp#pragma once#include \"index.hpp\"#include class Searcher{public: void InitSearcher(const std::string &output) { // 1.获取或者创建index对象 index = Index::GetInstance(); cout << \"获取index单例成功...\" << endl; // 2.根据index对象建立索引 index->BuildIndex(output); cout << \"建立正排和倒排索引成功...\" << endl; } // query: 搜索关键字 // json_string: 返回给用户浏览器的搜索结果 void Search(const std::string &query, std::string *json_string) { //1.[分词]:对我们的query进行按照searcher的要求进行分词 std::vector<std::string> words; JiebaUtil::CutString(query,&words); //2.[触发]:就是根据分词的各个\"词\",进行index查找,建立index是忽略大小写,所以搜索,关键字也需要 InvertedList inverted_list_all;//倒排拉链 for(auto word : words) { //忽略大小写 boost::to_lower(word); //找对应关键字倒排索引 InvertedList* inverted_list = index->GetInvertedIndex(word); if(inverted_list == nullptr) { continue; } //所有关键字对应的倒排索引都放在这里。有没有什么问题? inverted_list_all.insert(inverted_list_all.end(),inverted_list->begin(),inverted_list->end()); } //倒排索引找没找到没有必要往下走了 if(inverted_list_all.empty()) { return; } //3.[排序]:汇总查找结果,按照相关性(weight)降序排序 std::sort(inverted_list_all.begin(),inverted_list_all.end(),\\ [](const InvertedElem& e1,const InvertedElem& e2){\\ return e1.weight > e2.weight; }); //4.[构建]:根据查找出来的结果,构建json串 -- jsoncpp --通过jsoncpp完成序列化&&反序列化 Json::Value root; for(auto& item : inverted_list_all) { DocInfo* doc = index->GetForwardIndex(item.doc_id); if(nullptr == doc) { continue; } Json::Value elem; elem[\"title\"] = doc->title; //我们不需要把文档所有内容返回,只要返回有关键字的摘要就行了 elem[\"desc\"] = GetDesc(doc->content,item.word); elem[\"url\"] = doc->url; //for deubg, for delete //elem[\"id\"] = (int)item.doc_id; //elem[\"weight\"] = item.weight; //int->string root.append(elem); } //将序列化后的结果返回给用户 //Json::StyledWriter write; Json::FastWriter write; *json_string = write.write(root); }private: std::string GetDesc(const std::string& html_content,const std::string& word) { //找到word在html_content中的首次出现,然后往前找50字节(如果没有,从begin开始),往后找100字节(如果没有,到end就可以的) //截取出这部分内容 // const size_t prev_step = 50; // const size_t next_step = 100; const int prev_step = 50; const int next_step = 100; //1.找到首次出现 //这里有个细节,如果用find去找文档内容首次出现关键字的地方是有问题的 //find它是不会忽略大小写的,内容是有大小写的,但是我们把关键字却是以小写存到倒排中的 //也就是说当前用这个小写关键字去内容中找关键字首次出现的地方,明明内容中是有这个关键字的 //但是就是因为我们把关键字以小写写入倒排,所有导致明明有但是在内容中找不到 //size_t pos = html_word.find(word); auto it = std::search(html_content.begin(),html_content.end(),word.begin(),word.end(),\\ [](const char& s1,const char& s2){ \\ return std::tolower(s1) == std::tolower(s2); }); if(it == html_content.end()) { return \"None1\";//实际上走到这里关键字一定会content中出现,因为标题也是content中找到 } // const size_t pos = std::distance(html_word.begin(),it); const int pos = std::distance(html_content.begin(),it); //2.获取start,end // size_t start = 0; // size_t end = html_word.size() - 1; int start = 0; int end = html_content.size() - 1; //这里有个坑,size_t是无符号整型的,即使是负数也会转换出一个特别大的整数 //如果pos - prev_step < 0 负数就会转变成一个特别的的正数 // if(pos - prev_step > start) start = pos - prev_step; // if(pos + next_step < end) end = pos + next_step; //方法1: - 变成 +, + 变成 -,防止越界 // if(pos > start + prev_step) start = pos - prev_step; // if((int)pos < (int)(end - next_step)) end = pos + next_step; //方法2: size_t 变成 int if(pos - prev_step > start) start = pos - prev_step; if(pos + next_step < end) end = pos + next_step; //3. 截取子串,return if(start >= end) return \"None2\"; std::string desc = html_content.substr(start,end-start); desc += \"...\"; return desc; }private: Index *index = nullptr;};
上面代码还有一个问题!
如果你有一个文档是 :今天晚上吃米饭。分词:今天/晚饭/吃米饭/米饭。建立倒排索引,假设每个关键字对应的文档ID都是100
假如你搜索的关键字也是,今天晚饭吃米饭。先分词 ,今天/晚饭/吃米饭/米饭,然后去倒排搜索中去查,它们对应的文档ID可都是100,上面代码我们可是不管不顾直接把从倒排拉链中取出的节点都放在一个新的倒排拉链中保存起来。然后去排序,在去正排索引根据文档ID查文档内容。
如果这样的话,最终你会发现搜索引擎给你返回的内容都是重复的!!!
所以不能直接把从倒排拉链中取出的节点都放在一个新的倒排拉链中保存起来,而是先要去重! 因为文档ID都是一样的,所以我们可以根据文档ID去重,然后把文档ID相同的关键字放在一起,并且把每个关键字的权值也放在一起。
所以我们在searcher模块就有一个新的节点,这个节点用来保存在倒排索引中搜索到的然后没有出现没有重复文档ID节点。因为所有重复文档的ID和关键字以及权值我们都放在一起了。
typedef struct InvertedElemUnique{ uint64_t doc_id; int weight; //用数组存每个关键字,不要用sting,因为还要用关键字在content找内容 //如果doc_id虽然重复,但是关键字是在content中是分开的 //如果把关键字都拼一起了,然后去content去找内容是找不到的 std::vector<std::string> words; InvertedElemUnique() : doc_id(0), weight(0) {}} InvertedElemUnique;
然后我们可以先用unordered_map根据wendID先去重,然后在放到倒排拉链,后面就和之前的一样啦。
class Searcher{public://... // query: 搜索关键字 // json_string: 返回给用户浏览器的搜索结果 void Search(const std::string &query, std::string *json_string) { //1.[分词]:对我们的query进行按照searcher的要求进行分词 std::vector<std::string> words; JiebaUtil::CutString(query,&words); //2.[触发]:就是根据分词的各个\"词\",进行index查找,建立index是忽略大小写,所以搜索,关键字也需要 //InvertedList inverted_list_all;//倒排拉链 //为了去重 std::unordered_map<uint64_t,InvertedElemUnique> token_map; //在把去重后的结果放到倒排拉链 std::vector<InvertedElemUnique> inverted_list_all; for(auto word : words) { boost::to_lower(word); //找对应关键字倒排索引 InvertedList* inverted_list = index->GetInvertedIndex(word); if(inverted_list == nullptr) { continue; } //所有关键字对应的倒排索引都放在这里 //inverted_list_all.insert(inverted_list_all.end(),inverted_list->begin(),inverted_list->end()); //这里有些问题,如果分词情况: 你/今天/玩饭/吃什么 对应的doc_id假如都是100,搜索到的内容就会有重复 //因此不能直接就放,需要先去重 //内容重复,我们通过doc_id就清楚知道,然后我们可以把doc_id相同对应的权值和关键字都合并 for(auto& elem : *inverted_list) { auto& item = token_map[elem.doc_id]; item.doc_id = elem.doc_id; item.weight += elem.weight; item.words.push_back(std::move(elem.word)); } } for(auto &it : token_map) { inverted_list_all.push_back(std::move(it.second)); } //倒排索引找没找到没有必要往下走了 if(inverted_list_all.empty()) { return; } //3.[排序]:汇总查找结果,按照相关性(weight)降序排序 // std::sort(inverted_list_all.begin(),inverted_list_all.end(),\\ // [](const InvertedElem& e1,const InvertedElem& e2){\\ // return e1.weight > e2.weight; // }); //3.[排序]:汇总查找结果,按照相关性(weight)降序排序 std::sort(inverted_list_all.begin(),inverted_list_all.end(),\\ [](const InvertedElemUnique& e1,const InvertedElemUnique& e2){\\ return e1.weight > e2.weight; }); //4.[构建]:根据查找出来的结果,构建json串 -- jsoncpp --通过jsoncpp完成序列化&&反序列化 Json::Value root; for(auto& item : inverted_list_all) { DocInfo* doc = index->GetForwardIndex(item.doc_id); if(nullptr == doc) { continue; } Json::Value elem; elem[\"title\"] = doc->title; //我们不需要把文档所有内容返回,只要返回有关键字的摘要就行了 //elem[\"content\"] = doc->content; //elem[\"desc\"] = GetDesc(doc->content,item.word); elem[\"desc\"] = GetDesc(doc->content,item.words[0]); elem[\"url\"] = doc->url; //for deubg, for delete //elem[\"id\"] = (int)item.doc_id; //elem[\"weight\"] = item.weight; //int->string root.append(elem); } //将序列化后的结果返回给用户 //Json::StyledWriter write; Json::FastWriter write; *json_string = write.write(root); }private: Index *index = nullptr;};
2. 编写 http_server 模块
这里为了方便,我们就不自己手搓一个http服务器了。可以使用cpp-httplib库。
也是在gitee中找,随便选一个
cpp-httplib不建议下载最新的,因为运行这个库对gcc有要求必须要是高版本。不过如果你的unbentu是20.04这个版本的话,就不用管了,直接下下面的其中一个。
下载好之后也是拉到linux中解压之后,最后在软连接一下,不然在使用的时候会报找不到头文件的错误。
//http_server.cc#include \"cpp-httplib/httplib.h\"#include \"searcher.hpp\"//http根目录,所有资源都放在这里const std::string root_path = \"./wwwroot\";//构建索引的文档路径const std::string output = \"data/raw_html/raw.txt\";int main(){ Searcher search; search.InitSearcher(output); httplib::Server svr;//启动服务器 svr.set_base_dir(root_path.c_str());//http根目录 //for debug // svr.Get(\"/hi\", [](const httplib::Request &req, httplib::Response &rsp) // { rsp.set_content(\"你好,世界!\", \"text/plain; charset=utf-8\"); }); svr.Get(\"/s\", [&](const httplib::Request &req, httplib::Response &rsp){\\ if(!req.has_param(\"word\")) { rsp.set_content(\"必须要有搜索关键字!\", \"text/plan; charset=utf-8\"); return; } std::string word = req.get_param_value(\"word\"); //cout<<\"用户正在搜索: \"<<word<<endl; LOGMESSAGE(NORMAL,\"用户正在搜索: \" + word); std::string json_string; search.Search(word,&json_string); if(json_string.empty()) { rsp.set_content(\"Not Found\", \"text/plain\"); } else { rsp.set_content(json_string,\"application/json\"); } }); svr.listen(\"0.0.0.0\", 8080);//绑定IP和Port return 0;};
以上就是boost搜索引擎的后端所有内容了,接下来就是前端的一些知识了。
3. 编写前端模块
了解 html,css,js
html: 是网页的骨骼 – 负责网页结构
css:网页的皮肉 – 负责网页美观的
js(javascript):网页的灵魂–负责动态效果,和前后端交互
教程:w3school
这里不多说,因为我也是一个二把刀。。。可以自己学一学。
在根目录wwwroot下再建一个index.html ,这个就是首页。
<!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> <title>boost 搜索引擎</title> <style> /* 去掉网页中的所有的默认内外边距,html的盒子模型 */ * { /* 设置外边距 */ margin: 0; /* 设置内边距 */ padding: 0; } /* 将我们的body内的内容100%和html的呈现吻合 */ html, body { height: 100%; } /* 类选择器.container */ .container { /* 设置div的宽度 */ width: 800px; /* 通过设置外边距达到居中对齐的目的 */ margin: 0px auto; /* 设置外边距的上边距,保持元素和网页的上部距离 */ margin-top: 15px; } /* 复合选择器,选中container 下的 search */ .container .search { /* 宽度与父标签保持一致 */ width: 100%; /* 高度设置为52px */ height: 52px; } /* 先选中input标签, 直接设置标签的属性,先要选中, input:标签选择器*/ /* input在进行高度设置的时候,没有考虑边框的问题 */ .container .search input { /* 设置left浮动 */ float: left; width: 600px; height: 50px; /* 设置边框属性:边框的宽度,样式,颜色 */ border: 1px solid black; /* 去掉input输入框的有边框 */ border-right: none; /* 设置内边距,默认文字不要和左侧边框紧挨着 */ padding-left: 10px; /* 设置input内部的字体的颜色和样式 */ color: #CCC; font-size: 14px; } /* 先选中button标签, 直接设置标签的属性,先要选中, button:标签选择器*/ .container .search button { /* 设置left浮动 */ float: left; width: 150px; height: 52px; /* 设置button的背景颜色,#4e6ef2 */ background-color: #4e6ef2; /* 设置button中的字体颜色 */ color: #FFF; /* 设置字体的大小 */ font-size: 19px; font-family:Georgia, \'Times New Roman\', Times, serif; } .container .result { width: 100%; } .container .result .item { margin-top: 15px; } .container .result .item a { /* 设置为块级元素,单独站一行 */ display: block; /* a标签的下划线去掉 */ text-decoration: none; /* 设置a标签中的文字的字体大小 */ font-size: 20px; /* 设置字体的颜色 */ color: #4e6ef2; } .container .result .item a:hover { text-decoration: underline; } .container .result .item p { margin-top: 5px; font-size: 16px; font-family:\'Lucida Sans\', \'Lucida Sans Regular\', \'Lucida Grande\', \'Lucida Sans Unicode\', Geneva, Verdana, sans-serif; } .container .result .item i{ /* 设置为块级元素,单独站一行 */ display: block; /* 取消斜体风格 */ font-style: normal; color: green; } </style></head><body> <div class=\"container\"> <div class=\"search\"> <input type=\"text\" value=\"请输入搜索关键字\"> <button onclick=\"Search()\">搜索一下</button> </div> <div class=\"result\"> <!-- 这是标题 这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要
https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib 这是标题 这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要
https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib 这是标题 这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要
https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib 这是标题 这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要
https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib 这是标题 这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要
https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib --> </div> </div> <script> function Search(){ // 是浏览器的一个弹出框 // alert(\"hello js!\"); // 1. 提取数据, $可以理解成就是JQuery的别称 let query = $(\".container .search input\").val(); if(query == \'\' || query == null) { return; } console.log(\"query = \" + query); //console是浏览器的对话框,可以用来进行查看js数据 //2. 发起http请求,ajax: 属于一个和后端进行数据交互的函数,JQuery中的 $.ajax({ type: \"GET\", url: \"/s?word=\" + query, success: function(data){ console.log(data); BuildHtml(data); } }); } function BuildHtml(data){ if(data == \'\' || data == null || data == \"Not Found\") { document.write(\"搜索内容错误\"); return; } // 获取html中的result标签 let result_lable = $(\".container .result\"); // 清空历史搜索结果 result_lable.empty(); for( let elem of data){ // console.log(elem.title); // console.log(elem.url); let a_lable = $(\"\", { text: elem.title, href: elem.url, // 跳转到新的页面 target: \"_blank\" }); let p_lable = $(\"\"
, { text: elem.desc }); 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></body></html>
4. 添加日志
一般会把错误的信息打印到日志中,方便之后找问题。
#pragma once#include#include#define NORMAL 0#define WARRING 1#define DEBUG 2#define FATAL 3#define LOGMESSAGE(LEVEL,MESSAGE) LogMessage(#LEVEL,MESSAGE,__FILE__,__LINE__);//时间戳变成时间char* timeChange(){ time_t now=time(nullptr); struct tm* local_time; local_time=localtime(&now); static char time_str[1024]; snprintf(time_str,sizeof time_str,\"%d-%d-%d %d-%d-%d\",local_time->tm_year + 1900,\\ local_time->tm_mon + 1, local_time->tm_mday,local_time->tm_hour, \\ local_time->tm_min, local_time->tm_sec); return time_str;}void LogMessage(std::string level,std::string message,std::string file,int line){ char* nowtime = timeChange(); std::cout << \"[\" << level << \"]\" << \"[\" << nowtime << \"]\" \\ << \"[\" << message << \"]\" << \"[\" << file << \" : \" << line << \"]\" << std::endl;}
可以把之前代码有打印信息的地方,都换成下面这种

5. 补充 去掉暂停词
暂停词在 dict/stop_words.utf8 这个目录下

比如如果你在当前搜索引擎搜索关键字 is,明明是一个boost搜索引擎,你搜它根本没有意义,更何况一个html文件中有很多is,确实没有意义。就拿目前的代码来运行搜索关键字 filesytem是有意义的。

你去搜索is,服务器给这么多搜索结果,但是根本没有意义

因此,我们使用cppjieba进行分词的时候,我们要特殊处理一下,不管是建立索引时用到cppjieba分词,还是搜索时用到cppjieba分词。我们都要把这些暂停词去掉。因为每个地方都在用,所以写成单例。
//Common.h//去掉暂停词class JiebaUtil{private: static JiebaUtil* _inst; static std::mutex _mtx; JiebaUtil():jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH){} JiebaUtil(const JiebaUtil&) = delete; JiebaUtil& operator=(const JiebaUtil&) = delete; cppjieba::Jieba jieba; //为了快速查找暂停词使用unordered_map,Value使用bool没有实际意义 std::unordered_map<std::string,bool> _stop_words;public: static JiebaUtil* GetInstance() { if(_inst == nullptr) { std::unique_lock<std::mutex> lock; if(_inst == nullptr) { _inst = new JiebaUtil; _inst->InitJiebaUtil(); } } return _inst; } static void CutString(const std::string &src, std::vector<std::string> *out) { GetInstance()->CutStringHelper(src,out); }private: void InitJiebaUtil() { std::ifstream ifs(\"./dict/stop_words.utf8\"); if(!ifs.is_open()) { LOGMESSAGE(FATAL,\"load stop words file error\"); return; }//读取暂停词到哈希桶 std::string line; while(std::getline(ifs,line)) { _stop_words.insert({line,true}); } ifs.close(); } void CutStringHelper(const std::string &src, std::vector<std::string> *out) { //先进行分词 jieba.CutForSearch(src, *out); //遍历分词结果,去暂停词 auto it = out->begin(); while(it != out->end()) { auto iter = _stop_words.find(*it); if(iter != _stop_words.end()) { //删除暂停词,注意迭代器失效 it = out->erase(it); } else { ++it; } } }};JiebaUtil* JiebaUtil::_inst = nullptr;
虽然这样启动很慢,但是你是一个服务器,刚启动慢,查快也很正常。
当我们换上面的代码,然后重新启动服务器,在搜索 is ,直接提示搜索内容错误。

以上就是我们这个项目的所有内容。不过我们上面还是实现的很简单。有兴趣的可以在加点东西。
6. 项目扩展方向
- 建立整站搜索
我们目前只把boost_1_87_0/doc/html/下面的.html文件放到date/input,然后做搜索,但是其他地方也有.html文件。我们可以把boost_1_87_0路径下所有内容拷贝到data下,然后始递归搜索所有.html文件名带路径的时候,src_path直接设置位data,这样就能把所有.html文件和路径名都放到files_list中。

这个时候url头部就变成了下面这个样子
bool ParserUrl(const std::string &file_path, std::string *url){//std::string url_head = \"https://www.boost.org/doc/libs/1_87_0/doc/html\"; std::string url_head = \"https://www.boost.org/doc/libs/1_87_0\"; std::string url_tail = file_path.substr(src_path.size()); *url = url_head + url_tail; return true;}
- 设计一个在线更新的方案,用到信号,爬虫,完成整个服务器的设计
- 不使用组件,而是自己设计一下对应的各种方案(有时间,有精力)
- 在我们的搜索引擎中,添加竞价排名(强烈推荐)
- 热次统计,智能显示搜索关键词(字典树,优先级队列)(比较推荐)

6. 设置登陆注册,引入对mysql的使用 (比较推荐)