营销技巧第一季无删减seo知识培训
Boost搜索引擎
- 1. 为什么要做这个项目
- 2. 搜索引擎相关宏观原理
- 3. 搜索引擎技术栈和项目环境
- 4. 正排索引 vs 倒排索引 - 搜索引擎具体原理
- 5. 编写数据去标签与数据清洗的模块 Parser
- 5.1 去标签
- 5.2 编写parser
- 6. 编写建立索引的模块 Index
- 6.1 建立正排索引
- 6.2 建立倒排索引
1. 为什么要做这个项目
目前我们熟知的搜索引擎有:百度,360,搜狗等,它们所作的都是全网搜索 。而我们接下来做的是站内搜索,搜索数据更垂直,数据量其实更小。以前Boost库是没有搜索的,不过现在是有了,但没关系,重要的是虽然我们做的是Boost搜索引擎,但如果掌握了,可以改成任何一个搜索引擎。
我们可以先看看一个搜索引擎给它关键字给我们显现出来的东西。
可以看到主要呈现的东西有标题、摘要、网址。后面我们实现的搜索引擎也要呈现这样的效果。
2. 搜索引擎相关宏观原理
在一个服务器集群/服务器里磁盘某个路径下放着从全网爬下来的资源。有个searcher进程再跑。
第一步:对这个路径下的文件进行去标签+数据清理
第二步:建立索引(方便快速查找)
当用户发起一个http请求,通过GET方法提交搜索关键字,发起搜索任务
第三步:检索索引得到相关html
拼接多个网页的title+desc+url,构建一个新的网页,返回给用户
目前我们大致了解一下流程即可。
3. 搜索引擎技术栈和项目环境
- 技术栈: C/C++ C++11, STL, 准标准库Boost,Jsoncpp,cppjieba,cpp-httplib
- 选学: html5,css,js、jQuery、Ajax
- 项目环境:ubentu20.04 云服务器,gcc(g++)/Makefile , vs code
4. 正排索引 vs 倒排索引 - 搜索引擎具体原理
比如说目前我们有两个文档:
- 文档1:雷军发布了小米汽车
- 文档2:雷军买了四斤小米
所谓正排索引:就是根据文档ID找到文档内容(文档内的关键字)
文档ID | 文档内容 |
1 | 雷军发布了小米汽车 |
2 | 雷军买了四斤小米 |
建立倒排索引,之前我们要先对文档进行分词,(目的:方便建立倒排索引和查找)
- 文档1:[雷军发布了小米汽车]: 雷军/发布/小米/汽车/小米汽车
- 文档2:[雷军买了四斤小米]: 雷军/买/四斤/小米/四斤小米
停止词:了,的,吗,a,the,⼀般我们在分词的时候可以不考虑。
倒排索引:根据文档内容,分词,整理不重复的各个关键字,对应联系到文档ID的方案
关键字(具有唯一性) | 文档ID,weight(权值) |
雷军 | 文档1,文档2 |
发布 | 文档1 |
小米 | 文档1,文档2 |
汽车 | 文档1 |
小米汽车 | 文档1 |
买 | 文档2 |
四斤 | 文档2 |
四斤小米 | 文档1,文档2 |
关于权值也是有必要的,我们搜索一个关键字,就会从上到下给我们很多条搜索结果,那谁在前谁在后呢?这里我们简单一点,就以权值来衡量搜索谁在前谁在后。当前真实情况就是动用钞能力了。。。
模拟一次搜索引擎查找的过程:
用户输入:小米 -> 先倒排索引中查找 -> 提取出文档ID(1,2) -> 在去正排索引中查找 -> 找到对应文档的内容 -> title+conent(desc)+url 文档结果进行摘要 -> 构建响应结果
接下来我们正式编写我们的代码,我们一模块一模块的进行编写。
5. 编写数据去标签与数据清洗的模块 Parser
首先你得要有boost库数据才行。boost库
我们可以下载最新的版本,然后拉到你得linux中,
解压压缩包,会得到这个东西
大部分 .html都在 doc/html目录下,目前只需要boost_1_87_0/doc/html目录下的html文件,用它来进行建立索引
这里我把它拷贝到自己项目下的boost_search/data/input路径下
然后你刚才下载的boost库你就可以删了。
5.1 去标签
html的标签,这个标签对我们进行搜索是没有价值的,需要去掉这些标签,一般标签都是成对出现的!
我们要去标签,主要是为了得到里面有效内容,它们是我们建立索引需要的东西。
我们把去标签之后的干净内容放raw.txt文档中
这里要注意我们把内容写进文档,未来也要建立索引也要进行读取。为了方便读取,文档内的内容以\3进行分割,文档和文档直接用\n进行分割。
类似:title\3content\3url \n title\3content\3url \n title\3content\3url \n …
这样的话未来我们使用getline(ifsream, line),直接一行一行获取文档的全部内容 title\3content\3url
5.2 编写parser
未来公用类的都放在Conmon.h头文件里。
//Common.h
#pragma once
#include<iostream>
#include<string>
#include<vector>
#include<fstream>using std::cout;
using std::cerr;
using std::endl;//Parser.cc
#inclide"Common.h"//源数据所有html文件的路径
static const std::string src_path = "data/input";
//源数据去标签+数据清理之后的路径
static const std::string output = "data/raw_html/raw.txt";typedef struct DocInfo
{std::string title; //文档的标题std::string content; //文档的摘要std::string url; //该文档在官网的url
}DocInfo_t;//const &: 输入
//*: 输出
//&:输入输出
bool EnumFile(const std::string &src_path, std::vector<std::string> *files_list);
bool ParserHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results);
bool SaveHtml(const std::vector<DocInfo_t> &results, const std::string &output);int main()
{std::vector<std::string> files_list;// 第一步: 递归式的把每个html文件名带路径,保存到files_list,方便后期进行一个个的文件读取if (!EnumFile(src_path, &files_list)){cerr << "enum file name fail" << endl;return 1;}std::vector<DocInfo_t> results;// 第二步: 根据files_list读取每个文件的内容,并进行解析if (!ParserHtml(files_list, &results)){cerr << "parser html fail" << endl;return 2;}// 第三步: 把解析完的各个文件内容,写入到output,按照\3作为每个文档内部分割符,\n作为每个文档的分割符if (!SaveHtml(results, output)){cerr << "save html fail" << endl;return 3;}return 0;
}
如何递归式的把每个html文件名带路径都拿到呢?这里我们可以使用boost库的方法。
通过apt-get命令安装libboost-all-dev包
sudo apt-get update
sudo apt-get install libboost-all-dev
安转好之后,默认安装目录为/usr,
默认头文件安装目录在 /usr/include/boost
默认so库文件安装目录在/usr/lib/x86_64-linux-gnu
安装之后我们就可以写了
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)){//cout << root_path << "not exists" << endl;LOGMESSAGE(FATAL,src_path + "not exists");return false;}// 定义一个空的迭代器,用来判断递归结束fs::recursive_directory_iterator end;for (fs::recursive_directory_iterator start(root_path); start != end; ++start){// 判断文件是否是普通文件,.html结尾是普通文件if (!fs::is_regular_file(*start)){continue;}// 判断文件路径的后缀是否符合要求if (start->path().extension() != ".html"){continue;}// cout << "debug: " << start->path().string() << endl;// 当前的路径一定是一个合法的,以.html结束的普通网页文件// 将所有带路径的html保存在files_list,方便后续进行文本分析files_list->push_back(start->path().string());}return true;
}
当前已经把所有.html文件名带路径都放到files_list中,然后我们就可以依次读每条路径把每个文档的内容拿出来,进行分割。
//Common.hclass Util
{
public://从文件中读取内容static bool ReadFile(const std::string& file_path, std::string* out){std::ifstream ifs(file_path);if(!ifs.is_open()){cerr << "open file" << file_path << "fail" <<endl;return false;}std::string line;while(getline(ifs,line)){*out += line;}ifs.close();return true;}};//Parser.ccbool ParserHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results)
{for (const auto &path : files_list){// 1. 读取文件std::string result;if (!Util::ReadFile(path, &result)){continue;}DocInfo_t doc;// 2.解析指定文件,提取titleif (!ParserTitle(result, &doc.title)){continue;}// 3.解析指定文件,提取content,就是去标签if (!ParserContent(result, &doc.content)){continue;}// 4.解析指定文件路径,构建urlif (!ParserUrl(path, &doc.url)){continue;}// done,一定是完成了解析任务,当前文档的相关结果都保存在了doc里面// results->push_back(std::move(doc))//bug:todo;细节,本质会发生拷贝,效率可能会比较低results->push_back(std::move(doc)); // 拷贝变移动// for debug//ShowDoc(doc);//break;}return true;
}
提取title
bool ParserTitle(const std::string &file, std::string *title)
{auto begin = file.find("<title>");if (begin == std::string::npos){return false;}auto 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;
}
提取content(本质就是在 去标签)
这里我们用个简易的状态机,最开始的状态就是在 < 标签,然后一个个字符读取,遇到 > 标签之后可能下一个就属于content就拿,也有可能是 < 标签不能拿。所以每拿一个字符都要重新判断状态。
bool ParserContent(const std::string &file, std::string *content)
{// 写个状态机用来区分当前是在标签还是在内容enum status{LABLE, // 标签CONTENT // 内容};// 刚开始指向标签enum status s = LABLE;for (auto c : file){switch (s){case LABLE:if (c == '>'){s = CONTENT;}break;case CONTENT:if (c == '<'){s = LABLE;}else{// 我们不想保留原始文件中的\n,因为我们想用\n作为html解析之后文本的分隔符if (c == '\n')c = ' ';*content += c;}break;default:break;}}return true;
}
提取url
boost库的官方文档,和我们下载下来的文档,是有路径的对应关系的。
官网:
https://www.boost.org/doc/libs/1_87_0/doc/html/accumulators.html
我们下载下来的url样例:
boost_1_78_0/doc/html/accumulators.html
我们拷贝到我们项目中的样例:
data/input/accumulators.html
想把我们的路径变成官网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_tail = file_path.substr(src_path.size());*url = url_head + url_tail;return true;
}
将解析内容写入文件中
bool SaveHtml(const std::vector<DocInfo_t> &results, const std::string &output)
{
#define SEP '\3'//按照二进制方式进行写入std::ofstream ofs(output,std::ios::out | std::ios::binary);if(!ofs.is_open()){cerr << "open" << output << "fail" <<endl;return false;}//就可以进行文件内容的写入了for(auto& doc : results){std::string out_string;out_string = doc.title;out_string += SEP;out_string += doc.content;out_string += SEP;out_string += doc.url;out_string += '\n';ofs.write(out_string.c_str(),out_string.size());}ofs.close();return true;
}
6. 编写建立索引的模块 Index
目前已经把处理好的所有文档内容都放在raw.txt中了,下面我们可以从这里拿内容建立正排索引和倒排索引了。
//Index.hpp#include "Common.h"// 正排索引文档Id对应文档的内容
typedef struct DocInfo
{std::string title; // 文档的标题std::string content; // 文档的内容std::string url; // 该文档在官网的urluint64_t doc_id; // 文档ID,建立倒排排序需要用到
} DocInfo;// 倒排索引分词对应的文档ID和权重
typedef struct InvertedElem
{uint64_t doc_id;//文档idstd::string word;//关键字,等会对文档内容做摘要要用int weight;//权重InvertedElem() :doc_id(0), weight(0) {}} InvertedElem;// 倒排拉链
typedef std::vector<InvertedElem> InvertedList;class Index
{
private:// 正排索引的数据结构使用数组,数组的下标天然就是文档IDstd::vector<DocInfo> forward_index; // 正排索引// 倒排索引一定是一个关键字和一个或者多个InvertedElem对应关系(关键字和倒排拉链的映射关系)std::unordered_map<std::string, InvertedList> inverted_index;public:// 根据doc_id找到文档内容DocInfo *GetForwardIndex(const uint64_t doc_id){return nullptr;}// 根据关键字stirng,获得倒排拉链InvertedList *GetInvertedIndex(const std::string &word){return nullptr;}// 根据data/raw_html/raw_txt去标签,得到的文档内容,构建正排索引和倒排索引bool BuildIndex(const std::string& output) // parse处理完毕的数据交给我{return true;}
};
先把根据文档ID得到文档内容,和根据关键字得到倒排拉链,以及读一行建立正排索引和倒排索引简单函数写一下。
// 根据doc_id找到文档内容
DocInfo *GetForwardIndex(const uint64_t doc_id)
{if (doc_id >= forward_index.size()){cerr << "doc_id out range,fail" << endl;return nullptr;}return &forward_index[doc_id];
}// 根据关键字stirng,获得倒排拉链
InvertedList *GetInvertedIndex(const std::string &word)
{auto it = inverted_index.find(word);if (it == inverted_index.end()){cerr << word << "have no InvertedList" << endl;return nullptr;}return &inverted_index[word];
}// 根据data/raw_html/raw_txt去标签,得到的文档内容,构建正排索引和倒排索引
bool BuildIndex(const std::string& output) // parse处理完毕的数据交给我
{std::ifstream ifs(output,std::ios::in | std::ios::binary);if(!ifs.is_open()){//cerr << "sorry, " << output << " open error" << endl;LOGMESSAGE(FATAL,"sorry" + output + "open error");return false;}std::string line;while(std::getline(ifs,line)){//建立正排索引DocInfo* doc = BuildForwardIndex(line);if(doc == nullptr){cerr << "build" << line <<"fail"<<endl;continue;}//建立倒排索引BuildInvertedIndex(*doc);}ifs.close();return true;
}
6.1 建立正排索引
每个文档内容都用 \3,用以区分一个文档内的title,desc,url,虽然可以用string自带的find找到每个起始位置,然后使用substr根据起始位置以及长度得到title,desc,url。但是这我们使用boost中的split函数比较方便。
第一个参数表示,切分后结果放哪里
第二个参数表示,要切分的内容
第三个参数表示,按什么切分
第四个参数表示,是否压缩
是否压缩意思是有一个字符串,aaa/3bbb/3/3/3/3ccc
如果压缩 aaa/3bbb/3/3/3/3ccc 切分后结果是 aaa bbb ccc
如果不压缩 aaa/3bbb/3/3/3/3ccc 切分后结果是 aaa bbb ccc,/3和/3之间表示一个空格,压缩就是把/3/3/3/3当从一个/3。
默认是把压缩关闭的。token_compress_on 打开压缩。
//Common.h
#include<boost/algorithm/string.hpp>//使用boost库的函数,对字符串分割
class StringUtil
{
public:static void Spilt(const std::string &target, std::vector<std::string> *out, const std::string &sep){boost::split(*out,target,boost::is_any_of(sep),boost::token_compress_on);}
};//Index.hppDocInfo* BuildForwardIndex(const std::string& line){// 1.对line进行分割// line -> title content urlstd::vector<std::string> results;//一行分割的结果std::string sep = "\3"; // 行内分割符StringUtil::Spilt(line,&results,sep);// 2.符串进行填充到DocIinfoDocInfo doc;doc.title = results[0];doc.content = results[1];doc.url = results[2];doc.doc_id = forward_index.size();//先进行保存id,在插入,id就是当前doc在vector中的下标!//3. 插入到正排索引的vectorforward_index.push_back(std::move(doc));return &forward_index.back();}
6.2 建立倒排索引
每次建立一个正排索引之后,我们都把结果给拿到,然后去建立倒排索引。
//我们从正排索引拿到的⽂档内容
struct DocInfo{std::string title; //⽂档的标题std::string content; //⽂档对应的去标签之后的内容std::string url; //官⽹⽂档urluint64_t doc_id; //⽂档的ID,暂时先不做过多理解
};//假设⽂档内容:
title : 吃葡萄
content: 吃葡萄不吐葡萄⽪
url: http://XXXX
doc_id: 123
根据文档内容,形成一个或者多个InvertedElem(倒排拉链)
因为当前我们是一个一个文档进行处理的,一个文档会包含多个”词“, 都应当对应到当前的doc_id
- 需要对 title && content都要先分词
title: 吃/葡萄/吃葡萄(title_word)
content:吃/葡萄/不吐/葡萄皮(content_word)
词和文档的相关性(词频:在标题中出现的词,可以认为相关性更高一些,在内容中出现相关性低一些)
- 词频统计
struct word_cnt
{title_cnt;content_cnt;
}unordered_map<std::string, word_cnt> word_cnt;for &word : title_word{word_cnt[word].title_cnt++; //吃(1)/葡萄(1)/吃葡萄(1)
}for &word : content_word {word_cnt[word].content_cnt++; //吃(1)/葡萄(1)/不吐(1)/葡萄⽪(1)
}
知道了在文档中,标题和内容每个词出现的次数
- 自定义相关性
//把关键字从统计每个关键字和文档相关系的word_cnt拿出建立对应的倒排拉链
for &word : word_cnt{//具体一个词和123⽂档的对应关系,当有多个不同的词,指向同⼀个⽂档的时候,此时该优先显⽰谁??相关性!struct InvertedElem elem;elem.doc_id = 123;elem.word = word.first;elem.weight = 10*word.second.title_cnt + word.second.content_cnt ; //相关性,我们这⾥拍着脑⻔写了//插入关键字对应的倒排拉链inverted_index[word.first].push_back(elem);
}
关于如何分词,这里我们使用cppjieba分词。
可以在gitee中搜索cppjieba,随便选一个点进去
然后在linux中使用git clone拉到本地
git clone https://gitee.com/lycium_pkg_mirror/cppjieba.git
不过cppjieba还有一个地方需要注意,还需要把limonp也要克隆到本地,
git clone https://github.com/yanyiwu/limonp.git
如果下载目前对应路径下应该有这两个目录,
下面我们要把limonp拷贝到cppjieba/include/cppjieba下,不然使用就会有问题,找不到对应文件。
最后在你自己项目下使用软链接的方式去链接一下头文件所在路径,不然编译会找不到头文件在哪里。或者你使用绝对路径比如#include “/home/wdl/thridpart/cppjieba/include/cppjieba”
//Common.h#include"cppjieba/Jieba.hpp"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:static 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);
建立倒排索引,这里还有一点比如你搜索HELLO,hello,Hello,都会有hello的内容,这是因为搜索时忽略大小写了,因此我们在存关键字的时候也要忽略大小写,所以使用 boost::to_lower()把关键字变成小写在存
bool BuildInvertedIndex(const DocInfo& doc)
{//DocInfo{title, content, url, doc_id}//每个关键字在title和content出现的频次struct word_cnt{int title_cnt = 0;int content_cnt = 0;};//保存关键字和词频的映射关系std::unordered_map<std::string,word_cnt> word_map;//对标题进行分词std::vector<std::string> title_words;JiebaUtil::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;JiebaUtil::CutString(doc.content,&content_words);//对内容进行词频统计for(auto s : content_words){boost::to_lower(s); //需要统一转化成为小写word_map[s].content_cnt++;}//把在word_map的关键字放到倒排中for(auto& word_pair : word_map){InvertedElem elem;elem.doc_id = doc.doc_id;elem.word = word_pair.first;elem.weight = 10*word_pair.second.title_cnt + word_pair.second.content_cnt;inverted_index[word_pair.first].push_back(std::move(elem));}return true;
}
Index这个类整个项目有一份就够了,因此我们可以把它写成单例模式
#pragma once
#include "Common.h"
#include <mutex>// 正排索引文档Id对应文档的内容
typedef struct DocInfo
{std::string title; // 文档的标题std::string content; // 文档的内容std::string url; // 该文档在官网的urluint64_t doc_id; // 文档ID,建立倒排排序需要用到
} DocInfo;// 倒排索引分词对应的文档ID和权重
typedef struct InvertedElem
{uint64_t doc_id;std::string word;//关键字,等会对文档内容做摘要要用int weight;InvertedElem() : weight(0) {}} InvertedElem;// 倒排拉链
typedef std::vector<InvertedElem> InvertedList;class Index
{
private:// 正排索引的数据结构使用数组,数组的下标天然就是文档IDstd::vector<DocInfo> forward_index; // 正排索引// 倒排索引一定是一个关键字和一个或者多个InvertedElem对应关系(关键字和倒排拉链的映射关系)std::unordered_map<std::string, InvertedList> inverted_index;private://懒汉模式static Index* _Sint;static std::mutex _mtx;Index() {}Index(const Index&) = delete;Index& operator=(const Index&) = delete;public:static Index* GetInstance(){if(_Sint == nullptr){std::unique_lock<std::mutex> lock(_mtx);if(_Sint == nullptr){_Sint = new Index;}}return _Sint;}//...
};
Index* Index::_Sint = nullptr;
std::mutex Index::_mtx;