PCRE2 站内搜索引擎项目
项目码云直达
项目所用技术栈与开发环境
项目所用技术栈:
C/C++
STL。准标准库
Boost
库。Jsoncpp库。
cppjieba库。
cpp-httplib库。
多线程/多路复用。
html5,css,js、jQuery、Ajax。
ICU库。
项目开发环境:
CentOS-7
。VSCode
。
项目背景
在学习PCRE2 (Perl Compatible Regular Expressions 2)这个库时,去官网查阅文档,发现没有站内搜索功能:
CRE2 (Perl Compatible Regular Expressions 2) 是一个用于执行正则表达式匹配的库,它与Perl 5编程语言中的正则表达式兼容,并且在一些方面有所扩展。PCRE2是PCRE的后续版本,提供了改进的API和一些新特性。
正好它提供了下载源码的功能,源码中有文档对应的html
文件,我决定给它做一个站内搜索的功能。
doc
中是一个html
目录,然后这个目录中就是我们网站对应的各种html
文件:
我们想做一个类似百度搜索引擎的效果,最终返回给用户的是这样的形式:
项目整体架构
但我们的项目目前不打算使用爬虫程序去爬一些网站,而是直接下载文档的html
文件做一个站内搜索,所以我的数据源是提前下载好的,后续可以扩展,因为我的项目代码高度解耦。
正排索引与倒排索引介绍
正排索引
概念:通过文档
id
找到整个文档(title
、content
、url
)。举栗子:
ID Doc 0 我喜欢你 1 你喜欢我 2 他喜欢小狗
倒排索引
概念:通过对每个文档(文档标题和文档内容)进行分词(使用
jieba
库),然后将每个word
与文档id
建立映射,一个word
可能在多个文档中出现,所以word
映射的是数组。举栗子:
我喜欢你==我/喜欢/你- ~~你喜欢我==你/喜欢/我
他喜欢小狗==他/喜欢/小狗
Word ID 我 0,1 喜欢 0,1,2 你 0,1 他 2 小狗 2
为什么要有倒排索引和正排索引
- 正排索引:它是原本就存在的,
id
就是数组下标,我们需要从解析好的文件中读取出所有的Doc
,势必要读文件,将每个Doc
都存在数组中,它天然的就是正排索引,是文档id
到文档具体内容的映射。 - 倒排索引:用户在搜索框输入内容后,需要先对内容进行分词,它是词项到文档的映射,通过它我们可以快速找到该文档更详细的信息,存文档id就可以了(节省空间)。
实际上,正排索引和倒排索引经常结合使用:
- 通过倒排索引快速检索。
- 通过正排索引拿到文档的详细信息。
模拟一次用户查找的过程:
- 用户输入:你喜欢小狗吗?
- 服务器端拿到数据后分词:你/喜欢/小狗
- 先查倒排索引:提取出文档ID(0,1,2)。
- 将每个文档的内容进行摘要处理后,构建响应内容返回(序列化)。
Parse模块的编写
[!IMPORTANT]
Parse
模块的功能主要是将从网站上爬取/下载下来的html
文件进行数据的去标签。
什么是去标签
index.html
的部分代码是这样的:
<html>
<!-- This is a manually maintained file that is the root of the HTML version of
the PCRE2 documentation. When the HTML documents are built from the man
page versions, the entire doc/html directory is emptied, this file is then
copied into doc/html/index.html, and the remaining files therein are
created by the 132html script.
-->
<head>
<title>PCRE2 specification</title>
</head>
<body bgcolor="#FFFFFF" text="#00005A" link="#0066FF" alink="#3399FF" vlink="#2222BB">
<h1>Perl-compatible Regular Expressions (revised API: PCRE2)</h1>
<p>
The HTML documentation for PCRE2 consists of a number of pages that are listed
below in alphabetical order. If you are new to PCRE2, please read the first one
first.
</p>
<table>
<tr><td><a href="pcre2.html">pcre2</a></td>
<td> Introductory page</td></tr>
<tr><td><a href="pcre2-config.html">pcre2-config</a></td>
<td> Information about the installation configuration</td></tr>
<tr><td><a href="pcre2api.html">pcre2api</a></td>
<td> PCRE2's native API</td></tr>
<tr><td><a href="pcre2build.html">pcre2build</a></td>
<td> Building PCRE2</td></tr>
<tr><td><a href="pcre2callout.html">pcre2callout</a></td>
<td> The <i>callout</i> facility</td></tr>
<tr><td><a href="pcre2compat.html">pcre2compat</a></td>
<td> Compability with Perl</td></tr>
<tr><td><a href="pcre2convert.html">pcre2convert</a></td>
<td> Experimental foreign pattern conversion functions</td></tr>
- 在HTML中,
<
和>
符号用于定义标签的开始和结束。我们需要的是html
的内容,title
,因此需要进行去标签操作,将其手动解析出来。
代码实现
Parse.cc
:
#include <iostream>
#include <string>
#include <vector>
#include <boost/filesystem.hpp>
#include "util.hpp"
const std::string srcpath = "/root/doc/html/";
const std::string dstpath = "/root/doc/output/RawDoc.txt";
typedef struct DocInfo
{
std::string title;
std::string content;
std::string url;
} DocInfo_t;
bool GetAllHtmlPath(const std::string &root, std::vector<std::string> *out);
bool Parse(std::vector<std::string> &htmlpath, std::vector<DocInfo_t> *out);
bool SaveFile(const std::string &path, std::vector<DocInfo_t> &input);
int main()
{
// 0.首先提取递归出所有html文件的文件路径,存到数组中
std::vector<std::string> htmlpath;
if (!GetAllHtmlPath(srcpath, &htmlpath))
{
std::cerr << "Get all html Path err" << "\n";
}
// 1.根据每个html文件的文件路径,将其中的内容做解析
std::vector<DocInfo_t> NewDoc;
if (!Parse(htmlpath, &NewDoc))
{
std::cerr << "Parse all html err" << "\n";
}
// 2.将解析后的数据保存到文件中
if (!SaveFile(dstpath, NewDoc))
{
std::cerr << "Save to File err" << "\n";
}
return 0;
}
bool GetAllHtmlPath(const std::string &root, std::vector<std::string> *out)
{
// 0.定义根目录
namespace fs = boost::filesystem;
fs::path root_path(root);
// 1.判断目录是否存在
if (!fs::exists(root_path))
{
std::cerr << root << "no exist" << "\n";
return false;
}
// 2.定义空迭代器,用于判断递归结束,并开始递归遍历文件或目录
fs::recursive_directory_iterator end;
for (fs::recursive_directory_iterator begin(root_path); begin != end;begin++)
{
if (!fs::is_regular_file(*begin)) // 不是常规文件
continue;
if (fs::extension(*begin) != ".html") // 后缀不为html文件--比较string,而不是直接比较c_str(需要调用strcmp)
continue;
// 走到这里一定是我们想要的文件的路径
(*out).emplace_back(begin->path().string());
}
return true;
}
bool Extract_Title(const std::string &src_str, std::string *title)
{
size_t title_start = src_str.find("<title>");
if (title_start == std::string::npos)
return false;
size_t title_end = src_str.find("</title>");
if (title_end == std::string::npos)
return false;
title_start += std::string("<title>").size();
if (title_start >= title_end) //?
return false;
(*title) = src_str.substr(title_start, title_end - title_start);
return true;
}
bool Remove_Label(const std::string &src_str, std::string *content)
{
// 使用状态机来完成去标签
enum status
{
CONTENT,
LABEL
};
enum status s = LABEL;
// 首先遍历src_str
for (auto ch : src_str)
{
switch (s)
{
case LABEL:
if(ch == '>')
s = CONTENT;
break;
case CONTENT:
if (ch == '<')
s = LABEL;
else
{
if (ch == '\n')
(*content) += ' ';
else
(*content) += ch;
}
default:
break;
}
}
return true;
}
const std::string prefix_url = "https://www.pcre.org/current/doc/html/";
bool Spilt_Url(std::string &raw_url, std::string *actual_url)
{
size_t pos = raw_url.find(srcpath);
if (pos == std::string::npos)
return false;
(*actual_url) = prefix_url + raw_url.substr(srcpath.size());
return true;
}
void DebugForParse(const DocInfo_t& doc)
{
std::cout << "title: " << doc.title << "\n";
std::cout << "content: " << doc.content << "\n";
std::cout << "url: " << doc.url << "\n";
}
bool Parse(std::vector<std::string> &htmlpath, std::vector<DocInfo_t> *out)
{
for (int i = 0; i < htmlpath.size(); ++i)
{
// 0.首先读入当前html的所有文件内容
std::string src_content;
if (!Util_ns::File_Util::ReadFile(htmlpath[i], &src_content))
continue;
// 1.先将title提取出来
std::string _title;
if (!Extract_Title(src_content, &_title))
continue;
// 2.继续提取内容,去标签
std::string _content;
if (!Remove_Label(src_content, &_content))
continue;
// 3.拼接得到官网的url
std::string _url;
if (!Spilt_Url(htmlpath[i], &_url))
continue;
// 4.创建doc对象,并将其添加到输出参数中
DocInfo_t doc;
doc.title = std::move(_title);
doc.content = std::move(_content);
doc.url = std::move(_url);
(*out).emplace_back(std::move(doc));
//DebugForParse((*out)[0]);
//break;
}
return true;
}
#define SEP '\3'
bool SaveFile(const std::string &path, std::vector<DocInfo_t> &input)
{
//0.以二进制方式打开文件,不希望进行编码
std::ofstream ofs(dstpath,std::ios::binary);
if(!ofs.is_open())
{
std::cerr << "open file err,path is " << path << "\n";
return false;
}
//1.写入内容到文件中
for(auto& doc:input)
{
std::string out_str = doc.title;
out_str += SEP;
out_str += doc.content;
out_str += SEP;
out_str += doc.url;
out_str += '\n';
ofs.write(out_str.c_str(),out_str.size());
}
ofs.close();
return true;
}
GetAllHtmlPath方法介绍
[!NOTE]
该方法的功能是从根目录中递归解析出所有的特定文件的路径(
.html
后缀),因为有时候文档比较大,为了根据功能分类,会放在不同的目录中。
我们使用到了Boost
库的filesystem
组件来是实现对文件系统的操作,我们用到了它的一些如下方法:
- 路径处理(
filesystem::path
):用于表示和操作文件系统路径的类。它能处理不同操作系统上的路径格式差异,并提供多种方法来检查、修改路径。 - 检查文件或目录是否存在(
filesystem::exists
):此函数可以帮助我们确定某一个路径是否有效,返回值为bool
类型。 - 递归遍历根目录下的所有子目录和文件(
filesystem:recursive_directory_iterator
):该迭代器允许你递归的遍历某一个根目录下的所有子目录和文件,递归结束后,迭代器值为end
,一般filesystem:recursive_directory_iterator
类型定义一个变量不赋值就代表end
。 - 检查文件类型(
filesystem::is_regular_file
、filesystem::extension
):前者检查一个路径对应的文件是否为常规文件(不是目录/不是符号链接等),后者提取文件的扩展名,以便拿到特定的文件的路径。
使用示例:
我们在当前目录建立了一个叫testfilesystem
的目录,里面提前创建好了一些文件用于测试:
#include<iostream>
#include<boost/filesystem.hpp>
#include<vector>
const std::string root_path = "./testfilesystem";
int main()
{
namespace fs = boost::filesystem;
fs::path root(root_path);
if(!fs::exists(root))
std::cerr << "root is not exists..." << "\n";
std::vector<std::string> res;
fs::recursive_directory_iterator end;
for(fs::recursive_directory_iterator begin(root);begin != end;++begin)
{
if(!fs::is_regular(*begin))
continue;
if(fs::extension(*begin) != ".c")//后缀必须为.c
continue;
res.emplace_back(begin->path().string());
}
for(auto& s:res)
std::cout << s << "\n";
return 0;
}
运行结果:
Parse方法介绍
bool Parse(std::vector<std::string> &htmlpath, std::vector<DocInfo_t> *out)
{
for (int i = 0; i < htmlpath.size(); ++i)
{
// 0.首先读入当前html的所有文件内容
std::string src_content;
if (!Util_ns::File_Util::ReadFile(htmlpath[i], &src_content))
continue;
// 1.先将title提取出来
std::string _title;
if (!Extract_Title(src_content, &_title))
continue;
// 2.继续提取内容,去标签
std::string _content;
if (!Remove_Label(src_content, &_content))
continue;
// 3.拼接得到官网的url
std::string _url;
if (!Spilt_Url(htmlpath[i], &_url))
continue;
// 4.创建doc对象,并将其添加到输出参数中
DocInfo_t doc;
doc.title = std::move(_title);
doc.content = std::move(_content);
doc.url = std::move(_url);
(*out).emplace_back(std::move(doc));
//DebugForParse((*out)[0]);
//break;
}
return true;
}
[!IMPORTANT]
Parse
方法是我们这个模块最核心的方法,它内部又调用了三个函数:
Extract_Title
方法:从html
文件中提取出该页面的title
。Remove_Label
方法:去除html
的标签。Spilt_Url
方法:根据当前html
文件的路径名的一部分+官网的路径的一部分拼接出当前html
文件对应的官网url
。
对于
Extact_Title
的实现,我们只需使用find
函数查找<title>
和</title>
就可以拿到该html
文件的标题,因为每一个html
这两个标签只有这唯一的一个。对于
Remove_Label
的操作,我们使用到了状态机来完成去标签操作,思路如下:定义每一次遍历时的状态,
CONTENT
和LABEL
状态。初始时处于
LABEL
状态。遍历每个
html
文件中的每个字符,内部执行switch
分支结构,判断的条件是当前的状态。LABEL
:如果当前字符是>
,就将状态更改为CONTENT
状态,表示可以读内容了,否则直接break
。CONTENT
:如果当前字符又是<
,将状态再次改为LABEL
。连续的<><>
在这里就会再次回到LABEL
状态。- 如果当前字符是
'\n'
,我们将结果字符加入' '
,因为\n
我们需要作为不同文档间的区分字符。 - 其它情况就是普通字符,直接增加
content
结果字符串中。
- 如果当前字符是
对每一个html
文件清洗之后,都需要将其title
、content
、url
等内容写进DocInfo_t
对象中:
typedef struct DocInfo
{
std::string title;
std::string content;
std::string url;
} DocInfo_t;
[!NOTE]
我们下载下来的
html
文件的url
样例:/root/pcre2-10.43/doc/html/xxx.html
我们拷贝到项目中的
html
文件的url
样例:/root/doc/html/xxx.html
官网中的
html
的url
样例:https://www.pcre.org/current/doc/html/index.html
所以我们只需要将官网的https://www.pcre.org/current/doc/html/
作为前缀,项目中html
文件的url
取出后缀xxx.html
(doc/html
之后的内容),然后拼接在一起就得到了某个html
文件在官网中的url
.
SaveFile方法介绍
[!NOTE]
该方法是将去标签后的结果保存进文件中。
- 对于每个文档,它们的
title
、content
、url
字段之间,我们使用\3
这个特殊的转义字符作为分隔符。 - 每个文档之间,我们使用
\n
来作为分隔符。
这样Search
模块在拿数据时就很方便就能拿到数据了,因为双方提前制定好了协议,目的是为了快速取到数据(通过getline
方法直接拿到一个文档的内容,然后文档内通过\3
来拿到每个小的部分)。
#define SEP '\3'
bool SaveFile(const std::string &path, std::vector<DocInfo_t> &input)
{
//0.以二进制方式打开文件,不希望进行编码
std::ofstream ofs(dstpath,std::ios::binary);
if(!ofs.is_open())
{
std::cerr << "open file err,path is " << path << "\n";
return false;
}
//1.写入内容到文件中
for(auto& doc:input)
{
std::string out_str = doc.title;
out_str += SEP;
out_str += doc.content;
out_str += SEP;
out_str += doc.url;
out_str += '\n';
ofs.write(out_str.c_str(),out_str.size());
}
ofs.close();
return true;
}
Search模块的编写
主程序部分
#include"Search.hpp"
#include"cpp-httplib/httplib.h"
#include<memory>
#include"log.hpp"
static const std::string input = "/root/doc/output/RawDoc.txt";
int main()
{
httplib::Server server;
std::unique_ptr<Search_ns::Searcher> _searcher =std::make_unique<Search_ns::Searcher>();
_searcher->InitIndex(input);
ENABLESCREEN();
server.Get("/s",[&_searcher](const httplib::Request& req,httplib::Response& resp){
std::string param = req.get_param_value("word");
if(param.empty())
{
LOG(ERROR,"不存在为word的参数");
return;
}
//走到这里一定拿到了key值为word的参数
std::string json_out;
LOG(INFO,"用户搜索: %s",param.c_str());
_searcher->Search(param,&json_out);
if(!json_out.empty())
{
resp.set_content(json_out,"application/json; charset=utf-8");
}
});
server.set_base_dir("./wwwroot");
LOG(INFO, "服务器启动成功...");
server.listen("0.0.0.0",8080);
}
[!CAUTION]
Search
模块又分为两个小部分:Index
模块 、Search
模块。
Index模块的介绍与实现
Index.hpp实现
Index
模块的功能:根据Parse
去标签化的数据建立正排索引与倒排索引,并为外部提供正排索引与倒排索引的方法。Index
对象在整个后端服务中只会出现一份,所有我们将其设计为单例模式(使用的是懒汉模式,即资源第一次使用时才申请空间创建它)。
#pragma once
#include<iostream>
#include<vector>
#include<mutex>
#include<unordered_map>
#include<fstream>
#include"util.hpp"
#include"log.hpp"
namespace Index_ns
{
struct DocInfo{
std::string title;
std::string content;
std::string url;
uint64_t id;
};
struct InvertInfo{
uint64_t id;
std::string word;
int weight;
InvertInfo():weight(0){}
};
typedef std::vector<InvertInfo> InvertList;
static const std::string seq = "\3";
class Index
{
private:
static std::mutex _mtx;
static Index* _instance;
std::vector<DocInfo> _Forward_Index;
std::unordered_map<std::string,InvertList> _InvertListMap;//word与InvertList的映射
private:
Index(){};
Index(const Index&) = delete;
Index operator=(const Index&)=delete;
public:
static Index* GetInstance()
{
if(nullptr == _instance)
{
_mtx.lock();
if(nullptr == _instance)
{
_instance = new Index;
}
_mtx.unlock();
}
return _instance;
}
DocInfo* ForwardIndex(uint64_t id)//提供正排索引方法
{
if(id > _Forward_Index.size())
{
LOG(ERROR,"正排搜索失败,不存在id = %d 的文档",id);
return nullptr;
}
return &_Forward_Index[id];
}
InvertList* InvertIndex(const std::string& word)//倒排索引方法
{
auto iter = _InvertListMap.find(word);
if(iter == _InvertListMap.end())
{
LOG(ERROR,"倒排搜索失败,不存在word =%s的关键词",word.c_str());
return nullptr;
}
return &_InvertListMap[word];
}
bool BulidIndex(const std::string contentpath)//构建正排索引与倒排索引
{
//1.首先读取内容将内容按照行分割出来存入DocInfo中,并构建正排与倒排索引
std::ifstream ifs(contentpath,std::ios::in | std::ios::binary);
std::string line;
int cnt = 0;
while(std::getline(ifs,line))
{
DocInfo* doc = BulidForwardIndex(line);
if(doc == nullptr)
continue;
if(!BuildInvertIndex(doc))
continue;
cnt++;
if(cnt % 50 == 0)
{
LOG(INFO,"构建索引的个数为:%d",cnt);
}
}
}
~Index();
private:
DocInfo* BulidForwardIndex(const std::string& line)
{
//1.分割line
std::vector<std::string> res;
Util_ns::String_Util::Spilt(line,&res,seq);
if(3 != res.size())
return nullptr;
//2.填充Docinfo
DocInfo doc;
doc.title = res[0];
doc.content = res[1];
doc.url = res[2];
doc.id = _Forward_Index.size();
//3.将doc插入到正排索引数组中
_Forward_Index.emplace_back(std::move(doc));
//返回当前构建的Doc
return &_Forward_Index.back();
}
bool BuildInvertIndex(DocInfo* doc)
{
//1.提取doc title和content的关键词
std::vector<std::string> title_key;
Util_ns::Jieba_Util::CutString(doc->title,&title_key);
struct word_cnt{
int cnt_title;
int cnt_content;
word_cnt():cnt_content(0),cnt_title(0){}
};
//1.1统计每个词的词频
std::unordered_map<std::string,word_cnt> word_map_cnt;
for(auto s:title_key)
{
boost::to_lower(s);
word_map_cnt[s].cnt_title++;
}
//2.提取出doc content的关键词
std::vector<std::string> content_key;
Util_ns::Jieba_Util::CutString(doc->content,&content_key);
//2.1统计每个词的词频
for(auto s:content_key)
{
boost::to_lower(s);
word_map_cnt[s].cnt_content++;
}
#define TW 10
#define CW 1
//3.遍历哈希表建立索引
for(auto& word_pair:word_map_cnt)
{
InvertInfo _invertelem;
_invertelem.id = doc->id;
_invertelem.weight = word_pair.second.cnt_title * TW + word_pair.second.cnt_content * CW;
_invertelem.word = word_pair.first;
InvertList& _InvertList = _InvertListMap[word_pair.first];
_InvertList.emplace_back(std::move(_invertelem));
}
return true;
}
};
std::mutex Index::_mtx;
Index* Index::_instance = nullptr;
}
使用到的数据结构
DocInfo
:描述每个文档,和Parse
中的属性保持一致。struct DocInfo{ std::string title; std::string content; std::string url; uint64_t id; };
[!CAUTION]
给每个
DocInfo
变量都添加id
属性,这样在对其建立倒排索引时,就可以O(1)
的时间复杂度知道其对应的文档id
(数组下标),否则就需要遍历查找(O(N)
)。InvertInfo
:描述每一个分词word
对应的文档相关的信息(文档id
、weight
):struct InvertInfo{ uint64_t id; std::string word; int weight; InvertInfo():weight(0){} };
class Index
:索引对象,用来构建索引以及对外提供正排、倒排索引。它里面有两个重要的数据结构:std::vector<DocInfo> _Forward_Index
:std::unordered_map<std::string,InvertList> _InvertListMap
:
建立正排索引
[!important]
从
Parse
模块输出的文件中读取每一行的内容,一行就代表这个文档,我们需要提取出每个文档的title
、content
、url
。建立倒排索引和正排索引是对每个文档都需要建立的。
DocInfo* BulidForwardIndex(const std::string& line)
{
//1.分割line
std::vector<std::string> res;
Util_ns::String_Util::Spilt(line,&res,seq);
if(3 != res.size())
return nullptr;
//2.填充Docinfo
DocInfo doc;
doc.title = res[0];
doc.content = res[1];
doc.url = res[2];
doc.id = _Forward_Index.size();
//3.将doc插入到正排索引数组中
_Forward_Index.emplace_back(std::move(doc));
//返回当前构建的Doc
return &_Forward_Index.back();
}
使用到了
Boost
库中的字符串切分函数spilt
,这个函数可以帮我们切分字符串,需要用户指定谓词(切分的分隔符)、源字符串、以及切分后放置的容器:
使用cppjieba库进行分词
cppjieba是jieba分词的一个C++移植版,它提供了与原Python版本相似的功能,包括精确模式、全模式和搜索引擎模式等。
- GitHub上可以找到cppjieba的开源代码,方便集成到C++项目中。
使用示例:
#include<string>
#include<iostream>
#include<vector>
#include"cppjieba/Jieba.hpp"
using namespace std;
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";
int main(int argc, char** argv) {
cppjieba::Jieba jieba(DICT_PATH,
HMM_PATH,
USER_DICT_PATH,
IDF_PATH,
STOP_WORD_PATH);
vector<string> words;
string s;
s = "小明硕士毕业于中国科学院计算所,后在日本京都大学深造";
cout << s << endl;
cout << "[demo] CutForSearch" << endl;
jieba.CutForSearch(s, words);
cout << limonp::Join(words.begin(), words.end(), "/") << endl;
return 0;
}
运行结果:
建立倒排索引
[!note]
在建立正排索引后,我们拿到当前文档的
DocInfo
对象,可以接着去建立该文档的倒排索引。
建立倒排索引的过程:
- 先对该文档的
title
部分进行分词(使用jieba
库)。 - 利用哈希表,统计每个词在
title
中出现的次数。(我们创建了一个结构体word_cnt
,来分别统计每个词在标题上出现的次数和正文中出现的次数,便于后面计算权重)。 - 再对该文档的
content
进行分词。 - 同一个哈希表,统计每个词在
content
中出现的次数。 - 遍历整个哈希表建立该文档的倒排索引,建立
word
(单词)-vector<InvertInfo>
的映射关系。
[!caution]
对于用户的每次搜索,我们希望是忽略大小写的,所以建立倒排索引时,将所有的单词都先转化为了小写。
bool BuildInvertIndex(DocInfo* doc)
{
//1.提取doc title和content的关键词
std::vector<std::string> title_key;
Util_ns::Jieba_Util::CutString(doc->title,&title_key);
struct word_cnt{
int cnt_title;
int cnt_content;
word_cnt():cnt_content(0),cnt_title(0){}
};
//1.1统计每个词的词频
std::unordered_map<std::string,word_cnt> word_map_cnt;
for(auto s:title_key)
{
boost::to_lower(s);
word_map_cnt[s].cnt_title++;
}
//2.提取出doc content的关键词
std::vector<std::string> content_key;
Util_ns::Jieba_Util::CutString(doc->content,&content_key);
//2.1统计每个词的词频
for(auto s:content_key)
{
boost::to_lower(s);
word_map_cnt[s].cnt_content++;
}
#define TW 10
#define CW 1
//3.遍历哈希表建立索引
for(auto& word_pair:word_map_cnt)
{
InvertInfo _invertelem;
_invertelem.id = doc->id;
_invertelem.weight = word_pair.second.cnt_title * TW + word_pair.second.cnt_content * CW;
_invertelem.word = word_pair.first;
InvertList& _InvertList = _InvertListMap[word_pair.first];//[]->存在就获取,不存在就创建
_InvertList.emplace_back(std::move(_invertelem));
}
return true;
}
[!tip]
std::move
使用可以减少深拷贝,将当前的对象转化为将亡值,从而调用移动构造,将资源从一个对象转移到另一个对象,而不需要复制底层的数据(相当于变更了资源的管理权)。提高拷贝的效率,但注意不能滥用,一定要确保当前对象后续不会再使用。
Search模块的介绍与实现
Search.hpp的实现
#pragma once
#include "Index.hpp"
#include <iostream>
#include <vector>
#include <string>
#include "util.hpp"
#include <unordered_map>
#include <jsoncpp/json/json.h>
#include <algorithm>
#include <ctype.h>
#include <unicode/brkiter.h>
#include <unicode/unistr.h>
#include<memory>
namespace Search_ns
{
struct InvertedElems
{
uint64_t doc_id;
int weight;
std::vector<std::string> words;
InvertedElems() : doc_id(0), weight(0) {}
};
class Searcher
{
private:
Index_ns::Index *_index;
public:
Searcher() {};
~Searcher() {};
public:
void InitIndex(const std::string &input_root_path)
{
// 0.获取索引单例
_index = Index_ns::Index::GetInstance();
if (!_index)
LOG(FATAL, "获取单例失败...");
LOG(INFO, "获取单例成功");
// 1.构建索引
LOG(INFO, "开始构建索引...");
_index->BulidIndex(input_root_path);
LOG(INFO, "构建索引成功...");
}
void Search(const std::string &query, std::string *json_result)
{
// 0.将用户输入的字符串进行分词
std::vector<std::string> query_words;
Util_ns::Jieba_Util::CutString(query, &query_words);
// 1.分词之后,根据关键字开始查倒排
std::unordered_map<uint64_t, InvertedElems> _Inverted_results_mp;
std::vector<InvertedElems> _Inverted_results_arr;
// 1.1将查到的倒排结果放入哈希表中,起到一个去重的作用,我们不希望拿到多个相同的文档
for (auto &query_word : query_words)
{
boost::to_lower(query_word);
Index_ns::InvertList *_inverlist = _index->InvertIndex(query_word);
if (_inverlist == nullptr) // 没查到这个关键字
continue;
for (auto &elem : (*_inverlist)) // 遍历刚刚获取到的倒排拉链中的元素
{
InvertedElems &_elem = _Inverted_results_mp[elem.id]; // 不存在就创建存在就获取
_elem.weight += elem.weight;
_elem.doc_id = elem.id;
_elem.words.emplace_back(elem.word);
}
}
// 1.2将哈希表中的元素copy到数组中,并按照权值排序
for (auto &pair : _Inverted_results_mp)
{
_Inverted_results_arr.emplace_back(std::move(pair.second));
}
// 1.2.1将数组中的倒排元素按照weight降序排序
sort(_Inverted_results_arr.begin(), _Inverted_results_arr.end(), [](const InvertedElems &a, const InvertedElems &b) -> bool
{ return a.weight > b.weight; });
// 2.根据倒排拿出来的文档id,进行正排索引拿到文档,然后构建json串给用户返回
Json::Value root;
std::string word_s;
for (auto &s : query_words)
{
word_s += s;
word_s += " ";
}
word_s[word_s.size() - 1] = 0;
for (auto &elem : _Inverted_results_arr)
{
Index_ns::DocInfo *doc = _index->ForwardIndex(elem.doc_id);
if (doc == nullptr) // 没有查到该id对应的文档
continue;
Json::Value sub;
sub["title"] = doc->title;
sub["abstract"] = GetAbstractImproved(doc->content, elem.words[0]); //?
sub["url"] = doc->url;
// for debug will delete
sub["weight"] = elem.weight;
sub["id"] = (int)elem.doc_id;
root.append(sub);
}
Json::StyledWriter writer;
if (!root.isNull())
(*json_result) = writer.write(root);
}
private:
std::string GetAbstractImproved(std::string &content, const std::string &key)
{
// 0. 初始化错误状态码
UErrorCode status = U_ZERO_ERROR;
// 1. 初始化ICU环境
icu::Locale locale("en_US");
// 2. 拿到句子边界分析器
std::unique_ptr<icu::BreakIterator> bi(icu::BreakIterator::createSentenceInstance(locale, status));
if (U_FAILURE(status))
{
return "Error creating BreakIterator";
}
std::unique_ptr<icu::BreakIterator> bw(icu::BreakIterator::createWordInstance(locale, status));
icu::UnicodeString uContent = icu::UnicodeString::fromUTF8(content);
icu::UnicodeString uContent_lower(uContent);
uContent_lower.toLower(locale);
bi->setText(uContent);
bw->setText(uContent);
// 3. 找到key第一次出现的位置
int32_t pos = uContent_lower.indexOf(icu::UnicodeString::fromUTF8(key));
if (pos == std::string::npos)
{ // 理论上不可能出现,没有查找到关键词在文档中的位置
return "None1";
}
// 4. 向前查找最近的边界
int32_t start = bi->preceding(pos + 1); // 直接使用pos作为参数
if (start == icu::BreakIterator::DONE)
{
start = 0;
}
// 5. 向后查找下一个边界
int32_t end = bi->next(2); // 获取下一个边界
if (end == icu::BreakIterator::DONE)
{
end = uContent.length();
}
end = bw->following(end);
if (end != icu::BreakIterator::DONE)
{
end = bw->next();
}
if(end <= start)
return "None2";
// 提取摘要
icu::UnicodeString abstract = uContent.tempSubStringBetween(start, end);
std::string result;
abstract.toUTF8String(result);
result += "...";
return result;
}
};
}
程序流程图:
使用ICU库得到内容的摘要
ICU(International Components for Unicode)是一个成熟的、广泛使用的开源库,旨在提供强大的国际化支持,帮助开发者创建能够处理多语言文本和适应不同地区习惯的应用程序。ICU 库由 IBM 最初开发,并且现在由一个全球社区维护,它提供了跨平台的 API 和工具,用于实现全球化应用。
我们使用ICU库进行文本边界的查询,以获得一个摘要(获得关键词所在句子+紧接着关键词后的两个句子)。
代码示例:
#include <unicode/brkiter.h>
#include <unicode/unistr.h>
#include<iostream>
#include<memory>
std::string GetAbstractImproved(const std::string& content, const std::string& key) {
// 0. 初始化错误状态码
UErrorCode status = U_ZERO_ERROR;
// 1. 初始化ICU环境
icu::Locale locale("en_US");
// 2. 拿到句子边界分析器
std::unique_ptr<icu::BreakIterator> bi(icu::BreakIterator::createSentenceInstance(locale, status));
if (U_FAILURE(status)) {
return "Error creating BreakIterator";
}
std::unique_ptr<icu::BreakIterator> bw(icu::BreakIterator::createWordInstance(locale,status));
icu::UnicodeString uContent = icu::UnicodeString::fromUTF8(content);
bi->setText(uContent);
bw->setText(uContent);
// 3. 找到key第一次出现的位置
int32_t pos = uContent.indexOf(icu::UnicodeString::fromUTF8(key));
if (pos == std::string::npos) { // 理论上不可能出现,没有查找到关键词在文档中的位置
return "None1";
}
std::cout << "Keyword found at position: " << pos << std::endl;
// 4. 向前查找最近的边界
int32_t start = bi->preceding(pos+1); // 直接使用pos作为参数
if (start == icu::BreakIterator::DONE) {
start = 0;
}
std::cout << "Start boundary at: " << start << "\n";
// 5. 向后查找下一个边界
int32_t end = bi->next(2); // 获取下一个边界
if (end == icu::BreakIterator::DONE) {
end = uContent.length();
}
std::cout << "End boundary at: " << end << "\n";
end = bw->following(end);
if(end != icu::BreakIterator::DONE)
{
end = bw->next();
}
std::cout << "End boundary at: " << end << "\n";
// 提取摘要
icu::UnicodeString abstract = uContent.tempSubStringBetween(start, end);
std::string result;
abstract.toUTF8String(result);
result += "...";
return result;
}
int main()
{
std::string s = R"(The sun sets beautifully over the horizon.The sun sets beautifully over the horizon. Yes,od,id.好的,用户之前询问了如何在C++中使用ICU库来改进生成摘要的方法,现在他们想知道在CentOS 7上如何安装ICU库。我需要详细说明安装步骤,同时考虑到CentOS 7的软件包管理工具主要是yum,但可能版本较旧,所以可能需要源码编译。首先,我应该检查用户是否有root权限,因为安装系统包通常需要sudo。常见的安装方法有两种:使用yum安装预编译的包,或者从源码编译安装以获得最新版本。CentOS 7的默认仓库可能提供ICU,但版本可能较低,所以需要确认版本是否满足需求。如果用户需要较新版本,源码编译是更好的选择。其次,步骤需要清晰,分点列出。使用yum安装的话,可以列出相关包名,比如libicu和开发包libicu-devel。然后说明如何验证安装,比如检查头文件和库文件的位置。对于源码安装,需要指导用户下载最新源码,解压,然后执行标准的configure、make、make install流程。需要注意依赖项的安装,比如可能需要的开发工具链(gcc、make等),以及可能的配置选项,比如安装路径。此外,要提醒用户设置环境变量,如LD_LIBRARY_PATH,或者更新动态库缓存)";
std::cout << GetAbstractImproved(s,"Yes") << std::endl;
}
运行结果:
Util.hpp
#pragma once
#include<string>
#include<fstream>
#include<vector>
#include<boost/algorithm/string.hpp>
#include"cppjieba/Jieba.hpp"
#include<vector>
#include<mutex>
#include<unordered_map>
#include"log.hpp"
namespace Util_ns
{
class File_Util
{
public:
static bool ReadFile(const std::string& path,std::string* out)
{
std::ifstream ifs(path);
if(!ifs.is_open())
return false;
std::string line;
while(std::getline(ifs,line))
{
(*out) += line;
}
ifs.close();
return true;
}
};
class String_Util
{
public:
static void Spilt(const std::string& s,std::vector<std::string>* res,const std::string seq)
{
boost::split(*res,s,boost::is_any_of(seq),boost::token_compress_on);
}
};
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 Jieba_Util
{
private:
cppjieba::Jieba _jieba;
std::unordered_set<std::string> _stopmp;
Jieba_Util():_jieba(DICT_PATH,
HMM_PATH,
USER_DICT_PATH,
IDF_PATH,
STOP_WORD_PATH)
{
}
Jieba_Util(const Jieba_Util&)=delete;
Jieba_Util& operator=(const Jieba_Util&)=delete;
static Jieba_Util* _instance;
public:
static Jieba_Util* GetInstance()
{
std::mutex _mutex;
if(_instance == nullptr)
{
_mutex.lock();
if(_instance == nullptr)
{
_instance = new Jieba_Util;
_instance->LoadStopWord();
}
_mutex.unlock();
}
return _instance;
}
void LoadStopWord()
{
std::ifstream ifs(STOP_WORD_PATH,std::ios::in);
if(!ifs.is_open())
{
LOG(ERROR,"Stop word Load err");
return;
; }
std::string line;
while(std::getline(ifs,line))
{
if(!line.empty())
{
_stopmp.insert(line);
}
}
}
void CutStringHelper(const std::string& str,std::vector<std::string>* res)
{
_jieba.CutForSearch(str,*res);
for(auto iter = res->begin();iter != res->end();)
{
if(_stopmp.find(*iter) != _stopmp.end())
{
res->erase(iter);
}
else
{
iter++;
}
}
}
public:
static void CutString(const std::string& str,std::vector<std::string>* res)
{
GetInstance()->CutStringHelper(str,res);
}
};
Jieba_Util* Jieba_Util::_instance = nullptr;
}
- 使用
Jieba
对象进行分词时,我们将分词中的暂停词做了去除,Jieba
源码dict
下stop_words.utf8
文件提供了常见的暂停词,我们将其从文件中读到哈希集合中,分词后枚举分词后的vector<std::string>
对象,将暂停词erase
即可。
[!caution]
注意
vector
容器erase
的迭代器失效问题(删除元素后,后面的元素覆盖了原先迭代器的位置)。
- 另外,
Jieba
对象只有我们内部使用,不暴露给外部,给外部暴露一个静态的CutString
接口即可。
日志相关的模块
log.hpp
:
#pragma once // 防止头文件被多次包含,确保只被编译一次
#include <string> // 引入 C++ 标准字符串库
#include <stdarg.h> // 引入 C 标准库,用于处理可变参数
#include <iostream> // 引入 C++ 输入输出流库
#include <fstream> // 引入文件输入输出流库
#include <time.h> // 引入时间相关的函数
#include <stdio.h> // 引入 C 标准输入输出函数
#include "LockGuard.hpp" // 引入自定义的锁保护类(假设这是一个用于线程同步的工具)
#include<unistd.h>
using namespace std; // 使用标准命名空间
// 全局变量,用于控制是否保存日志到文件
bool gIsSave = false;
const std::string logname = "log.txt"; // 定义日志文件的名称
// 日志级别的枚举,定义了五个不同的日志级别
enum Level
{
DEBUG = 0, // 调试级别
INFO, // 信息级别
WARNING, // 警告级别
ERROR, // 错误级别
FATAL // 致命错误级别
};
// 保存日志消息到文件的函数
void SaveLogToFile(const string& filename, const string& message)
{
ofstream _file(filename, ios::app); // 以追加模式打开文件
if (!_file.is_open()) // 如果文件打开失败,直接返回
return;
_file << message; // 将消息写入文件,并换行
}
// 将日志级别转换为对应的字符串
string LevelToString(int level)
{
switch (level)
{
case DEBUG:
return "Debug"; // 返回调试信息
case INFO:
return "Info"; // 返回普通信息
case WARNING:
return "Warning"; // 返回警告信息
case ERROR:
return "Error"; // 返回错误信息
case FATAL:
return "Fatal"; // 返回致命错误信息
default:
return "No-Known"; // 返回未知信息
}
}
// 获取当前系统时间并返回格式化的时间字符串
string GetTimeString()
{
struct tm* formattime;
time_t curtimes = time(nullptr); // 获取当前时间戳
formattime = localtime(&curtimes); // 将时间戳转换为本地时间
if (formattime == nullptr) // 如果时间获取失败,返回空字符串
return nullptr;
string res;
// 格式化当前时间为 "年-月-日 时:分:秒" 的格式
res = to_string(formattime->tm_year + 1900) + "-" + to_string(formattime->tm_mon + 1) + "-" + to_string(formattime->tm_mday) +
" " + to_string(formattime->tm_hour) + ":" + to_string(formattime->tm_min) + ":" + to_string(formattime->tm_sec);
return res;
}
// 定义互斥锁用于线程同步,确保日志操作时不会发生竞态条件
pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;
// 日志消息记录函数
void LogMessage(string filename, int line, int IsSave, int level, const char* format, ...)
{
string Level = LevelToString(level); // 获取日志级别的字符串表示
pid_t self_id = getpid();
string time_str = GetTimeString();
va_list va;
va_start(va, format); // 获取可变参数列表
char buffer[1024];
vsnprintf(buffer, sizeof(buffer), format, va); // 格式化日志消息
va_end(va); // 结束可变参数的处理
string messages = "["+time_str+"]"+
"["+Level+"]" +
"["+to_string(self_id)+"]"+
"["+filename+"]"+"["+to_string(line)+"]"+buffer+"\n";
LockGurad gurad(_mutex); // 使用锁保护日志输出,避免多线程冲突
if (IsSave) // 如果配置为保存到文件
{
SaveLogToFile(logname, messages); // 将日志保存到文件
}
else // 如果配置为输出到控制台
{
cout << messages; // 输出日志到控制台
}
}
// 宏定义:用于在代码中简单地记录日志,带有日志级别和格式化参数
#define LOG(level, format, ...) \
do \
{ \
LogMessage(__FILE__, __LINE__, gIsSave, level, format,##__VA_ARGS__); \
} while (0)
// 宏定义:启用将日志保存到文件
#define ENABLEFILE() \
do \
{ \
gIsSave = true; \
} while (0)
// 宏定义:启用将日志输出到控制台
#define ENABLESCREEN() \
do \
{ \
gIsSave = false; \
} while (0)
LockGuard.hpp
:RAII风格,有效防止死锁问题。
#ifndef _LOCKGUARD_HPP_
#define _LOCKGUARD_HPP_
#include<pthread.h>
class LockGurad
{
public:
LockGurad(pthread_mutex_t& mutex):
mutex_(mutex)
{
pthread_mutex_lock(&mutex_);//加锁
}
~LockGurad()
{
pthread_mutex_unlock(&mutex_);//解锁
}
private:
pthread_mutex_t& mutex_;
};
#endif
前端模块
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="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<title>boost 搜索引擎</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: Arial, sans-serif;
}
.container {
width: 800px;
margin: 20px auto;
}
.search {
display: flex;
gap: 10px;
margin-bottom: 30px;
}
.search input {
flex: 1;
height: 50px;
padding: 0 15px;
border: 2px solid #4e6ef2;
border-radius: 4px;
font-size: 16px;
outline: none;
transition: border-color 0.3s;
}
.search input:focus {
border-color: #254eda;
}
.search button {
width: 120px;
height: 50px;
background: #4e6ef2;
border: none;
border-radius: 4px;
color: white;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.search button:hover {
background: #254eda;
}
.result {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.item {
margin-bottom: 25px;
padding: 15px;
background: white;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.item a {
display: block;
font-size: 18px;
color: #1a0dab;
text-decoration: none;
margin-bottom: 8px;
}
.item a:hover {
text-decoration: underline;
}
.item p {
color: #545454;
line-height: 1.5;
margin-bottom: 8px;
}
.item i {
display: block;
color: #006621;
font-style: normal;
font-size: 14px;
}
.no-result {
text-align: center;
color: #70757a;
padding: 40px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="search">
<input type="text" placeholder="输入搜索关键词" id="searchInput">
<button onclick="handleSearch()">搜索</button>
</div>
<div class="result" id="resultArea"></div>
</div>
<script>
// 初始化绑定事件
$(document).ready(() => {
// 回车键支持
$('#searchInput').on('keypress', (e) => {
if (e.which === 13) handleSearch()
})
// 自动聚焦输入框
$('#searchInput').focus()
})
async function handleSearch() {
const query = $('#searchInput').val().trim()
if (!query) return
try {
const response = await $.ajax({
url: '/s',
method: 'GET',
data: { word: query },
dataType: 'json',
timeout: 5000
})
renderResults(response, query)
} catch (error) {
showMessage(error.status === 200 ?
`找不到 "${query}" 的相关结果` : `搜索失败,请检查网络连接`)
}
}
function renderResults(data, query) {
const $result = $('#resultArea')
$result.empty()
if (!data || data.length === 0) {
return showMessage(`找不到 "${query}" 的相关结果`)
}
data.forEach(item => {
const html = `
<div class="item">
<a href="${item.url}" target="_blank">${item.title}</a>
<p>${item.abstract}</p>
<i>${item.url}</i>
</div>
`
$result.append(html)
})
}
function showMessage(msg) {
$('#resultArea').html(`<div class="no-result">${msg}</div>`)
}
</script>
</body>
</html>
整个项目效果展示
服务器启动(由于要去除暂停词需要,遍历整个文档的分词,所以启动较慢):
浏览器界面:
网络未连接时:
当关键字站内不存在时:
关键词存在时的结果:
服务器端在用户搜索时打印的日志:
[!tip]
DEBUG
类型的日志是用于调试的,后续可以注释掉。
项目亮点
- 高效的技术栈组合
- 使用C++高性能库(如Boost、STL)处理文本解析和索引构建,结合多线程/多路复用技术提升并发能力,适合处理大规模数据。
- 前端轻量简洁,通过Ajax实现异步交互,用户界面友好,响应速度快。
- 模块化与解耦设计
- Parse、Index、Search模块职责清晰,高度解耦。例如,Parse模块可独立扩展支持其他数据源(如PDF、Markdown),Search模块可替换不同分词算法。
- 索引机制优化
- 正排索引与倒排索引结合,快速定位文档并提取详细信息。倒排索引中引入权重计算(标题权重
TW=10
,正文CW=1
),提升搜索相关性。
- 正排索引与倒排索引结合,快速定位文档并提取详细信息。倒排索引中引入权重计算(标题权重
- 国际化支持
- 集成ICU库处理多语言文本边界(如句子分割),生成摘要时更精准,支持多语言环境。
- 日志与调试支持
- 自定义日志模块记录运行状态,支持文件和控制台输出,结合RAII锁保护多线程安全,便于问题排查。
- 单例模式
- 单例模式管理索引数据,避免重复构建;使用
std::move
减少拷贝开销;代码中通过DEBUG
宏和日志分级辅助调试。
- 单例模式管理索引数据,避免重复构建;使用
可扩展方向
- 分布式架构
- 将索引分片存储于多节点,结合一致性哈希或分布式框架(如gRPC),提升吞吐量和容错性。
- 动态数据更新
- 集成爬虫模块定期抓取PCRE官网最新文档,实现增量索引更新(如双缓冲机制),支持实时搜索。
- 搜索算法优化
- 引入TF-IDF、BM25等相关性算法,或集成机器学习模型(如BERT)优化排序结果。
- 多语言分词扩展
- 扩展
cppjieba
配置,支持英文、日文等分词,结合ICU库实现更全面的国际化搜索。
- 扩展
- 用户体验增强
- 前端增加搜索建议(自动补全)、关键词高亮、分页展示、结果过滤(按时间/权重)等功能。
- 缓存与性能优化
- 使用LRU缓存热门查询结果,减少重复计算;通过内存池优化索引数据结构,降低内存碎片。
- 安全与稳定性
- 增加输入校验(防SQL注入)、HTTPS支持、请求限流;监控系统资源(如内存泄漏检测)。
- 多格式文档支持
- 扩展Parse模块,支持PDF(如libpoppler)、Markdown等格式解析,扩大应用场景。
- API开放与集成
- 提供RESTful API接口,允许第三方系统集成搜索功能,返回标准化JSON/XML数据。
- 可视化与管理功能
- 增加管理后台,支持索引状态监控、日志分析、停用词动态配置等运维功能。