文章目录
项目宏观原理
有如下两句话:
- 文档1:雷军买了四斤小米
- 文档2:雷军发布了小米手机
一、正排索引
文档ID | 文档内容 |
---|---|
文档1 | 雷军买了四斤小米 |
文档2 | 雷军发布了小米手机 |
正排索引是直接使用文档ID找到文档的内容。
二、倒排索引
倒排索引是根据文档内容,分词,整理不重复的各个关键字,使用关键字来找到文档的ID。
关键字是不重复的,具有唯一性的。
关键字 | 文档ID |
---|---|
雷军 | 文档1,文档2 |
买 | 文档1 |
发布 | 文档2 |
四斤 | 文档1 |
小米 | 文档1,文档2 |
四斤小米 | 文档1 |
手机 | 文档2 |
小米手机 | 文档2 |
特别注意的是:“了”, “的” 等语气词不是关键字。
模拟查找过程
用户输入:小米
倒排索引查找关键字–>定位到文档ID–>正排索引–>还原文档内容–>title+content(desc)+url
文档结果进行摘要—>构建响应结果
三、标签和去标签和清洗模块parser.cc
本项目的数据源是boost_1_78_0/doc/html目录下的所有文件和文件夹内容。
所以需要实现一个parser.cc源文件来对boost_1_78_0/doc/html目录的数据源做索引和清洗。
什么叫做标签?
包含有<>的,就叫做标签。
这些标签在我们的索引中没有什么用,所以要去掉。
通常来说,标签都是成对出现的。比如 head 和 /head
但也有一些标签只有开始,没有结束。
[@**** data]$ ll
total 20
drwxrwxr-x 60 dzt dzt 16384 Mar 16 12:38 input
//这里存放原始的html文件内容
drwxrwxr-x 2 dzt dzt 4096 Mar 16 12:51 raw_html
//这里存放去标签后干净的文档
目的:把每个文档都去标签,然后将干净的内容放到写入到同一个文件中,每个文档内容不需要携带\n,用 \3 , \4等作为分割符即可!
xxxxxxxxxxxxxx\3yyyyyyyyyyyyyy\3zzzzzzzzzzzzz\4
因为\3,\4这些分割符是控制字符,不会显示出来。
四、parser.cc代码结构
从数据源读取文件名+路径
- 1.将要去标签和解析的源数据进行读取。符合要求的所有 .html文件放入vector容器中。
std::vectorstd::string files_list中
- 2.按照files_list读取到的文件内容,按照文件名一个个进行解析,解析成 标签+内容+url的方式保存在一个个结构体中
std::vector<DocInfo_t> results;
- 3.把解析好的内容塞到一个文件中
std::string output = “/data/raw_html/raw.txt”;
文件中的每一行都是 title\3content\3url \n 的形式
一行就是一个文件,方便后续提取构建正排和倒排索引。
//要读取的所有文件,放的全是html网页
const std::string src_path = "data/input/";
//将源文件解析后,干净的内容放到这里
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); //output作为一个将要被打开的文件,是输入型参数
int main()
{
//第一步,递归式读取文件名+路径,保存到files_list中,方便后续一个个的搜索
std::vector<std::string> files_list;
// 读取失败
if(!EnumFile(src_path,&files_list))
{
std::cerr<< "Enum File error" << std::endl;
return 1;
}
// 第二步,按照files_path读取的内容,一个个进行解析文件名。
std::vector<DocInfo_t> results;
if(ParserHtml(files_list, &results))
{
std::cerr<< "ParserHtml error" << std::endl;
return 2;
}
//第三步:把解析好的文件内容写入到output中,按照\3作为分隔符
if(SaveHtml(results,output))
{
std::cerr<< "SaveHtml error" << std::endl;
return 3;
}
return 0;
}
tips:boost库和boost搜索手册区别
在编写parser.cc文件时,需要用到boost库中的源代码,一些源函数进行编写, 所以要下载
- sudo yum install -y boost-devel
这个库的版本是1.53的版本。
而我们搜索数据源时,搜索的是boost库 boost_1_78_0版本中的html网页!
这两个boost文件是不一样的!
一个是写代码需要用到源代码库,一个是搜索数据源需要用到的库。
4.2 编写EnumFile模块
该模块的功能就是将boost_178_0库下的所有.html文件全部检索出来,并存放到一个vector中。
4.3 编写ParseHtml模块
该模块就是对每一个.html文件进行解析。
具体操作如下:
-
1.先读取文件,如果读取失败, 继续读取下一个文件
-
2.解析该文件,提取title
-
3.解析该文件,提取content
-
4.解析该文件,构建url
这里解析url的时候,我遇到了一个坑:传参问题。
前面两个的传参都是传的result,也就是传文件的内容过去解析。
而这个文件的url,是要解析文件的路径,所以要传一个文件的路径过去的。
4.4 将解析后的数据源保存
五、建立文件索引
图解正/倒排索引
首先应该有几个内容:
- 1.一个文档的信息:
struct DocInfo
{
std::string title; // 解析的文档的标题
std::string content; // 要解析文档的去标签后的内容
std::string url; // 文档在官网中的url
uint64_t doc_id; // 文档id
};
- 2.单个倒排索引元素结构体
这里由于需要建立倒排索引
而倒排索引是根据关键字,提取出一串含有该关键字的倒排拉链(也就是包含该关键字的多个文档)。通过获取倒排拉链的元素,就能获取到文档id,再通过文档id,进行正排索引,获取对应文档。
//倒排索引的结构体
struct InvertedElem
{
uint64_t doc_id; // 文档id
std::string word; //关键字
int weight; // 权重
};
1. 建立正排索引
根据读到的内容,建立正排索引
- 1.首先解析line内容,将内容按照分隔符’\3’,分割成三部分:title content url
- 2.将字符串填充到DocInfo中
- 3.将DocInfo插入到正排索引的vector中。
//根据读到的那一行内容建立正排索引
DocInfo* BuildForwardIndex(const std::string& line)
{
//1.解析line内容 --> title content url
std::vector<std::string> results; //把一个字符串line打散成三个字符串,放到results中
std::string sep = "\3";
ns_util::CutUtil::CutString(line,&results,sep);
if(results.size() != 3) // 不满足 title content url 完整的三部分
{
return nullptr;
}
//2.将字符串填充到DocInfo中
DocInfo doc;
doc.title = results[0];
doc.content = results[1];
doc.url = results[2];
doc.doc_id = forward_index.size(); //先进行保存id,再插入,对应的id就是当前doc在vector中的下标,无需-1了
//3.将DocInfo插入到正排索引的vector中
forward_index.push_back(doc);
return &doc;
}
这里建立正排索引的过程使用vector容器的原因:
vector容器的下标天然可以作为文档id
2. 建立倒排索引
- 1.将正排索引获取到的title和content进行切分。
- 2.遍历title_words和content_words容器,统计每个关键字出现的次数,即统计词频。
- 3.构建倒排拉链,一个关键字对应一个倒排拉链。
- 4.将所有的关键字汇总的所有倒排拉链集合起来,就是用户想要搜索的信息。
完整的搜索过程
以上的汇总:
引入jieba库
细节:编写倒排索引时,假如出现下面情况:
用户在输入Hello,hello,HELLO几种情况时,搜索到的文档是否应该一致。
jieba库的作用:使用jieba函数,对字符串进行切分
切分的比较完善。
八、编写searcher搜索代码
1.获取Index索引单例对象
void InitSearcher(const std::string& input)
{
index = ns_index::Index::GetInstance(); //获取单例对象
LOG(NORMAL,"获取单例索引对象成功!");
index->BuildIndex(input); // 建立索引模块
LOG(NORMAL,"建立索引成功!");
}
2.搭建Search模块
1)分词
//1.[分词]:对用户想要查找的query按照searcherd的要求进行分词
std::vector<std::string> words;
ns_util::JiebaUtil::Split(query,&words); //对用户想要发起搜索的字符串进行切分
从引入jieba库中,使用Split函数对用户输入的字符串进行切分。
2)触发
根据关键字信息,获取倒排拉链。
并对倒排拉链中的节点信息,进行去重处理。
//2.[触发]:根据分词的各个"词",进行index查找 ---注意细节:查找时不区分大小写
//一个关键字有一条拉链,用户输入的可能不止一个关键字,就会搜索到多条拉链,要将这些拉链保存起来
//ns_index::InvertedList inverted_list_all; // typedef std::vector<InvertedElem> InvertedList;
std::vector<InvertedElemPrint> inverted_list_all; // 保存对每个倒排拉链去重后的节点信息
std::unordered_map<uint64_t,InvertedElemPrint> tokens_map; //用来将多个文档去重,结果是map中保存的是每一个去重后的
//节点信息
for(auto &word : words)
{
boost::to_lower(word);
//先获取倒排拉链,
ns_index::InvertedList* inverted_list = index->GetInvertedIndex(word);
//一个关键字对应一条拉链,有可能该关键字不存在于文档中
if(nullptr == inverted_list)
{
continue;
}
// 你/是/一个/好人
//这里可能出现两个关键字获取到的倒排拉链都是一样的,就会导致在统一保存拉链时,
//inverted_list_all 内部会出现重复的拉链
//为了实现倒排拉链中节点的去重,不能再直接拿到拉链后就插入,需要先去重
for(auto& elem : *inverted_list)
{
auto& item = tokens_map[elem.doc_id]; //把拉链中的相同id文档提出来,[]:不存在就新建,存在就直接返回
item.doc_id = elem.doc_id;
item.weight += elem.weight;
item.words.push_back(std::move(elem.word));
}
//inverted_list_all.insert(inverted_list_all.end(),inverted_list->begin(),inverted_list->end());
}
//保存对每个倒排拉链去重后的节点信息
for(auto &item :tokens_map)
{
inverted_list_all.push_back(item.second);
}
3)合并排序
使用sort函数,对所有的倒排拉链,按照关键字出现的权重,进行降序排序。
//3.[合并排序]:汇总查找结果,按照相关性(weight)进行降序排序
//sort函数对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;});//Lambda匿名函数
std::sort(inverted_list_all.begin(),inverted_list_all.end(),
[](const InvertedElemPrint& e1,const InvertedElemPrint& e2)
{return e1.weight > e2.weight;});//Lambda匿名函数
4)构建json
//4.[构建]:根据查找出来的结果,构建json串 --jsoncpp 根据文档id正排索引即可
//遍历inverted_list_all,将文档id一个个拿出来,进行正排索引
Json::Value root;
for(auto &item : inverted_list_all)
{
//从已经排好序的inverted_list_all获取文档内容
ns_index::DocInfo* doc = index->GetForwardIndex(item.doc_id);
if(doc == nullptr) continue;
//构建json串
Json::Value elem;
elem["title"] = doc->title;
elem["desc"] = GetDesc(doc->content,item.words[0]); //这里的文件内容不是我们想要的,我们只想要一部分,后续会对这个地方处理
//GetDesc-->获取摘要的内容,不是要全部内容,只需要在摘要中显示出关键字
elem["url"] = doc->url;
//debug
//elem["weight"] = (int)item.weight;
//elem["id"] = (int)item.doc_id;
root.append(elem); //总的json串信息
}
//Json::StyledWriter writer; // 方便调试
Json::FastWriter writer; //正常使用
*json_string = writer.write(root); //形成搜索结果返回(输出型参数)
}
1.从content提取关键字摘要的模块编写
std::string GetDesc(const std::string& html_content, const std::string& word)
{
//思路:找到关键字word在html_content中的位置,往前找50字节(如果没有50字节,就从begin开始)
//往后找100字节(如果没有100字节,就从end结束) ,再截取这部分内容
//1.找到关键字word在content中的位置
//std::size_t pos = html_content.find(word); //这里也有问题 ,find函数不忽略大小写
//而之前在倒排索引插入时,已经统一将关键字全部小写化了。
//所以在find原文档时,就会出现大小写不匹配导致无法查找到文档的情况
//这里需要用c语言库的search函数
//if(pos == std::string::npos) //这种情况是绝对不会存在的
// return "None";
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.获取begin,end
int prev_step = 50;
int next_step = 100;
int begin = 0;
int end = html_content.size() - 1; //std:;size_t 无符号整形,这是大坑
if(pos > begin + prev_step) begin = pos - prev_step;
if(pos < end - next_step ) end = pos + next_step;
//3.截取内容
if(begin >= end) return "None2";
std::string desc = html_content.substr(begin, end - begin);
desc += "...";
return desc;
}
引入cpp-httplib库并编写httplib调用
#include "cpp-httplib/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);
httplib::Server svr;
svr.set_base_dir(root_path.c_str()); // 设置默认路径
//lambda
svr.Get("/s",[&search](const httplib::Request& req,httplib::Response& rsp){
if(!req.has_param("word")) //没有搜索关键字,不行
{
rsp.set_content("必须要有搜索关键字!","text/plain; charset=utf-8"); //返回一个普通文件plain ,也可以返回一个网页文件text/html
return;
}
//rsp.set_content("你好 world!","text/plain; charset=utf-8");
//到这里就获取到了关键字了,要提出来
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",8081);
return 0;
}
写在最后:关于实现该项目时遇到的一些bug
1.parser模块解析指定文件的url时,传参传的是文件路径,而不是文件内容
2.静态成员在类外初始化时需要指定类域的问题