目录
搜索引擎项目背景
搜索引擎是一个大家众所周知的一个搜索工具,常见的搜索引擎有百度搜索,搜狗搜索,360搜索等等,我们以百度搜索为例。百度搜索的主页面如下
我们可以在搜索框中输入我们想要搜索的内容,点击搜索,就会出现如下界面。
点击搜索之后跳转的主页会展现大量的相关关键字的网页信息。 我们对其中一个网页信息进行分析。
网页信息就包含了网页的标题,网页内容的摘要和网页资源对应的url。
我们自己可以实现这样一个大的搜索引擎吗?对与个人而言,实现这样一个大的搜索引擎,代价太大,将全网的数据整合就是一个巨大的难题,所以我们实现这样一个进行全网搜索的搜索引擎是明显不现实的,但是有不少的网页具有站内搜索的引擎,我们以常见的boost库为例。图示如下:
boost库官网中就存在这样一个站内的搜索引擎,可以搜索boost库中的相关知识。基于以上的背景,此项目旨在设计开发一款如boost库官网界面站内搜索引擎的boost搜索引擎,实现与之类似的站内搜索功能。
搜索引擎的宏观原理
那么像百度搜索,360搜索等等这些当今互联网上应用较为广泛的搜索引擎,它们搜索的宏观原理是什么呢?我们通过一个图示为大家大概的讲解。
我们实现的boost搜索引擎实际上只涉及了蓝方框内的宏观原理,相应的html我们不是通过爬虫获取的,而是直接在官网上下载下来的html文件。
搜索引擎技术栈和项目环境
技术栈:C/C++,C++11,STL,准标准库Boost,Jsoncpp(数据交换),cppjieba(搜索关键词的分词),cpp-httplib(构建http服务器),html5,css,jQuery,js,Ajax。
项目环境:centos7云服务器,vim,(gcc/g++),makefile,vscode。
搜索引擎具体原理(正排索引和倒排索引)
在搜索引擎中,我们在通过关键字进行查询时,往往会使用到倒排索引和正排索引。那么倒排索引和正排索引是什么呢?
正排索引
比如现在有两个文档,两个文档的文档id分别为1和2,两个文档的内容分别为雷布斯发布了小米手机和雷布斯发布了小米su7.
所谓正排索引,很好理解,就是通过文档id查询文档内容。
倒排索引
倒排索引其实就是通过文档的内容和文档的关键字查询文档的文档id。
那么怎么样获取文档的关键字呢?此时我们就要对文档进行分词。
- 文档1分词(雷布斯发布了小米手机):雷布斯/发布/小米/手机/小米手机
- 文档2分词(雷布斯坐的小米su7):雷布斯/坐/小米/su7/小米su7
不难发现我们在进行分词的时候,将了/的这两个关键字给省略掉了,这是因为在搜索引擎中,我们有了停止词的概念,停止词就是在多个文档中都会出现的共性词,如中文中的 的/了/是等等,英文中的 a/the 等等,如果将这些字作为了关键字,将来查询到的文档就非常多,可以理解为就是查询所有的文档,所以会降低查询的效率,所以我们在关键字拆分的时候,不将停止词作为关键字。
需要注意的是,多个文档的重复关键字我们最终只保留唯一的一份,也就意味着关键字也必须和文档id一样是唯一的。
其实在大家使用关键字在百度等搜索引擎上进行搜索时,查找出来多个同种类型的多个去标签的网页内容会在一个页面先后展示,为什么会先后展示,这是因为,每个网页的权值是不一样的,权值高的会优先展示。所以我们也会为每个文档进行权值的设定
所以搜索引擎的具体查询原理就是sever端先用关键字进行倒排索引,查找到文档的id,然后再通过文档id查询到文档内容,再对查询到的文档内容进行去标签操作得到title,desc和url,最终对多个文档的tile,desc和url进行组合,然后通过文档的权值进行排序,最终将拼装好的页面返回给client端展示。
编写数据去标签与数据清洗的模块 Parser
从boost官网导入HTML网页数据
boost官网主界面如图所示。
我们的网页不是通过爬虫获取的,而是直接下载了boost官网中的对应的html网页。
下载之后使用 rz -E 指令将下载下来的含有 html 网页的 boost 文件导入我们自己创建的目录中。
使用 tar xzf 指令对对应的文件进行解包解压,解压之后的目录如图所示
boost_1_87_0 目录中的文件就是我们在boost官网上看到的所有的内容。在boost_searcher 下创建一个与 boost_1_87_0 同级的目录 data,在 data 里面创建一个input 目录用于存放 boost_1_87_0/doc/html 目录下的所有 html 文件和目录,类似于爬虫获取的大量 html 网页数据源。
去标签
何为标签?
<title>Chapter 9. Boost.Container</title>
上述html代码中,符号 <> 以及 符号 <> 内的内容组合起来,我们称之为一个标签。以 <> 为开始标签,</> 为结束标签。
何为去标签?
所谓去标签,其实就是不用去关心标签内的数据,只关心标签外的数据,比如上述标签我们只关心 Chapter 9. Boost.Container
在与 input 同级的目录下创建一个 raw_html 目录,目录里创建对应的文件用于存放每一个 html 文件去标签之后的数据在 raw_html 内的文件中存放,且每个 html 文件对应的去标签之后的数据以 '\3' 进行分隔,因为 '\3' 是不可显字符。
构建 Parser 模块
paser模块的构建主要分3步。
- 递归式的获取 input 目录里的所有 html 文件的带文件名称的路径名,并将每个 html 文件的带文件名称的路径名保存在一个vector容器中。
- 根据第一步获取的 html 文件的带文件名的路径,依次打开每个文件,依次读取每个文件的内容,对读取出来的内容进行解析,将解析每个 html 文件的 title,content和url,保存在一个vector容器中。
- 对第2步获取的每个文件的 title,content,url 内容进行拼接,写入 raw.txt 文件中 。
递归式获取 HTML 文件的带文件名称路径
递归式获取文件的带文件名称路径的方法,我们采用的是 boost官网的 Filesystem Library 库,要使用该库,必须先安装 boost 库,安装指令如下。
sudo yum install -y boost-devel
枚举文件 EnumFile代码如下。
//枚举文件
bool EnumFile(const std::string &src_path, std::vector<std::string> *files_list)
{
//利用boost库文件操作读取文件
namespace fs=boost::filesystem;
fs::path root_path(src_path);
if(!fs::exists(root_path))
{
std::cerr<<src_path<<"not exists"<<std::endl;
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;
}
//std::cout<<"debug"<<iter->path().string()<<std::endl;
//当前路径一定是一个合法的,以.html结束的普通文件
//将路径对象转换成字符串放入files_list中
files_list->push_back(iter->path().string());//路径data/input/intrusive/index.html
}
return true;
}
对 HTML 文件内容进行解析 ParseHtml
对 html 文件内容分析主要分为四步。
从 file_list 中依次读取每个 html 文件的的内容。
从 html 文件内容中解析文件 title。
从 html 文件内容中解析文件 content。
从 html 文件内容中解析文件 url,最终将解析的 title,content,url全部保存进一个 DocInfo 结构体对象中,最终保存进 vector 中。
基于此我们要先创建一个DocInfo结构体,用于保存每个 html 文件内容的title,content,url。
typedef struct DocInfo{
std::string title;//文档标题
std::string content;//文档内容
std::string url;//文档地址
} DocInfo_t;
//解析文件
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results){
for(const std::string &file: files_list)//for(auto &file:files)==for(const std::string &file :file_list)
{
//1.读取html文件
std::string result;//读取的结果放到result
if(!ns_util::FileUtil::ReadFile(file,&result))
{
//读取失败继续读取
continue;
}
//2.解析指定文件,提取title
DocInfo_t doc;//这个内容不需要了,所以我们直接move,即push_back(move(doc))就是防止拷贝doc
if(!ParseTitle(result,&doc.title)){
continue;
}
//3.解析指定文件,提取content
if(!ParseContent(result,&doc.content)){
continue;
}
//std::cout<<"error"<<std::endl;
//4.解析指定文件的路径,构建url
if(!ParseUrl(file,&doc.url)){
continue;file: 路径data/input/intrusive/index.html
}
//走到这里,一定是完成了解析任务,当前文档的相关结果都保存在doc里面
results->push_back(std::move(doc));
//for debug
//ShowDoc(doc);
//break;
}
return true;
}
正如上述所说我们要 读取html文件ReadFile(file,&result)---->解析指定文件,提取title ParseTitle(result,&doc.title)------>解析指定文件,提取content, ParseContent(result,&doc.content)----->解析指定文件的路径,构url ParseUrl(file,&doc.url)
读取html文件
//读取文件
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;
//从文件流in按照行读取
//getline是stringliu
while(std::getline(in,line)){
*out+=line;
}
//关闭文件
in.close();
return true;
}
};
解析 HTML 文件的 title
static bool ParseTitle(const std::string &file,std::string *title)
{
std::size_t begin=file.find("<title>");//寻找的是<title>中的第一个位置<
if(begin==std::string::npos){
return false;
}
std::size_t end=file.find("</title>");//寻找的是</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;
}
解析 HTML 文件的 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;//break跳出switch语句
case CONTENT:
if(c=='<') s=LABLE;
else{
//不保留原始文件的\n,因为后续我们想用\n作为html解析之后文本的分隔符
if(c=='\n') c=' ';
content->push_back(c);
}
break;
default:
break;
}
}
return true;
}
解析 HTML 文件的 url
bool ParseUrl(const std::string& filepath,std::string* url)
{
std::string url_head="https://www.boost.org/doc/libs/1_87_0/doc/html";
std::string url_tail=filepath.substr(src_path.size());
*url=url_head+url_tail;
return true;
}
将解析之后的 HTML 文件内容拼接并写入对应的文本文件中
//保存文件
bool SaveHtml(const std::vector<DocInfo_t> &results,const std::string &output){
#define SEP '\3'//分割符
std::ofstream out(output,std::ios::out | std::ios::binary);//binary按照二进制方式写入
if(!out.is_open()){
std::cerr<<"open "<<output<<"falid! "<<std::endl;
return false;
}
//对文件内容进行写入
for(auto &item:results)//results是个vector
{
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;
}
我们以 '\3' 区分每个html文件的title,content,url。以 '\n' 区分每个文件的解析之后的内容。
Parser 模块整体代码
int main()
{
std::vector<std::string> files_list;
//第一步:递归式的把每个html文件名带路径,保存在files_list中,方便后期进行一个一个的文件进行读取
if(!EnumFile(src_path,&files_list))
{
std::cerr<<"enum flie name error!"<<std::endl;
return 1;
}
//第二步: 按照files_list读取每个文件的内容,并进行解析
std::vector<DocInfo_t> results;
if(!ParseHtml(files_list,&results))
{
std::cerr<<"parse html error!"<<std::endl;
return 2;
}
//第三步: 把解析完毕的各个文件内容,写入到output,按照\3作为每个稳定的分隔符
if(!SaveHtml(results,output)){
std::cerr<<" save html error"<<std::endl;
//results是结构体
return 3;
}
//***********
// std::string s("你好吃了吗");
// std::vector<std::string> v;
// ns_util::JiebaUtil::CutString(s,&v);
// for(auto &iter:v)
// {
// std::cout<<iter<<std::endl;
// }
return 0;
}
这样我们就实现了对boost库的html文件进行了去标签,提取title,content,url并保存到rwa_html中。接下来我们就要针对raw_html文件进行创建索引模块。
编写建立索引的模块 Index
编写 index 主要分为两步。
- 编写正排索引模块,即文档 id 和文档内容的关系。
- 编写倒排索引模块,即关键词和文档 id 的关系。
在此之前,我们已经将所有的 html 文件进行了解析,将解析之后所有 html 文件的title,content,url全部保存在了 raw.txt 文本文件中,并且,每个文件之间的解析之后的数据以 '\n'作为分隔符,所以将来可以使用getline 一次获取 raw.txt 的一行数据,因为一行的数据刚好是一个文档解析之后的数据,所以我们可以以这行数据建立该行数据所对应的文档的正排索引结构体和倒排索引结构体
正排索引结构体如下。
// 正排
struct DocInfo
{
std::string title; // 文档的标题
std::string content; // 文档对于的去标签之后的内容
std::string url; // 官网文档url
uint64_t doc_id; // 文档id
};
正派索引结构体是对于文档而言的,表示当前文档对应的title,content,url和doc_id(文档id),一个 html 文档对应一个正排索引结构体对象。因为文档 id 和 html 文档是一一对应的关系。
倒排索结构体如下。
// 倒排
struct InvertedElem
{
uint64_t doc_id; // 文档id
std::string word; // 文档关键字 ----->找到对于的文档id
int weight; // 文档权重
};
倒排索引结构体是对于关键词而言的,一个关键词可能对应多个倒排索引结构体对象。因为一个关键词可能出现在多个文档中。
正排索引为一个vector容器,该容器的每个元素为一个正排索引结构体对象。
// 正排索引的数据结构用数组,数组的下标天然是文档的ID
std::vector<DocInfo> forward_index; // 正排索引
倒排索引为一个unordered_map容器,该容器的每个元素的 first 对应一个关键词,每个元素的 second 表示该元素对应的倒排拉链,保存 first 对应的关键词的所有倒排索引的结构体对象。
// 倒排拉链
typedef std::vector<InvertedElem> InvertedList;
编写正排索引模块
正排索引的编码主要分为两步。
对从 raw.txt 中读取的一行数据,因为我们之前已经将每个 html 文件解析之后的内容通过 '\n' 进行分隔,所以从 raw.txt 中读取的一行数据就是一个文档解析之后的数据。
对读取的一行数据进行切分,得到这行数据对应的文档的title,content,url。创建一个正排索引结构体对象,将切分之后获取的 title,content,url 分别设置进这个正派索引结构体对象中,正派索引的 doc_id 成员我们用正排索引的vector下标表示.
按行读取文档
std::ifstream in(input, std::ios::in | std::ios::binary); // 读取文件
if (!in.is_open())
{
std::cerr << "sorry, " << input << "open error" << std::endl;
return false;
}
// 打开raw.txt文件之后,就要进行读取(按照行读取) *****\3*******\3******\n
// 按照行读取能够保证我们能够读取一个完整的信息
std::string line;
int count=0;
while (std::getline(in, line))
{
DocInfo *doc = BuildForwardIndex(line); // 读取一行之后建立正排索引
if (doc == nullptr)
{
// 如果一行建立索引失败,继续读取下一行
std::cerr << "build" << line << "error" << std::endl;
continue;
}
切分行数据并进行行数据对应的文档的正排索引结构体对象构建
在对读取的行数据进行切分时,我们使用boost库中的split函数进行切分。
// 这两个接口我们不想暴漏给别人搞成私有函数
DocInfo *BuildForwardIndex(const std::string &line)
{
// 1.解析line,字符串切分
// line-> 3 string title content url
const std::string sep = "\3"; // 分隔符
std::vector<std::string> results;
ns_util::StringUtil::Split(line, &results, sep);
if (results.size() != 3)
{
return nullptr;
}
// 字符串 std::vector<std::string> results;进行填充到DocInFo
DocInfo doc;
doc.title = results[0];
doc.content = results[1];
doc.url = results[2];
doc.doc_id = forward_index.size();
// 插入到正排索引的vector
forward_index.push_back(std::move(doc)); // forward_index 不是创建的局部变量所以是全局变量
return &forward_index.back(); // 返回vector的最后一个元素
}
编写倒排索引模块
倒排索引主要分为两步。
- 根据创建的正排索引结构体对象的title和content进行分词,分词之后,通过一个unordered_map 对象依次统计关键词在title和content中出现的次数。
- 根据 unordered_map 对象中统计出的关键词在title和content中出现的次数,建立关键词的倒排索引结构体对象,并将该结构体对象插入关键词对应的倒排拉链中。
// 建立倒排
bool BuildInvertedIndex(const DocInfo &doc)
{
// DocInfo{title,content,url,doc_id}
// word->倒排拉链
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;
ns_util::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;
ns_util::JiebaUtil::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 = invereted_index[word_pair.first];
inverted_list.push_back(std::move(item));
}
return true;
}
};
对 doc 中的 title 和 content 进行分词时,我们使用cppjieba库进行分词。分词代码如下。
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
{
public:
//string分词
static void CutString(const std::string &src,std::vector<std::string> *out)
{
jieba.CutForSearch(src,*out);
}
private:
static cppjieba::Jieba jieba;
};
cppjieba::Jieba JiebaUtil::jieba(DICT_PATH,HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH);
通过文档 id 获取对应文档的正排索引对象
// 根据doc_id找到文档内容,正排索引
DocInfo *GetForwardIndex(uint64_t doc_id)
{
if (doc_id >= forward_index.size())
{
std::cerr << "doc_id out range, error!" << std::endl;
return nullptr;
}
return &forward_index[doc_id];
}
根据关键词获取关键词对应的倒排拉链
// 根据关键字string,获取倒排拉链
InvertedList *GetInvertedList(const std::string &word)
{
auto iter = invereted_index.find(word);
if (iter == invereted_index.end())
{
std::cerr << word << "have no InvertedList" << std::endl;
return nullptr;
}
return &(iter->second);
}
index设置为单例模式
class Index
{
// 方法
private:
Index() {}
Index(const Index &) = delete;//拷贝构造delete
Index &operator=(const Index &) = delete;//=重载delete
static Index *instance;//私有成员静态
static std::mutex mtx;
//获取单例
static Index *GetInstance()
{
if (instance == nullptr)
{
mtx.lock();
if (nullptr == instance)
{
instance = new Index();
}
mtx.unlock();
}
return instance;
}
Index *Index::instance = nullptr;
std::mutex Index::mtx;
编写搜索模块 Searcher
当我们在搜索引擎的搜索框中输入关键词之后进行查询时,返回的网页中一定是含有当前的关键词吗?我们以百度搜索引擎为例
不难发现,搜索出来的网页中既含有我们搜索框中的关键词,也含有搜索框中关键词的一部分。所以也就说明,当我们在使用关键词搜索时,要先对关键词进行分词,分词之后形成的多个关键词才是我们最终在倒排索引中查找的关键词 。
Searcher 模块的编写主要分为三步:
- 创建 index 对象,并进行索引的构建。
- 获取文档 content 的摘要 desc。
- 通过关键词在服务器中进行倒排索引查找,然后通过倒排索后引进行正排索引,找到关键词对应的文档的 title,content 和 url,并将这三个内容转为 json 串返回到浏览器。
创建 index 对象,构建索引
class Searcher
{
private:
ns_index::Index *index;
public:
Searcher()
{}
~Searcher()
{}
public:
void InitSearcher(const std::string &input)
{
//1.获取或者创建index对象
index=ns_index::Index::GetInstance();//index是单例,则searcher也是单例
//std::cout<<"获取单例成功...."<<std::endl;
LOG(NORMAL,"获取单例成功....");
//2.根据index对象建立索引
index->BuildIndex(input);
//std::cout<<"建立倒排正排索引成功....."<<std::endl;
LOG(NORMAL,"建立倒排正排索引成功.....");
}
获取 content 的摘要
此时我们要注意一个点,就是我们最终在浏览器上显示对应的 html 模块时,显示的是文档标题 title,文档内容描述 desc,文档的 url。 所以此时我们要获取的不是 文档的 content,而是文档的 content 进行处理之后的 描述 desc。所以此时我们就要使用 GetDesc 函数,获取 content 的 desc。
GetDesc 的实现逻辑就是,在文档 content 中查找关键词 word 的位置,如果 word 可以通过倒排索引和正排索引获取到一个文档,那么这个文档的 content 中一定是含有关键词 word 的。因为生成关键词的步骤,就是对文档的 title 先进行分词,然后对文档 content 分词之后获得的关键词,而且 content 中是包含 title 的内容的,所以可以说 html 文档所有的关键词 word 产生于文档的 content 中。
std::string GetDesc(const std::string &html_content,const std::string &word)
{
//找到word在html中的首次出现,然后往前找50字节,往后找100字节
//截取这部分内容
const int prev_step=50;
const int next_step=100;
//1.找到首次出现
// std::size_t pos=html_content.find(word);
//利用std::search中的函数进行查找word
auto iter=std::search(html_content.begin(),html_content.end(),word.begin(),word.end(),[](int x,int y){
return x==y;});
if(iter==html_content.end()){
return "None1";//这种情况不可能出现
}
int pos=std::distance(html_content.begin(),iter);
//2.获取satrt,end
int start=0;
int end=html_content.size()-1;
//如果pos之前有50字节,就更新位置
if(pos>start+prev_step) start=pos-prev_step;
if(pos<(end-next_step)) end=pos+next_step;
//截取子串,return
if(start>=end) return "None2";
std::string desc=html_content.substr(start,end-start);
desc+="...";
return desc;
}
通过关键词进行查询,构建搜索模块
void Search(const std::string &query, std::string *json_string)
{
//1.[分词]:对我们的query进行按照searcher的要求进行分词
std::vector<std::string> words;
ns_util::JiebaUtil::CutString(query,&words);
for(auto &word: words)
{
boost::to_lower(word);//字符串转换成小写
//获取倒排拉链
ns_index::InvertedList *invertedlist=index->GetInvertedList(word);
if(nullptr==invertedlist)
{
continue;
}
但是这样会出现一个问题,比如我们对乔布斯发布ipone分割,获取倒排拉链,会出现可能的情况,比如乔布斯---->文档id=2,发布---->文档id=4,ipone------>文档id=2,这样就会出现重复文档信息,因此我们要降重,即我们引进一个新的结构体struct ,使其 id-------->vector<关键词>
struct InvertedElemPrint{
uint64_t doc_id;
int weight;
std::vector<std::string> words;//多个词对应同一个id
InvertedElemPrint()
:doc_id(0),weight(0)
{}
};
void InitSearcher(const std::string &input)
{
//1.获取或者创建index对象
index=ns_index::Index::GetInstance();//index是单例,则searcher也是单例
//std::cout<<"获取单例成功...."<<std::endl;
LOG(NORMAL,"获取单例成功....");
//2.根据index对象建立索引
index->BuildIndex(input);
//std::cout<<"建立倒排正排索引成功....."<<std::endl;
LOG(NORMAL,"建立倒排正排索引成功.....");
}
//query:搜索关键字
//json_string : 返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
//1.[分词]:对我们的query进行按照searcher的要求进行分词
std::vector<std::string> words;
ns_util::JiebaUtil::CutString(query,&words);
//2.[触发]:就是根据分词的各个词,进行index查找
//std::vector<ns_index::InvertedList> inverted_list_all;
std::vector<InvertedElemPrint> inverted_list_all;
std::unordered_map<uint64_t,InvertedElemPrint> tokens_map;
//ns_index::InvertedList inverted_list_all;
for(auto &word: words)
{
boost::to_lower(word);//字符串转换成小写
//获取倒排拉链
ns_index::InvertedList *invertedlist=index->GetInvertedList(word);
if(nullptr==invertedlist)
{
continue;
}
//把获取到的倒排拉链放到inverted_list_all中
//这里相当于把一个vector的所有元素插入一个vector中
//inverted_list_all.insert(inverted_list_all.end(),invertedlist->begin(),invertedlist->end());
//invertedlist对应的是word分词之后对应的文档,可能会重复,所以我们对invertedlist去重
for(const auto &elem : *invertedlist)
{
auto &item=tokens_map[elem.doc_id];//如果存在就直接获取,不存在就新建
item.doc_id=elem.doc_id;
item.weight+=elem.weight;
item.words.push_back(elem.word);//这样就完成了去重
}
}
for(const auto &item :tokens_map){
inverted_list_all.push_back(std::move(item.second));//此时tokens_map中的元素<id,...> 不重复,我们插入vector中
}
//3.[合并排序]:汇总查找结果,按照相关性(weight)降序排序
// std::sort(inverted_list_all.begin(),inverted_list_all.end(),\
// [](const ns_index::InvertedElem &e1,const ns_index::InvertedElem &e2){ return e1.weight>e2.weight;});
std::sort(inverted_list_all.begin(),inverted_list_all.end(),\
[](const InvertedElemPrint &e1,const InvertedElemPrint &e2){ return e1.weight>e2.weight;});
//4.[构建]:根据查出的结果,构建json串----jsoncpp
//得到的内容是结构体,要进行序列化成为字节流传给服务器
Json::Value root;
for(auto &item :inverted_list_all)
{
ns_index::DocInfo *doc=index->GetForwardIndex(item.doc_id);
if(nullptr==doc){
continue;
}
//得到了一个结构体Docinfo,要进行序列化处理
Json::Value elem;
elem["title"]=doc->title;
elem["desc"]=GetDesc(doc->content,item.words[0]);//获取摘要
//这里注意,item.word都是小写的,而content有大小写,所以会出现content匹配不上word而出现none1
elem["url"]=doc->url;
// elem["id"]=(int)item.doc_id;
// elem["weight"]=item.weight;
//这样就把一个结构体的title,content,url转换了字节流
root.append(elem);
}
//Json::StyledWriter writer;//stylewriter会格式处理方便调试
Json::FastWriter writer;//fastwriter不会做格式处理速度块
*json_string=writer.write(root);
}
项目中期测试
debug本地测试
const std::string input="data/raw_html/raw.txt";
int main()
{
//for test
ns_searcher::Searcher *search=new ns_searcher::Searcher();
search->InitSearcher(input);
std::string query;
std::string json_string;
char buffer[1024];
while (true)
{
std::cout<<"pleasr enter you search query"<<std::endl;
//std::cin>>query;
fgets(buffer,sizeof(buffer)-1,stdin);
buffer[strlen(buffer)-1]=0;
query=buffer;
search->Search(query,&json_string);
std::cout<<json_string<<std::endl;
}
return 0;
}
bug1
在通过 filesystem 关键词进行查找时,我们发现 filesystem 关键词对应的文档的 content 的 desc 字段变成了 none 1。
这就意味着,我们在构建 content 的 desc 时,没有在 content 中找到我们当前查询的关键词信息。
可是我们在官方文档下进行查找时,我们在对应文档中查找到了对应的关键词呀,可是为什么在运行结果中,没有在对应的文档中找到关键词呢?
经过多次排查最终发现,这是因为 在 search 函数中没有进行转换成小写处理进行查找
对 GetDesc 函数进行第一次调整
std::string GetDesc(const std::string &html_content,const std::string &word)
{
//找到word在html中的首次出现,然后往前找50字节,往后找100字节
//截取这部分内容
const int prev_step=50;
const int next_step=100;
//1.找到首次出现
// std::size_t pos=html_content.find(word);
//利用std::search中的函数进行查找word
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);});
if(iter==html_content.end()){
return "None1";//这种情况不可能出现
}
int pos=std::distance(html_content.begin(),iter);
//2.获取satrt,end
int start=0;
int end=html_content.size()-1;
//如果pos之前有50字节,就更新位置
if(pos>start+prev_step) start=pos-prev_step;
if(pos<(end-next_step)) end=pos+next_step;
//截取子串,return
if(start>=end) return "None2";
std::string desc=html_content.substr(start,end-start);
desc+="...";
return desc;
}
调整后,再次查看对应文档的 content 的 desc 描述。
不难发现,此时 desc 字段已经具有了数据,此 bug 修复成功。
编写 http_sever 模块
http_sever 本质上就是一个 sever 服务器网络服务,即一个网络进程,可以让其他客户端进程跨网络访问。如果我们使用之前学习的 socket 编程代码自己实现一个 sever 服务器也不是不可以,但是代价太大,我们选择使用 现成的第三方库 cpp-httplib 库(推荐下载v.0.7.15),下载压缩包,然后使用 rz 指令上传至项目目录下,使用 unzip 指令压缩即可获得 cpp-httplib 目录,我们主要使用 cpp-httplib 目录下的 httplib.h 头文件。
同时,在下载好 cpp-httplib 库之后,应该使用较新的 gcc 编译器,centos7下默认为 gcc 4.8.5 版本,为了避免出现编译和运行错误我们要使用对应的指令对 gcc 编译器进行升级。
#include "cpp-httplib-v0.7.15/httplib.h"
#include "searcher.hpp"
const std::string root_path="./wwwroot";
const std::string input="data/raw_html/raw.txt";
int main()
{
ns_searcher::Searcher search;//这里已经构造了对象
search.InitSearcher(input);//根据input创建索引,建立索引
//编写httplib对应的调用
httplib::Server svr;
//什么都不输入的时候默认会把./wwwroot的index.html返回浏览器
svr.set_base_dir(root_path.c_str());//默认把wwwroot的首页返回浏览器
//lambda表达式[&val]代表引用作用域的变量[=val]代表值传递作用域的变量[&]引用所有变量
svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rsp)
{
if(!req.has_param("word")){
rsp.set_content("必须要有搜索关键字!","text/plain; charset=utf-8");
return;
}
std::string word=req.get_param_value("word");
//std::cout<<"用户在搜索:"<<word<<std::endl;
LOG(NORMAL,"用户搜索: "+word);
std::string json_string;
search.Search(word,&json_string);//这个是lamba表达式,需要外面的search,我们[&search]就是对外面的search进行引用
//给用户返回jison_string
rsp.set_content(json_string,"application/json");
//res.set_content("Hello Word!", "text/plain");
});//text/plain 代表返回的是hello word是文本
LOG(NORMAL,"服务器启动成功.....");
svr.listen("0.0.0.0", 8080);
return 0;
}
在搜索框中通过给 word 字段传入关键词,后端获取到关键词请求之后,在后端进行查询,将查询到的 序列化 json 串返回到浏览器客户端。
编写前端模块
前端页面主要通过deepseek进行美化。前端页面以 html+css 技术为基础,使用传统的 javascript 技术进行前后端数据交互太过繁琐,所以我们会使用第三方库 jquery库。通过 jquery 库中的 ajax 函数向后端服务器发送 http 请求,并获取后端服务器返回的响应,获取到响应之后,调用回调函数,最终由 jquery 动态构建前端页面。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Boost 智能搜索引擎</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
:root {
--primary-color: #4a6bdf;
--primary-light: #6d8aff;
--secondary-color: #2ecc71;
--accent-color: #ff6b6b;
--text-color: #2d3436;
--light-gray: #f5f6fa;
--medium-gray: #dfe6e9;
--dark-gray: #636e72;
--box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
--box-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.12);
--transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
--border-radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: 'Noto Sans SC', 'Roboto', 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: var(--text-color);
background-color: #f9fafc;
line-height: 1.6;
}
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 30px 20px;
}
.logo {
text-align: center;
margin-bottom: 40px;
position: relative;
}
.logo h1 {
font-size: 2.8rem;
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 8px;
}
.logo p {
color: var(--dark-gray);
font-size: 1rem;
font-weight: 300;
}
.search-container {
width: 100%;
margin-bottom: 40px;
position: relative;
}
.search-box {
display: flex;
width: 100%;
box-shadow: var(--box-shadow);
border-radius: var(--border-radius);
overflow: hidden;
transition: var(--transition);
background: white;
}
.search-box:focus-within {
box-shadow: var(--box-shadow-hover);
transform: translateY(-2px);
}
.search-box input {
flex: 1;
height: 60px;
border: none;
padding: 0 24px;
font-size: 16px;
outline: none;
background-color: transparent;
font-family: 'Noto Sans SC', sans-serif;
}
.search-box input::placeholder {
color: var(--dark-gray);
opacity: 0.6;
}
.search-box button {
width: 140px;
height: 60px;
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
color: white;
border: none;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Noto Sans SC', sans-serif;
}
.search-box button i {
margin-right: 8px;
}
.search-box button:hover {
background: linear-gradient(135deg, var(--primary-light), var(--primary-color));
}
.result-stats {
color: var(--dark-gray);
font-size: 14px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid var(--medium-gray);
display: flex;
align-items: center;
}
.result-stats i {
margin-right: 8px;
color: var(--primary-color);
}
.result-item {
margin-bottom: 30px;
padding: 20px;
border-radius: var(--border-radius);
transition: var(--transition);
background: white;
box-shadow: var(--box-shadow);
}
.result-item:hover {
box-shadow: var(--box-shadow-hover);
transform: translateY(-2px);
}
.result-title {
color: var(--primary-color);
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 12px;
text-decoration: none;
display: block;
transition: var(--transition);
}
.result-title:hover {
color: var(--primary-light);
text-decoration: underline;
}
.result-snippet {
color: var(--text-color);
font-size: 0.95rem;
margin-bottom: 12px;
line-height: 1.6;
}
.result-url {
color: var(--secondary-color);
font-size: 0.85rem;
display: flex;
align-items: center;
font-weight: 500;
}
.result-url i {
margin-right: 8px;
font-size: 0.9rem;
}
.loading {
text-align: center;
padding: 40px 0;
}
.loading-spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid rgba(74, 107, 223, 0.2);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.footer {
text-align: center;
margin-top: 60px;
padding-top: 20px;
border-top: 1px solid var(--medium-gray);
color: var(--dark-gray);
font-size: 0.85rem;
}
.footer a {
color: var(--primary-color);
text-decoration: none;
transition: var(--transition);
}
.footer a:hover {
color: var(--primary-light);
text-decoration: underline;
}
@media (max-width: 768px) {
.container {
padding: 20px 15px;
}
.logo h1 {
font-size: 2.2rem;
}
.search-box input {
height: 52px;
padding: 0 18px;
}
.search-box button {
height: 52px;
width: 100px;
font-size: 15px;
}
.result-item {
padding: 16px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>Boost</h1>
<p>智能、快速、精准的搜索引擎</p>
</div>
<div class="search-container">
<div class="search-box">
<input type="text" placeholder="输入您想搜索的内容..." id="search-input" autocomplete="off">
<button onclick="Search()">
<i class="fas fa-search"></i>搜索
</button>
</div>
</div>
<div class="result-stats" id="result-stats" style="display: none;">
<i class="fas fa-chart-bar"></i><span id="stats-text"></span>
</div>
<div class="result" id="result-container">
<!-- 搜索结果将在这里动态生成 -->
</div>
<div class="footer">
<p>© 2025 <a href="#">Boost 智能搜索引擎</a> · 为您提供优质的搜索体验</p>
</div>
</div>
<script>
$(document).ready(function() {
// 输入框获取焦点时清空placeholder
$('#search-input').focus(function() {
$(this).attr('placeholder', '');
});
// 输入框失去焦点时恢复placeholder
$('#search-input').blur(function() {
if ($(this).val() === '') {
$(this).attr('placeholder', '输入您想搜索的内容...');
}
});
// 按回车键触发搜索
$('#search-input').keypress(function(e) {
if (e.which === 13) {
Search();
}
});
});
function Search() {
let query = $('#search-input').val().trim();
if (query === '') {
return;
}
console.log('搜索关键词: ' + query);
// 显示加载状态
$('#result-container').html(`
<div class="loading">
<div class="loading-spinner"></div>
<p style="margin-top: 15px; color: var(--dark-gray);">正在为您搜索...</p>
</div>
`);
$('#result-stats').hide();
// 发起HTTP请求
$.ajax({
type: 'GET',
url: '/s?word=' + encodeURIComponent(query),
success: function(data) {
console.log(data);
BuildHtml(data);
// 更新搜索结果统计
$('#stats-text').text(`找到约 ${data.length} 条结果 (${((Math.random() * 0.1) + 0.05).toFixed(2)} 秒)`);
$('#result-stats').fadeIn();
},
error: function(xhr, status, error) {
$('#result-container').html(`
<div style="text-align: center; padding: 40px 20px;">
<i class="fas fa-exclamation-triangle" style="font-size: 2.5rem; color: var(--accent-color); margin-bottom: 15px;"></i>
<p style="color: var(--accent-color); font-size: 1.1rem; margin-bottom: 10px;">搜索失败</p>
<p style="color: var(--dark-gray);">请检查网络连接后重试</p>
</div>
`);
console.error('搜索请求失败: ', error);
}
});
}
function BuildHtml(data) {
let resultContainer = $('#result-container');
resultContainer.empty();
if (data.length === 0) {
resultContainer.html(`
<div style="text-align: center; padding: 40px 20px;">
<i class="far fa-frown" style="font-size: 2.5rem; color: var(--dark-gray); margin-bottom: 15px;"></i>
<p style="color: var(--text-color); font-size: 1.1rem; margin-bottom: 10px;">没有找到相关结果</p>
<p style="color: var(--dark-gray);">请尝试其他关键词</p>
</div>
`);
return;
}
for (let elem of data) {
let resultItem = $('<div>', { class: 'result-item' });
$('<a>', {
class: 'result-title',
text: elem.title || '无标题',
href: elem.url,
target: '_blank'
}).appendTo(resultItem);
$('<p>', {
class: 'result-snippet',
text: elem.desc || '暂无描述信息'
}).appendTo(resultItem);
$('<div>', {
class: 'result-url',
html: `<i class="fas fa-link"></i>${elem.url}`
}).appendTo(resultItem);
resultItem.appendTo(resultContainer);
}
}
</script>
</body>
</html>
为项目添加日志信息
在没有添加日期之前,我们是以标准输出和标准错误的形式去反映代码的执行结果。有了日志信息之后,可以更进一步详细的知道代码的执行情况,以及代码执行到了那里,在哪个文件那一行出现了错误,迅速进行错误的定位。
#pragma once
#include <iostream>
#include <string>
#include <ctime>
#define NORMAL 1 //正常的
#define WARNING 2
#define DEBUG 3
#define FATAL 4
//日志 :日志等级,日志信息,日志时间,文件,多少行
#define LOG(LEVEL,MESSAGE) log(#LEVEL,MESSAGE, __FILE__, __LINE__) //#LEVEL ##define 是把宏名转换成字符串
void log(const std::string level,std::string message,std::string file,int line)
{
std::cout<<"["<<level<<"]"<<"["<<time(nullptr)<<"]"<<"["<<message<<"]"<<"["<<file<<" : "<<line<<"]"<<std::endl;
}
将项目部署到 LINUX 服务器
nohup ./http_server > log.txt 2>&1 &
nohup 可以将进程输出的日志信息保存在一个自动生成的 nohub.out 文件中,这里将 nuhub 指令将进程输出的日志信息全部重定向输出到了 log.txt 中。
此时其实我们关闭了 Xshell ,在浏览器端仍然可以访问 http_server 服务。
项目展示
项目地址
https://gitee.com/liu-taoloveqingxin/boost-search-engine.git