文章目录
Boost搜索引擎项目
1.项目的相关背景
写在前面
在如今的信息时代下,检索信息成为几乎人人的"必需品",在此大背景下,出现了诸如百度,360,搜狗等大型的搜索引擎。
而我们想做一个和百度,360这样的搜索引擎是不切实际的。
- 我们无法抓取全网信息并且保存,还要建立模型。
- 我们的服务器是云服务器,没有这么高的性能。
基于此我们可以通过做一个微型的搜索引擎达到"管中窥豹"的效果,明晰搜索引擎的运行原理。
由于我们的服务器是很普通的,没有那么好的性能。所以我们选择做一个站内搜索,而我们选择的是c++中的boost库。因为boost的官方网站中是没有站内搜索的。这样我们就达到了明晰搜索引擎的运行原理的目的也达到了方便我们自己后续进行boost学习的目的。
![]()
Boost库简介
Boost库是为C++语言标准库提供扩展的一些C++程序库的总称,由Boost社区组织开发、维护。Boost库可以与C++标准库完美共同工作,并且为其提供扩展功能。
Boost库由Boost社区组织开发、维护。其目的是为C++程序员提供免费、同行审查的、可移植的程序库。Boost库可以与C++标准库完美共同工作,并且为其提供扩展功能。Boost库使用Boost License来授权使用,根据该协议,商业的非商业的使用都是允许并鼓励的。
期望结果
我们可以看看一些主流搜索引擎搜索布局(我们也将仿照编程实现)
![]()
![]()
2.搜索引擎的宏观原理
3.搜索引擎的技术栈和项目环境
技术栈: 后端:C/C++,C++11,STL,boost准标准库,Jsoncpp,cppjieba,cpp-httplib 前端:HTML5,CSS,JS,jQuery,Ajax
项目环境: Centos7云服务器,vim/gcc(g++)/Makefile, vs2019/Vscode
4.正排索引和倒排索引—>搜索引擎原理
样例
我们先举几个例子方便说明
文档一: 张三拿到了录取通知书。 文档二: 张三拿到了录取通知书,被奖励手机。
正排索引
正排索引就是根据文档ID找到文档内容(文档关键字)
文档ID 文档内容 1 张三拿到了录取通知书。 2 张三拿到了录取通知书,被奖励手机。
文档分词
为什么要进行分词呢?比如说我们搜索清华大学,页面不仅仅会显示清华大学还会显示和清华大学相关的内容。
![]()
文档分词目的: 方便查找与方便建立倒排索引
文档一: [张三拿到了录取通知书] —> 张三/拿到/录取/通知书 张三/拿到/录取通知书
文档二: [张三拿到了录取通知书,被奖励手机] —> 张三/拿到/录取/通知书/奖励/手机
张三/拿到/录取通知书/奖励/手机
这里会发现我们进行分词时是省略了一些词的,我们省略的词是:暂停词/停止词
- 了 的 啊 哦 哟 吗…
倒排索引
倒排索引就是根据文档内容进行分词,整理出文档不重复的所有关键字,最后对应到文档ID
文档关键字 文档ID (为了严谨我们还会根据权重(weight)进行最终拍板,这里先写出) 张三 文档1 文档2 拿到 文档1 文档2 录取 文档1 文档2 通知书 文档1 文档2 录取通知书 文档1 文档2 奖励 文档1 手机 文档1
模拟一次查找过程
用户输入: 录取通知书 —> 在倒排索引中查找 —> 找到了文档1 文档2 —> 根据正排索引查找 —> 找到相应文档内容 —> 文档结果摘要[title content url] —> 响应结果
5.数据的去标签与数据清洗
模块作用
该模块是我们将下载好的boost库文档原始文件按照需求进行提取并对提取的文件的标题,内容,网址进行处理
获取boost资源
进入官网 https://www.boost.org/
![]()
下载资源
![]()
在boost_1_81_0中不是所有文件我们都要,我们只要/boost_1_81_0/doc/html/*,所以要开始构建项目环境了
// 将下载好的boost库导入云服务器 rz -E // 解包 unzip xzf boost_1_81_0.tar.tgz // 在你安装的boost_1_81_0的目录下进行操作 mkdir -p data/SourceData // 我们将我们所需要的文件拷贝放入进SourceData下 拷贝操作 cp -rf boost_1_81_0/doc/html/* data/SourceData (后面的符号是与前面形成了注释,复制前面的就可以了)*/ // 删除已经安装的boost_1_81_0.tar.tgz rm -rf boost_1_81_0.tar.tgz
安装boost库
// 我们要用到boost库,所以我们要先安装 sudo yum install -y boost-devel // 要引入的头文件 #include <boost/filesystem.hpp>
编写思路
![]()
所以我们最终只要[ 网页标题title + 网页内容content + 网址url ],其它的我们都不要
//上述我们将需要的文件放入进了data/SourceData 中 ---> 这是原始的,没有经过我们处理的文件。 // 我们还需要创建目录保存我们处理之后的。 cd data mkdir parser.html touch parserdata.txt // 我们最终处理好的数据就放入parserdata.txt中 // 我们怎样处理数据 在保存网页数据的时候,我们使用 '\3'作为分隔符 这是因为在ASCII表中 , 控制字符是不可显示字符, 即无法打印。在我们获取的文档内容(即data/SourceData中的html网页文件)中,里面基本上都是可打印字符,基本上不会有不可显示的控制字符。如此以来也就不会污染我们的文档内容啦。
parser.cpp的基本框架
// 目录 const std::string src_path = "data/SourceData"; const std::string output = "data/parser.html/parserdata.txt"; typedef struct DocInfo { std::string title; // 文件的标题 std::string content; // 文件内容 std::string url; // 文件的路径 }DocInfo_t; int main() { std::vector<std::string> files_list; // 1: 递归的将每个html(文件名带路径)保存 EnumFile(src_path,&files_list) // 2: 对每个文件的内容读取和解析 std::vector<DocInfo_t> results; ParseHtml(files_list,&results) // 3: 将解析完成的各个文件的内容写入进output SaveHtml(results,output) return 0; }
EnumFile接口的实现
bool EnumFile(const std::string& src_path, std::vector<std::string>* files_list) { namespace fs = boost::filesystem; fs::path root_path(src_path); // 路径是否存在 if(!fs::exists(root_path)) { return false; } // 判断递归是否结束 fs::recursive_directory_iterator end; for(fs::recursive_directory_iterator iter(root_path); iter != end; iter++) { // 判断是否是普通文件 if(!fs::is_regular_file(*iter)) continue; // 判断文件后缀是否是.html if(iter->path().extension() != ".html") continue; files_list->push_back(iter->path().string()); } return true; }
ParseHtml接口实现
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results) { for(const std::string& file : files_list) { // 1: 读取文件 std::string result; if(!ns_util::FileUtil::ReadFile(file,&result)) continue; // 2: 解析指定的文件,提取title DocInfo_t doc; if(!ParseTitle(result,&doc.title)) continue; // 3: 解析指定的文件,提取content if(!ParseContent(result,&doc.content)) continue; // 4: 解析指定的文件路径,构建url if(!ParseUrl(file,&doc.url)) continue; results->push_back(doc); } return true; } // 2: 解析指定的文件,提取title static bool ParseTitle(const std::string& file, std::string* title) { std::size_t begin = file.find("<title>"); if(begin == std::string::npos) return false; std::size_t end = file.find("</title>"); if(end == std::string::npos) return false; begin += std::string("<title>").size(); if(begin > end) return false; *title = file.substr(begin,end - begin); return true; } // 3: 解析指定的文件,提取content static bool ParseContent(const std::string& file, std::string* content) { enum status { LABLE, CONTENT }; enum status s = LABLE; for(char c : file) { switch(s) { case LABLE: if(c == '>') s = CONTENT; break; case CONTENT: if(c == '<') s = LABLE; else { content->push_back(c); } break; default: break; } } return true; } // 4: 解析指定的文件路径,构建url static bool ParseUrl(const std::string& file_path, std::string* url) { std::string url_head = "https://www.boost.org/doc/libs/1_81_0/doc/html"; std::string url_tail = file_path.substr(src_path.size()); *url = url_head + url_tail; return true; } // 读取文件 class FileUtil { public: static bool ReadFile(const std::string& file_path, std::string* out) { std::ifstream in(file_path,std::ios::in); if(!in.is_open()) { std::cerr << "open file " << file_path << " error " << std::endl; return false; } std::string line; while(std::getline(in,line)) { *out += line; } in.close(); return true; } };
SaveHtml接口实现
bool SaveHtml(const std::vector<DocInfo_t>& resluts, const std::string& output) { #define SEP '\3' // 二进制的方式进行写入 std::ofstream out(output, std::ios::out | std::ios::binary); if(!out.is_open()) { std::cerr << "open " << output << " failed" << std::endl; return false; } // 对文件内容进行写入 for(auto& item : resluts) { std::string out_string; out_string = item.title; out_string += SEP; out_string += item.content; out_string += SEP; out_string += item.url; out_string += '\n'; out.write(out_string.c_str(),out_string.size()); } out.close(); return true; }
结果
vim parserdata.txt
6.建立索引
引入分词工具cppjieba
// jieba的使用--cppjieba // 获取链接: git clone https://gitcode.net/mirrors/yanyiwu/cppjieba.git // 我们需要自己执行: cd cppjieba; cp -rf deps/limonp include/cppjieba/, 不然会编译报错
index.cpp的基本框架
struct DocInfo { std::string title; // 文档标题 std::string content; // 文档内容 std::string url; // 文档url uint64_t doc_id; // 文档ID }; struct InvertedElem { uint64_t doc_id; std::string word; int weight; }; typedef std::vector<InvertedElem> InvertedList; class Index { public: Index() {} ~Index() {} // 根据文档ID(doc_id)找到文档内容 DocInfo* GetForwardIndex(uint64_t doc_id) { return nullptr; } // 根据关键字string找到文档内容 InvertedList* GetInvertedList(const std::string& word) { return nullptr; } // 根据去标签,格式化后的文档构建正排索引和倒排索引 bool BuildIndex(const std::string& input) { // 建立正排索引 DocInfo* doc = BuildForwardIndex(line); // 建立倒排索引 BuildInvertedIndex(*doc); return true; } private: // 正排索引 std::vector<DocInfo> forward_index; // 倒排索引 std::unordered_map<std::string, InvertedList> inverted_index; };
建立正排索引
DocInfo* BuildForwardIndex(const std::string& line) { // 1: 解析line,字符串切分 // line ---> title content url std::vector<std::string> results; std::string sep = "\3"; // 切分函数 Split(line,&results,sep); // 2: 字符串填充到DocInfo DocInfo doc; doc.title = results[0]; doc.content = results[1]; doc.url = results[2]; // 先保存ID再插入,ID与数组下标相对应 doc.doc_id = forward_index.size(); // 3: 插入进正排索引 forward_index.push_back(std::move(doc)); return &forward_index.back(); } // 切分函数 void Split(std::string& target, std::vector<std::string>* out, std::string& sep) { boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on); }
建立倒排索引
//倒排索引一定是一个关键字对应一个或一组InvertedElem或vector<InvertedElem> bool BuildInvertedIndex(const DocInfo& doc) { // 统计每个标题,内容出现的次数 struct word_cnt { int title_cnt; int content_cnt; word_cnt() : title_cnt(0), content_cnt(0) {} }; std::unordered_map<std::string, word_cnt> word_map; // 暂存词频的映射表 // 对标题进行分词 std::vector<std::string> title_words; // 分词函数 CutString(doc.title, &title_words); // 对标题进行词频统计 for(auto& s : title_words) { // 将分词统一转化为小写 boost::to_lower(s); word_map[s].title_cnt++; } // 对文档内容进行分词 std::vector<std::string> content_words; // 分词函数 CutString(doc.content, &content_words); // 对内容进行词频统计 for(auto& s : content_words) { // 将分词统一转化为小写 boost::to_lower(s); word_map[s].content_cnt++; } #define X 10 #define Y 1 for(auto& word_pair : word_map) { InvertedElem item; item.doc_id = doc.doc_id; item.word = word_pair.first; item.weight= X * word_pair.second.title_cnt + Y*word_pair.second.content_cnt; InvertedList& inverted_list = inverted_index[word_pair.first]; inverted_list.push_back(std::move(item)); } return true; } // 分词函数 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"; class JiebaUtil { private: static cppjieba::Jieba jieba; public: void CutString(const std::string& src, std::vector<std::string>* out) { jieba.CutForSearch(src, *out); } }; cppjieba::Jieba JiebaUtil::jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH);
7.搜索列表
安装jsoncpp
jsoncpp库用于实现json格式的序列化和反序列化,完成多个数据对象组织成为json格式字符串,以 及将json格式字符串解析成得到多个数据对象的数据。
// 安装jsoncpp sudo yum install -y jsoncpp-devel
编写思路
searcher.cpp的基本框架
class Searcher { private: // 供系统进行查找的索引 Index::Index *index; public: Searcher() { } ~Searcher() { } void InitSearcher(const std::string &input) { // 1: 创建index对象 // 2: 根据index对象建立索引 } void Search(const std::string &query, std::string *json_string) { // 1: 对query按照searcher的要求进行分词 // 2: 根据分词的各个"词",进行index查找,建立Index忽略大小写,搜索,关键字也需要 // 3: 汇总查找结果,按照weight降序排序 // 4: 根据查找出来的结果,构建json串 - jsoncpp - 通过jsoncpp完成序列化和反序列化 } std::string GetDesc(const std::string &html_content, const std::string &word) { // 显示摘要(文档的一小部分内容) return ; } };
Search函数接口实现
// query: 搜索关键字 // json_string: 返回给用户浏览器的搜索结果 void Search(const std::string &query, std::string *json_string) { // 1: [分词]: 对query按照searcher的要求进行分词 std::vector<std::string> words; CutString(query, &words); // 2: [触发]: 根据分词的各个"词",进行index查找,建立Index忽略大小写,搜索,关键字也需要 Index::InvertedList inverted_list_all; for (std::string word : words) { boost::to_lower(word); Index::InvertedList *inverted_list = index->GetInvertedList(word); if (inverted_list == nullptr) continue; inverted_list_all.insert(inverted_list_all.end(), inverted_list->begin(), inverted_list->end()); } // 3: [合并排序]: 汇总查找结果,按照weight降序排序 std::sort(inverted_list_all.begin(), inverted_list_all.end(), [](const Index::InvertedElem &e1, const Index::InvertedElem &e2) { return e1.weight > e2.weight; }); // 4: [构建]: 根据查找出来的结果,构建json串 - jsoncpp -- 通过jsoncpp完成序列化和反序列化 Json::Value root; for (auto &item : inverted_list_all) { Index::DocInfo *doc = index->GetForwardIndex(item.doc_id); if (doc == nullptr) continue; Json::Value elem; elem["title"] = doc->title; // content是文档的去标签结果,但不是我们想要的,我们要一部分 elem["desc"] = GetDesc(doc->content, item.word); elem["url"] = doc->url; root.append(elem); } Json::StyledWriter writer; *json_string = writer.write(root); }
获取摘要
std::string GetDesc(const std::string &html_content, const std::string &word) { // 找到word在html_content中的首次出现,然后往前找50字节(如果没有,从begin开始),往后找100字节 // (如果没有,到end就可以) int prev_step = 50; int next_step = 100; // 1: 找到首次出现 auto iter = std::search(html_content.begin(), html_content.end(), word.begin(), word.end(), [](int x, int y){ return (std::tolower(x) == std::tolower(y)); }); int pos = std::distance(html_content.begin(), iter); // 2: 获取start,end int start = 0; int end = html_content.size() - 1; // 如果之前有50个字符,开始更新 if(pos > start + prev_step) start = pos - prev_step; if((int)pos < (int)(end - next_step)) end = pos + next_step; // 3: 截取字串并返回 if (start >= end) return string(); return html_content.substr(start, end - start); }
8.本地测试
测试代码
const std::string input = "data/parser.html/parserdata.txt"; int main() { Searcher::Searcher* search = new Searcher::Searcher(); search->InitSearcher(input); std::string query; std::string json_string; while(true) { std::cout << "Pleasr Enter You Search Query# "; std::cin >> query; search->Search(query,&json_string); std::cout << json_string << std::endl; } return 0; }
结果
到这里表明我们建立索引模块,搜索引擎模块是没有问题的,可以接着进行后续的操作
9.服务器
升级gcc/g++
我们接下来要将服务在网络上进行展示,所以我们要用到一个开源的HTTP库cpphttplib
cpp-httplib是一个以C++11特性编写的,跨平台HTTP/HTTPS库,要使用这个库我们必须有较高版本的gcc/g++
// 升级步骤 // 查看gcc/g++版本 gcc/g++ -v // 安装scl sudo yum install centos-release-scl scl-utils-build // 安装新版本 sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++ // 每次手动启用较高版本 scl enable devtoolset-7 bash gcc/g++ -v // 永久启动 vim ~/.bash_profile 然后添加 scl enable devtoolset-7 bash
安装cpp-httplib
// 登录网址下载 https://gitee.com/welldonexing/cpp-httplib/tree/v0.7.15 // 下载完成导入进云服务器进行解包
![]()
服务器代码实现
const std::string input = "data/parser.html/parserdata.txt"; const std::string root_path = "./root"; int main() { Searcher::Searcher search; search.InitSearcher(input); httplib::Server svr; // 设置相对根目录 svr.set_base_dir(root_path.c_str()); // 注册请求 svr.Get("/s", [&search](const httplib::Request& req, httplib::Response& rsp){ // 获取用户要搜索关键字 std::string word = req.get_param_value("word"); std::cout << "用户搜索: " << word << std::endl; // 响应服务 std::string json_string; search.Search(word,&json_string); rsp.set_content(json_string, "application/json"); }); svr.listen("0.0.0.0",8029); return 0; }
本地测试成功这一步一定是没有问题的
10.网页设计
网络资源存储
// 创建根目录存放网络资源(代码,图片......) mkdir root cd root touch 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> * { margin: 0; padding: 0; } html, body { height: 100%; } .container { width: 800px; margin: 0px auto; margin-top: 15px; } .container .search { width: 100%; height: 52px; } .container .search input { float: left; width: 600px; height: 50px; border: 1px solid black; border-right: none; padding-left: 10px; color: black; font-size: 19px; } .container .search button { float: left; width: 150px; height: 52px; background-color: #4e6ef2; 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 { text-decoration: none; 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" placeholder="输入关键字搜索..."> <button οnclick="Search()">搜索一下</button> </div> <div class="result"> </div> </div> <script> function Search(){ let query = $(".container .search input").val(); console.log("query = " + query); / $.ajax({ type:"GET", url:"/s?word=" + query, success:function(data){ console.log(data); BuildHtml(data); } }); } function BuildHtml(data){ let result_lable = $(".container .result"); result_lable.empty(); for(let elem of data){ // console.log(elem.title); // cosole.log(elem.url); let a_lable = $("<a>",{ text: elem.title, href: elem.url, target:"_blank" }); let p_lable = $("<p>",{ text: elem.desc }); let i_lable = $("<i>",{ text: elem.url, }); let div_lable = $("<div>",{ 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>
11.项目部署至云服务器
nohup ./http_server > log/log.txt 2>&1 &
12.项目展示
初始页面
![]()
搜索页面
![]()