编译环境deepin-vim,这是一个基于github,新浪微博和boost库的简单搜索引擎的后端实现,前端具体实现我在github上面看见有两个非常完备的搜索引擎项目:
https://github.com/mamengchen/searchEngine是一个基于RSS文本内容检索的轻量级搜索引擎
https://github.com/mamengchen/Search_Project是一个模拟百度搜索的方式实现站内搜索引擎,整个项目分为两大部分,HTTP服务器和搜索服务器
1.前提
搜索引擎是一个经久不衰的项目,以后的时代必然是互联网时代,而上网我们大多数情况都会做搜索相关的事情。所以掌握最基础的实现索引技术是程序员必然的基本素养之一。
完备的搜索引擎:
- 带有一个输入框
- 点击搜索之后,得到了搜索界面
- 搜索结果页中的每个结果都是和查询词有一定的关联关系的
- 点击搜索的时候,会跳转到另外一个网站上
github,新浪微博都是可以从浏览器上下载它的代码的,boost是我们要用其中的boost::filesystem提供了一些对系统文件的操作方法,具体方法概述请参考:https://www.cnblogs.com/yxysuanfa/p/7400250.html
2.实现原理以及示意图
2.1示意图:
2.2实现原理:
2.2.1文件解剖器的中的详细过程。
数据处理模块
把boost文档中涉及到的html进行处理:
1、去标签
2、把文件进行合并
把文件中涉及到的N个HTML的内容合并成一个行文本文件。
生成的结果是一个大文件,里面包含很多行,每一行对应boost文档中的一个html,这么做的目的是为了
让后面的索引模块处理来跟方便
3、对文档的结构进行分析,提取出文档的标题,正文,目标url
这个就是对boost中筛选出后缀是.html的文件,把其中的目录和不是这个后缀的去掉,并且把筛选过的文件放入的file_list中去。其中用到了boost::filesystem的一些方法这里提供一个链接可以帮助理解:https://www.cnblogs.com/yxysuanfa/p/7400250.html
bool EnumFile(const std::string& input_path,
std::vector<std::string>* file_list)
{
//命名空间的简化
namespace fs = boost::filesystem;
//input_path 是一个字符串,根据这个字符串构造出一个path对象
fs::path root_path(input_path); //初始化
if (!fs::exists(root_path)) //如果文件不存在打印下面的话
{
std::cout << "input_path not exist! input_path = "
<< input_path << std::endl;
return false;
}
//boost递归遍历目录,借助一个特殊的迭代器
//下面是构造一个未初始化的迭代器作为遍历结束标记
fs::recursive_directory_iterator end_iter;
for (fs::recursive_directory_iterator iter(root_path); //从头读到尾部
iter != end_iter; ++iter)
{
//a>此处我们应该剔除目录
if (!fs::is_regular_file(*iter))
{
continue;
}
//b>根据扩展名,只保留 html
if (iter->path().extension() != ".html")
{
continue;
}
file_list->push_back(iter->path().string());
}
return true;
}
这里通过我对html语言的了解知道文本的标题它都用<title></title>包含着,先用find找到他们第一个点下表,然后使第一个下标走个它的长度就到你标题的下标了,标题下标到</title>取出来就是标题信息,这里提到的正则表达式是应付更加麻烦的分类的方法就是加入你还要细分有些东西就是得靠一些特殊字符,这是正则表达式的demo:https://www.cnblogs.com/lizhenlin/p/6654934.html
//从html中的title标签中提取标题
//正则表达式
//<title></title>
bool ParseTitle(const std::string& html, std::string& title)
{
//1、先查找<title>标签
size_t beg = html.find("<title>");
if (beg == std::string::npos)
{
std::cout << "<title> not found!" << std::endl;
return false;
}
//2、再查找</title>标签
size_t end = html.find("</title>");
if (end == std::string::npos)
{
std::cout << "</title> not found!" << std::endl;
return false;
}
//3、通过字符串取子串的方式获取到title标签中的内容
beg += std::string("<title>").size();
if (beg > end)
{
std::cout << "beg end error!" << std::endl;
return false;
}
title = html.substr(beg, end - beg);
return true;
}
这里代码解释很清楚了,但是有个点得说说就是在html中你在正文中输入的<>都是被转义的字符,所以不必担心会出现少读了正文中<>中的文字
//除了便签之外的东西,都认为是正文
bool ParseContent(const std::string& html, std::string* content)
{
//一个一个字符读取。
//如果当前字符是正文内容,写入结果
//如果当前字符是<认为标签开始,接下的字符就舍弃.
//一直遇到>认为标签结束,接下来的字符就恢复
//
//这个变量为true意味着当前在处理正文
//为false意味着当前在处理标签
bool is_content = true;
for (auto c : html)
{
if (is_content)
{
//当前为正文状态
if (c == '<')
{
//进入标签状态
is_content = false;
}
else
{
//当前字符就是普通的正文字符,需要加入到结果中
if (c == '\n')
{
c = ' ';
//此处把换行替换为空格,为了最终的行文本文件
}
content->push_back(c);
}
} else {
//当前是标签状态
if (c == '>') {
is_content = true;
}
}
}
return true;
}
这里用来获取URL,boost是有一个共同前缀的,后文件路径
//Boost文档URL有一个统一的前缀
//https://www.boost.org/doc/libs/1_66_0/doc/
//URL的后半部分可以通过该文档的路径中解析出来
//文档的路径形如
//../data/input/html/thread.html
//需要的后缀的形式
//html/thread.html
bool ParseUrl(const std::string& file_path, std::string* url)
{
std::string prefix = "https://www.boost.org/doc/libs/1_66_0/doc/";
std::string tail = file_path.substr(g_input_path.size());//获得一个字符串的子字符串
*url = prefix + tail;
return true;
}
这里就是分析文件的如何执行的总步骤,上面的都是下面的每一步需要做的函数,打开文件在Util.hpp中
bool ParseFile(const std::string& file_path, DocInfo* doc_info)
{
//1、打开文件,读取文件内容
std::string html;
bool ret = FileUtil::Read(file_path, &html);
if (!ret)
{
std::cout << "Read file failed! file_path = " << file_path << std::endl;
return false;
}
//2、解析标题
ret = ParseTitle(html, doc_info->title);
if (!ret)
{
std::cout << "ParseTitle failed! file_path" << file_path << std::endl;
return false;
}
//3、解析正文,并且去除html标签
ret = ParseContent(html, &doc_info->content);
if (!ret)
{
std::cout << "ParseContent failed! file_path = " << file_path << std::endl;
return false;
}
//4、解析出url
ret = ParseUrl(file_path, &doc_info->url);
if (!ret)
{
std::cout << "ParseUrl failed! file_path= " << file_path << std::endl;
return false;
}
return true;
}
2.2.1索引以及搜索模块的实现
①正派索引:正排表是以文档的ID为关键字,表中记录文档中每个字的位置信息,查找时扫描表中每个文档中字的信息直到找出所有包含查询关键字的文档。
②倒排索引:倒排表以字或词为关键字进行索引,表中关键字所对应的记录表项记录了出现这个字或词的所有文档,一个表项就是一个字表段,它记录该文档的ID和字符在该文档中出现的位置情况。
比较好理解的正排索引以及倒排索引的解释博客:https://www.cnblogs.com/Onlywjy/p/8372452.html
这里是搜索引擎稍微重要的一点步骤了:构建索引模块和搜索模块,倒排索引是基于正排索引,而搜索是基于索引的
#pragma once
#include <string>
#include <iostream>
#include <vector>
#include <unordered_map>
#include "cppjieba/Jieba.hpp"
//构建索引模块和搜索模块
namespace searcher {
//这个结构体表示着索引模块
struct DocInfo {
uint64_t doc_id; //文档id
std::string title; //标题
std::string content; //正文
std::string url; //url
};
//Weight 表示的含义是某个词在某个文档中出现过,
//以及该词的权重是多少
struct Weight {
uint64_t doc_id; // size_t
int weight; //权重,为了后面进行排序准备(采用词频计算权重)
std::string key;
};
//类型重命名,创建一个“倒排拉链”类型
typedef std::vector<Weight> InvertedList;
//通过这个类来描述索引模块
class Index {
private:
//知道id获取到对应的文档内容
//使用vector的下表来表示文档id
std::vector<DocInfo> forward_index_;
//知道某个词,获取到对应的id列表
std::unordered_map<std::string, InvertedList> inverted_index_;
cppjieba::Jieba jieba_;
public:
//读取 raw_input 文件,在内存中构建索引
//input_path就是上面那个文件的路径
bool Build(const std::string& input_path);
//查正排,给定 id找到文档内容
const DocInfo* GetDocInfo(uint64_t doc_id) const;
//查倒排,给定词,找到这个词在哪些文档中出现过
const InvertedList* GetInvertedList(const std::string& key) const;
void CutWord(const std::string& input,
std::vector<std::string>* output);
Index();
private:
const DocInfo* BuildForward(const std::string& line);
void BuildInverted(const DocInfo* doc_info);
};
//搜索模块
class Searcher {
private:
Index* index_;
public:
Searcher():index_(new Index())
{}
~Searcher()
{
delete index_;
}
//加载索引
bool Init(const std::string& input_path);
//通过特定的格式再 result 字符串中表示搜索结果
bool Search(const std::string& query, std::string* result);
private:
std::string GetDesc(const std::string& content,
const std::string& key);
};
} //end searcher
基于索引模块函数的函数理解:
1.构建索引:
//读取 raw_input 文件,在内存中构建索引
bool Index::Build(const std::string& input_path)
{
std::cout << "Index Build Start!" << std::endl;
//1、按行读取文件内容(每一行就对应一个文档)
std::ifstream file(input_path.c_str());
if (!file.is_open())
{
std::cout << "input_path open failed! input_path=" << input_path << std::endl;
return false;
}
std::string line; //每次按行读出的这个内容不包含结尾的"\n"
while (std::getline(file, line)) {
//2、构造DocInfo对象,更新正排索引数据
// 对读到的一行文件进行解析,得到DocInfo 对象再插入vector
const DocInfo* doc_info = BuildForward(line);
//3、更新倒排索引数据
BuildInverted(doc_info);
if (doc_info->doc_id % 100 == 0) {
std::cout << "Build doc_id = " << doc_info->doc_id << std::endl;
}
}
std::cout << "Index Build Finish!" << std::endl;
file.close();
return true;
}
BuildForward是用来构建正排索引,这里我们用一些特殊字符把标题,正文,url分开,在给它们分配好id这样正排索引就建立好了
const DocInfo* Index::BuildForward(const std::string& line)
{
//1.对这一行内容进行切分(\3)
std::vector<std::string> tokens; //存放切分结果
//借助boost进行切分
StringUtil::Split(line, &tokens, "\3");
if (tokens.size() != 3)
{
std::cout << "tokens not ok" << std::endl;
return NULL;
}
//2.构造一个DocInfo对象
DocInfo doc_info;
doc_info.doc_id = forward_index_.size();//id
doc_info.title = tokens[0];//标题
doc_info.url = tokens[1];//正文
doc_info.content = tokens[2];//url
//3.把这个对象插入到正排索引中
forward_index_.push_back(doc_info);//把元素push进入索引对象里
return &forward_index_.back();//back返回最后一个元素的应用
}
这个就是构建倒排索引,先对正排索引的正文和标题分词,统计词频,这里我们用到了jieba来分词,boost::to_lower来统计词频时忽略大小写,此过程较为难理解:我先把正排的拿到,用CutWord进行分词,把分词结果放入vector<string>类型中的title_tokens与content_tokens,然后定义一个结构体WordCnt来进行标题和正文的词频统计,在创建了一个哈希map:word_cnt,word是键值,词出现的次数是value。然后遍历这个哈希map,创建一个Weight来存放doc_id(这个词在那些文本),权重就是为了以后给搜索排序的一个相关程度,key(就是这个词在倒排中的位置),然后在把这个Weight,Push进倒排索引
void Index::BuildInverted(const DocInfo* doc_info)
{
//1.先对当前的doc_info进行分词,对正文分词,对标题分词
std::vector<std::string> title_tokens;
CutWord(doc_info->title, &title_tokens);
std::vector<std::string> content_tokens;
CutWord(doc_info->content, &content_tokens);
//2.对 doc_info 中的标题和正文进行词频统计
// 当前词在标题中出现几次,在正文中出现几次
struct WordCnt {
int title_cnt;
int content_cnt;
};
//用一个 hash表完成词频的统计
//word是键值,title_cnt与content_cnt是value
std::unordered_map<std::string, WordCnt> word_cnt;
for (std::string word : title_tokens)
{
//假设正文中出现 hello, HELLO, 应该算一个词出现两次
//统计词频时忽略大小写
boost::to_lower(word);
++word_cnt[word].title_cnt;
}
for (std::string word : content_tokens)
{
boost::to_lower(word);
++word_cnt[word].content_cnt;
}
//3.遍历分词结果,在倒排索引中查找
//word_pair => std::pair
for (const auto& word_pair : word_cnt)
{
Weight weight;
weight.doc_id = doc_info->doc_id;//这个是文本id
weight.weight = 10 * word_pair.second.title_cnt + word_pair.second.content_cnt; //权重
weight.key = word_pair.first; //把这个词顺便记录这个就是以后的键值
InvertedList& inverted_list = inverted_index_[word_pair.first];
inverted_list.push_back(weight);
}
return;
}
//查正排,给定 id找到文档内容
const DocInfo* Index::GetDocInfo(uint64_t doc_id) const
{
if (doc_id >= forward_index_.size())
{
return nullptr;
}
return &forward_index_[doc_id];
}
//查倒排,给定词,找到这个词在哪些文档中出现过
const InvertedList* Index::GetInvertedList(const std::string& key) const
{
/*
std::unordered_map<std::strint, InvertedList>::
const_iterator pos = inverted_index_.find(key);
*/
auto pos = inverted_index_.find(key);
if (pos == inverted_index_.end())
{
//没找到
return nullptr;
}
//unordered_map迭代器指向的数据类型是啥?
return &pos->second;
}
void Index::CutWord(const std::string& input,
std::vector<std::string>* output)
{
jieba_.CutForSearch(input, *output);
}
2.搜索模块:
根据用户输入的查询词,对索引进行查找,最终找出那些文档和这个词相关
- 分词:对查询词进行分词
- 匹配:针对每个分词结果查找倒排索引
- 排序:按照词的出现频率进行降序排序
- 构造返回结果:根据触发得到的id列表查正排索引得到搜索结果
//以下代码是搜索模块的实现
bool Searcher::Init(const std::string& input_path)
{
return index_->Build(input_path);
}
bool Searcher::Search(const std::string& query,
std::string* json_result)
{
//1.[分词]对查询进行分词
std::vector<std::string> tokens;
index_->CutWord(query, &tokens);
//2.[匹配]针对分词结果查倒排索引,找到那些文档是具有相关性的
std::vector<Weight> all_token_result;
for (std::string word : tokens)
{
boost::to_lower(word);
auto* inverted_list = index_->GetInvertedList(word);
if (inverted_list == NULL)
{
//不能因为某个分词结果在索引中不存在就影响到其他的分词
//结果的查询
continue;
}
//此处进一步的改进是考虑不同的分词结果对应相同文档id的情况
//此时需要进行去重,和权重合并,
//此处实现的思想,类似于,合并有序链表
all_token_result.insert(all_token_result.end(),
inverted_list->begin(),
inverted_list->end());
}
//3.[排序]把这些结果按照一定规则排序
//sort 第三个参数可以使用 仿函数/函数指针/lambda 表达式
//lambda 表达式就是一个匿名函数
std::sort(all_token_result.begin(), all_token_result.end(),
[](const Weight& w1, const Weight& w2){
return w1.weight > w2.weight;
});
//4.[构造结果]查正排,找到每个搜索结果的标题,正文,url
//预期构造成的结果形如:
//[
// {
// "title":"这是主题",
// "desc":"这是描述",
// "url":"这是url",
// }
//]
Json::Value results;//表示所有搜索的结果
for (const auto& weight : all_token_result)
{
const auto* doc_info = index_->GetDocInfo(weight.doc_id);
if (doc_info == NULL)
{
continue;
}
//如何构造成 JSON结构呢?有现成的第三方库来实现 jsoncpp
Json::Value result; //表示一条搜索结果的 JSON对象
result["title"] = doc_info->title;
result["url"] = doc_info->url;
result["desc"] = GetDesc(doc_info->content, weight.key);
results.append(result);
}
//借助 jsoncpp 能够快速的完成 JSON对象
Json::FastWriter writer;
*json_result = writer.write(results);
return true;
}
std::string Searcher::GetDesc(const std::string& content,
const std::string& key)
{
//描述也是正文的一部分,描述最好要包含查询词,
//1.先在正文中查找一下这个词的位置
size_t pos = content.find(key);
if (pos == std::string::npos){
//该词在正文中不存在(合理的,有可能这个词只在标题中出现)
//此时直接从开头截取一段
if (content.size() < 160)
{
return content;
} else {
return content.substr(0, 160) + "....";
}
}
//2.以该位置为基准位置,往前截取一部分字符串,往后截取一部分字符串
//以该位置为基准,往前截取60个字节,往后截取100个字节
size_t beg = pos < 60 ? 0 : pos - 60;
if (beg + 160 >= content.size())
{
return content.substr(beg);
} else {
return content.substr(beg, 160) + "....";
}
}
} //end searcher
搜索步骤比较详细的在代码中体现了,就不多说了,服务器我是在github上clone的一个已经封装好的直接调用就可以了
服务器github:https://github.com/yhirose/cpp-httplib
源代码:https://github.com/mamengchen/Search_Engines-cpp