🏠大家好,我是Yui_💬
🍑如果文章知识点有错误的地方,请指正!和大家一起学习,一起进步👀
🚀如有不懂,可以随时向我提问,我会全力讲解~
🔥如果感觉博主的文章还不错的话,希望大家关注、点赞、收藏三连支持一下博主哦~!
🔥你们的支持是我创作的动力!
🧸我相信现在的努力的艰辛,都是为以后的美好最好的见证!
🧸人的心态决定姿态!
💬欢迎讨论:如有疑问或见解,欢迎在评论区留言互动。
👍点赞、收藏与分享:如觉得这篇文章对您有帮助,请点赞、收藏并分享!
🚀分享给更多人:欢迎分享给更多对编程感兴趣的朋友,一起学习!
文章目录
项目介绍与演示
搜索引擎的核心功能是帮助用户再海量的信息中快速、准确的找到所需的内容。
要实现这样的目标,搜索引擎需要具备以下技术:
- 网页抓取
- 内容解析与预处理
- 索引构建
- 检索与排序
- 自然语言处理
由于本文这只涉及boost的搜索,所以并不会用到网页的爬取这一技术。当然在现实中的搜索引擎肯定不止我们上面写的那些,搜索引擎是一个非常复杂的技术,本文也只是对搜索引擎的一个简单实现。
下面我们来看看实现的效果吧
感觉还是不错的,虽然现在boost库已经支持搜索功能了,但是其实我们这个更应该说是一个文档搜索引擎,如果你需要我们可以不搜索boost文档去搜索其他文档。
1. 项目拆分
为了实现boost搜索引擎,我们肯定也要有上面所写到的那些技术。
网页爬取:
不过对应网页内容的抓取,我们可以直接下载官方文档,这样就可以得到官方网页的内容了。
内容解析与预处理:
然后就是内容解析与预处理,我们解析官方文档中所有的html
文件,这些都是网页会显示的内容。找到所有的html
文件都就要把文件中的所有标题、正文、url内容都提取出来。
索引建立:
建立索引是为了将解析后的信息进行组织和存储,建立高效的索引结构,以便于快速检索
检索与排序:
当用户输入查询时,我们的搜索引擎必须在索引中快速找到相关的内容,并根据一定的算法对结果进行排序,确保最相关的信息优先展示给用户。
自然语言的处理:
理解和处理用户的查询意图,以及网页内容的语义信息,这需要进行用到分词技术。
1.1技术栈
技术栈:C/C++、C++11、STL、准标准库Boost、Jsoncpp、cppjieba、cpp-httplib、html5、css、js、jQuery、Ajax
项目环境:centos/ubuntu,vim、gcc(g++)、Makefile vscode`
2. 获取网页内容
进入boost官方网站
目前的版本是1.87
然后我们需要把下载的内容转移到linux环境中解压。
解压指令为:
tar -xzf boost_1_87_0.tar.gz
解压完毕后,我们需要创建一个目录boost_searcher
,这个就是我们的工作目录,然后在该目录下面创建一个目录data/input
该目录是用来存储数据的,所以我们需要把boost_1_87_0/doc/html/*
内容都拷贝到data/input
当中。
复制完后,你可以在data/input
中看到以下文件
然后我们回到工作目录boost_searcher
,创建一个parser.cpp
文件,用于内容解析。
3.内容解析与预处理
内容解析与预处理我们必须要清楚要解析哪些内容,因为我是搜索引擎,最后搜索到的都是网页,所以我解析的内容肯定都是html
文件。那么我的第一步就是把所有的html
文件都找出来并保存,保存后我们再开始解析,在此之前我必须要讲讲html
文件的格式。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
</body>
</html>
html
文件都是有标签组成了,我们要解析的就是标签中的内容,那么我们就需要把所有的标签给去掉,这也就是解析的步骤。
最后就是预处理,我们会把解析完毕的各个文件写入到一个文件中。
总结为3步走(files_list
output
是后续代码中的变量名):
- 递归式的把每个html文件名带路径,保存到
files_list
,方便后期进行一个一个的文件读取。 - 按照`files_list读取每个文件的内容,并进行解析。
- 把解析完毕的各个文件的内容,写入到
output
中,按照\3
作为每个文档的分隔符。
下面我会给出parser.cpp
文件的基本格式,我们只需要把这个格式的功能补全就完成了该段代码的书写,当然对各个功能的书写,我依旧会各个攻破,并给出思路。
#include <iostream>
#include <string>
#include <vector>
#include "Log.hpp"
//记录所有的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;
/**
*:cosnt &表示输入
*:* 表示输出
*:& 表示输入输出
* */
bool EnumFile(const std::string& src_path,std::vector<std::string>*files_list);
bool ParseHtml(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;
//1.递归式地把每个html文件路径保存到files_list中,方便后期进行读取
if(!EnumFile(src_path,&files_list))
{
LOG(FATAL,"enum file name error [%d->%s]",errno,strerror(errno));
exit(1);
}
//2.安装files_list读取每个文件的内容,并进行解析
std::vector<DocInfo_t> results;
if(!ParseHtml(files_list,&results))
{
LOG(FATAL,"parse html error [%d->%s]",errno,strerror(errno));
exit(2);
}
//3.把解析完的各个文件内容,写入到output中,按照\3作为每个文档的分隔符
if(!SaveHtml(results,output))
{
LOG(FATAL,"save html error [%d->%s]",errno,strerror(errno));
exit(3);
}
return 0;
}
关于日志头文件,其实大家可以不用去管,这是我以前写的一个日志系统,这里就直接拿来用了,对整体影响不大。
3.1 保存html
路径
思路:把所有以html
为后缀的文件路径都保存下来,这样做我们需要用到文件系统的功能。
因为C++的文件系统库没有boost库
的好用,所有在这里我们会用到boost库
。
如果你的系统没有安装boost
且和笔者一样为Centos
系统,可以执行以下命令:
sudo yum install boost boost-devel
安装完毕后,我们就可以引入头文件了。
#include <boost/filesystem.hpp>
步骤:先判断目标路径是否存在,如果存在我们开始递归遍历该路径下的所有内容,如果文件后缀为html
我就记录下来。
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))
{
LOG(FATAL,"%s not exists ",src_path.c_str());
return false;
}
//定义一个空的迭代器,用来判断递归结束
fs::recursive_directory_iterator end;
for(fs::recursive_directory_iterator iter(root_path);iter!=end;iter++)
{
//判断文件是否为普通文件,以及html文件(html文件都是普通文件)
if(!fs::is_regular_file(*iter))
{
continue;
}
if(iter->path().extension()!=".html")
{
continue;
}
//到这里的都是html文件,下面开始计入文件
LOG(DEBUG,"%s",iter->path().string().c_str());
files_list->push_back(iter->path().string());
}
return true;
}
通过debug,我们来看看是否保存成功
这里只截取了部分,因为实在太多了。后续我们就可以把这个debug代码注释了。
3.2 解析html
文件
我们随便点开一个html
文件
我们需要的内容就除了标签外的内容,就比如我用绿线划起来的内容,它是一个网页的标题,我们肯定需要,同时我们还需要网页的内容和url,这就是我需要解析出来的内容。
关于解析html
内容,我们也可以分为4步走:
- 读取文件
- 解析指定文件,提取title
- 解析指定文件,提取content
- 解析指定文件,构建url
在解决问题前,我们先构建一个工具类文件,将来用这个工具类在完成文件的读取,和分词。
我们先写文件读取内容,后续再补充分词功能:
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include "Log.hpp"
namespace ns_util
{
class FileUtil
{
public:
static bool ReadFile(const std::string& file_path,std::string* out)
{
std::ifstream in(file_path,std::ios::in);
if(!in.is_open())
{
LOG(FATAL,"open file [%d->%s]",errno,strerror(errno));
return false;
}
std::string line;
while(std::getline(in,line))
{
*out+=line;
}
in.close();
return true;
}
};
}
然后我们再给出解析html
文件内容的结构:
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results)
{
for(const std::string&file:files_list)
{
//1.读取文件
std::string result;
if(!ns_util::FileUtil::ReadFile(file,&result))
{
continue;
}
DocInfo_t doc;
//2.解析指定的文件,提取title
if(!ParseTitle(result,&doc.title))
{
continue;
}
//3.解析指定文件,提取content
if(!ParseContent(result,&doc.content))
{
continue;
}
//4.提取url
if(!ParseUrl(file,&doc.url))
{
continue;
}
//走到这一定完成了解析任务,把文件的相关内容保存在doc里面。
results->push_back(std::move(doc));//使用move减少拷贝
}
return true;
}
了解结构后,我们逐个攻破。
3.2.1 提取title
想要提取title
非常的简单,因为一个网页只会有一个title
标签,格式也固定<title>xxx</<title>
,想提取出xxx
只需要找到<title>和</title>
的下标,然后提取字串即可。
static bool ParseTitle(const std::string&file,std::string*title)
{
std::size_t begin = file.find("<title>");
if(begin == std::string::npos)
{
return false;
}
std::size_t end = file.find("</title");
begin+=std::string("<title>").size();
if(begin>end)
{
return false;
}
*title = file.substr(begin,end-begin);
return true;
}
3.2.2 提取content
想要提取content
肯定就没有提取title
那么简单了,因为content
的标签众多,为此我们只能专注于<XX>xxx</XX>
。发现了吗,只要是>xxx<
间的就是content
。所以我们可以写一个简单的状态机开完成任务。
利用简单的状态机让我们保证提取的是><
间的内容。
枚举两种状态,一种为LABLE
表示此时处于标签状态,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;
case CONTENT:
if(c == '<')
s = LABLE;
else
{
if(c == '\n')//不保留原始文件的`\n`,为了在后续利用'\n'作为文本的分隔符
c = ' ';
content->push_back(c);
}
break;
default:
break;
}
}
return true;
}
3.2.3 构建url
为什么是构建url呢,我们再次打开官网。
打开这个网页https://www.boost.org/doc/libs/1_87_0/doc/html/move.html
我们拆分下这个链接,前面的https://www.boost.org/doc/libs/1_87_0/doc/html
是固定的,后面的/move.html
就是我们保存的html路径,所以我们就可以知道这个后我们就可以开始构建url
了。
static bool ParseUrl(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;
}
3.3 保存html
文件内容
保存文件要注意的只要记得加\3
充当分隔符
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);
if(!out.is_open())
{
LOG(FATAL,"open %s failed!",output.c_str());
return false;
}
//进行文件内容的写入
for(auto& item:results)
{
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;
}
可以看到,raw.txt
确实有了内容。
接下来,我们开始编写,搜索引擎的搜索功能,这个功能就要涉及到- 索引构建、检索与排序、自然语言处理。
4. 索引构建
首先我们需要考虑的就是如何建立索引。
在建立前,还需要科普什么是正排索引,什么是倒排索引。
4.1 知识补充——正排索引
正排索引是按照文档ID组织数据的,每个文档记录其包含的关键词及相关信息。
结构:
文档ID -> 关键词列表
下面引用博客园情月的示例及解释:正排索引和倒排索引简单介绍
网页A中的内容片段:
Tom is a boy.
Tom is a student too.
网页B中的内容片段:
Jon work at school.
Tom’s teacher is Jon.
正排索引会记录每个关键词出现的次数,查找时会扫描表中的每个文档中字的信息,直到找到包含查询关键字的文档。
假设网页A的局部文档ID为TA,网页B的局部文档ID为TB,那么TA进行正排索引建立的表结构是下面这样的:
从上面的介绍可以看出,正排是以doc_id
作为索引的,但是在搜索的时候我们基本上都是用关键词来搜索。所以,试想一下,我们搜一个关键字(Tom),当100个网页的10个网页含有Tom这个关键字。但是由于是正排是doc id 作为索引的,所以我们不得不把100个网页都扫描一遍,然后找出其中含有Tom的10个网页。然后再进行rank,sort等。效率就比较低了。尤其当现在网络上的网页数已经远远超过亿这个数量后,这种方式现在并不适合作为搜索的依赖。
不过与之相比的是,正排这种模式容易维护。由于是采用doc 作为key来存储的,所以新增网页的时候,只要在末尾新增一个key,然后把词、词出现的频率和位置信息分析完成后就可以使用了。
优点:
- 适用于按文档查找内容,如获取某个文档的所有关键词。
- 数据存储简单,易于维护。
缺点:
- 查询效率低,如果要查找包含某个关键词的所有文档,需要遍历所有文档,耗时较长。
- 不适用于全文检索,因为查找某个关键词在哪些文档中出现是低效的。
4.2 知识补充——倒排索引
倒排索引是按照关键词组织数据的,每个关键词对应包含该关键词的文档ID列表。
结构:
关键词 -> [文档ID列表]
假设:
我的手机是苹果 出自文档1
你的手机是华为 出自文档2
iPhone是手机 出自文档3
那么就会存在以下的索引
"手机" -> [文档1, 文档2, 文档3]
"苹果" -> [文档1]
"华为" -> [文档2]
"iPhone" -> [文档3]
优点:
- 查询效率高,可以快速查找包含某个关键词的所有文档,适用于搜索引擎、全文检索。
- 适用于大规模数据,是搜索引擎的核心数据结构。
缺点: - 构建索引成本高,需要预处理文本数据,并建立反向映射关系。
- 需要额外的存储空间,存储关键词到文档 ID 的映射。
索引类型 | 适用场景 | 优势 | 劣势 |
---|---|---|---|
正排索引 | 需要根据文档获取关键词、元数据 | 构建简单,适用于小规模数据 | 查找包含某个关键词的文档效率低 |
倒排索引 | 搜索引擎、全文检索 | 查询效率高,适合快速检索 | 构建索引耗时,需要额外存储空间 |
通常在搜索引擎中,正排索引和倒排索引会同时使用 |
- 构建倒排索引:用于快速查询关键词出现在哪些文档中。
- 使用正排索引:在检索出文档后,根据正排索引获取文档的其他信息。
4.3 创建index
类
所以知道正排索引和倒排索引后我们应该怎么写代码呢?
我们需要构建一个index
类,这个类需要具有创建正派索引和倒排索引的能力。
其中正排索引利用下标来标记存储文档,而倒排索引就是利用关键词来获取这些关键词出现的文档。
我们可以利用vector
来存储文档作为正排索引,同样为了描述一个文档,我们还是需要创建一个结构体DocInfo
。
然后就是倒排索引,为了实现倒排索引,我们可以利用倒哈希表的映射功能:
KEY VALUE
"手机" -> [文档1, 文档2, 文档3]
"苹果" -> [文档1]
"华为" -> [文档2]
"iPhone" -> [文档3]
以关键词为key值,一组文档为value。
因为文档有许多嘛,我们肯定也需要用一个容器来存储,同时在创建一个结构体来描述文档,不过这个就和上面有所不同了,在这里我们不需要描述文档的所有内容,因为最后我们是需要根据id
去正排索引里拿数据的,所以这个的文档属性只需要有文档id word 和weight
即可。
最后得到正排的数据结构为:std::vector<DocInfo> forward_index
倒排的数据结构:std::unordered_map<std::string, InvertedList> Inverted_index
注意:我们涉及的index
类是单例模式。为什么要设计为单例模式呢?
为了避免重复加载索引,节约内存。
下面给出index
类的基本结构,后续就是在这个结构上进行补充:
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <unordered_map>
#include <mutex>
#include "util.hpp"
#include "Log.hpp"
#include <vector>
namespace ns_index
{
struct DocInfo
{
std::string title;
std::string content;
std::string url;
uint64_t doc_id; // 文档的id
};
struct InvertedElem
{
uint64_t doc_id;
std::string word;
int weight;
InvertedElem()
:weight(0)
{}
};
//倒排拉链
typedef std::vector<InvertedElem> InvertedList;
class Index
{
private:
//正排索引的数据结构直接用数组,数组的下标就是天然的文档id
std::vector<DocInfo> forward_index;//正排索引
//倒排索引一定是一个关键字和一组InvertedElem对应【关键词和倒排拉链的映射关系】
std::unordered_map<std::string, InvertedList> Inverted_index;
private://单例模式
Index() {}
Index(const Index&) = delete;
Index& operator = (const Index&) = delete;
public:
~Index() {}
public:
static Index* GetInstance()
{
if (nullptr == instance)//双重保险
{
mtx.lock();
if (nullptr == instance)
{
instance = new Index();
}
mtx.unlock();
}
return instance;
}
public:
//根据去标签,格式话后的文档,构建正排和倒排索引
bool BuildIndex(const std::string& input)
{}
private:
static Index* instance;
static std::mutex mtx;
};
Index* Index::instance = nullptr;
std::mutex Index::mtx;
}
4.3.1 实现BuildIndex
函数
我们需要导入parser解析过的数据,数据存储在/home/yui/boost_searcher/data/raw_html/raw.txt
.
将数据导入后开始建立索引,先建立正排索引,然后再建立倒排索引。
bool BuildIndex(const std::string& input)
{
std::ifstream in(input,std::ios::in | std::ios::binary);
if(!in.is_open())
{
LOG(FATAL,"input %s failed",input.c_str());
return false;
}
std::string line;
int count = 0;
while(std::getline(in,line))
{
DocInfo* doc = BuildForwardIndex(line);
if(nullptr == doc)
{
LOG(WARNING,"bulid %s failed",line.c_str());
continue;
}
BuildInvertedIndex(*doc);
count++;
if(count%100 == 0)
{
LOG(DEBUG,"create index success num:%d",count);
}
}
return true;
}
下面具体实现正排索引与倒排索引
4.3.2 实现BuildForwardIndex
函数
实现这个正排索引,其实还是很简单的,比倒排肯定简单多了。
为了实现这个功能,我需要对字符串进行切分,还记得当初我给文档字符串添加的分割符\3
这也就是我们切割文档的关键,我们需要切割出文档的title\content\url
为了切割字符串,我们可以使用boost
库做到一键切除。同时把切割字符串这个功能封装到工具类中。
//记得添加头文件#include <boost/algorithm/string.hpp>
class StringUtil
{
public:
static void Split(const std::string& target,std::vector<std::string>*out,const std::string& sep)
{
//利用boost库
boost::split(*out,target,boost::is_any_of(sep),boost::token_compress_on);
}
};
简单介绍下boost::split
这个函数吧
下面引用GPT的解释,感觉不错:
函数原型:
#include <boost/algorithm/string.hpp>
#include <vector>
#include <string>
void split(std::vector<std::string>& result,
const std::string& input,
const boost::algorithm::detail::is_any_ofF<char>& separator,
boost::algorithm::token_compress_mode_type compress = boost::algorithm::token_compress_off);
参数说明:
result
:用于存储分割后的子字符串的std::vector<std::string>
。input
:待分割的字符串。separator
:分隔符,可以是单个字符,也可以是多个字符。compress
:boost::algorithm::token_compress_on
:合并多个相邻的分隔符,避免空字符串。boost::algorithm::token_compress_off
(默认):不会合并分隔符,相邻分隔符会导致空字符串。
分离完字符串就可以把它们取出来了
DocInfo* BuildForwardIndex(const std::string& line)
{
//1. 解析line,字符串切分
//line 切分为:title content url这3个字符串
std::vector<std::string> results;
const std::string sep = "\3";//行内分割符
ns_util::StringUtil::Split(line,&results,sep);
if(results.size()!=3)
{
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中的下标
//3.插入到正排索引的vector
forward_index.push_back(std::move(doc));
return &forward_index.back();//其实就是返回当前新构成的文档
}
4.3.3 实现BuildInvertedIndex
函数
想要实现倒排索引,就必须要先分词(后面讲,工具类中会包含分词类,用到了jieba库)。
变量doc
中已经有了文档的title
和content
了,我们可以先创建一个结构体word_cnt
目的是为了统计title/content
中分词后的各字符串出现的次数,方便后续计算权重。
在该函数中,我们需要分别把title/content
交给分词工具,分词工具会帮助我们分好词,我拿到分好的词就要开始词频统计,统计完后我们就可以开始把InvertedElem
补充完整。补充完整后就可以把它存储到属性inverted_index
中了。
bool BuildInvertedIndex(const DocInfo &doc)
{
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 (std::string 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 (std::string 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 = inverted_index[word_pair.first];//key不存在会自动创建
inverted_list.push_back(std::move(item));
}
return true;
}
4.3.4 知识补充——cppjieba库
本文用到的分词工具都是来自cppjieba
库
打开github,
就是星标最多的那个。
点击后,就可以开始clone了
git clone https://github.com/yanyiwu/cppjieba.git
在终端输入该指令,即可安装最新版本的jieba库。
其实我是不推荐这个最新版本的,不知道为什么昨天晚上尝试这个最新版本一直达不到要求,可能我的机器配置有点小小的问题吧,按下面的教程也没有把demo跑出来。
最终我也是下载了历史版本,我下载的22年的一个版本。
如果你不知道怎么版本回退,在输入完上面指令后进入cppjieba
目录输入以下指令:
git checkout e81930b7c2266ce0b1a820f2bbad55098859f833
这样你的版本就和我一致了。
下面我会教你跑通demo.cpp
文件。
先输入上面3个指令啊:
cp cppjieba/test/demo.cpp ./ #将demo文件拷贝出来测试用
ln -s cppjieba/dict dict #该目录中存储都是各种词库,没有这个也分不了词
ln -s cppjieba/include inc #存储各种头文件的地方
处理完这些后我们demo文件也需要小改,比较所处的目录不同了。
主要改动有两处,就是我箭头所指方向。
当然这个其实还是不行的,我们还需要把cppjieba/deps/limonp
拷贝到cppjieba/include/cppjieba
当中
拷贝后的效果:
这其实也是jieba库的一个小bug。
下面我们就可以编译demo文件了
你可以看到这种效果就是成功了。
下面我们要解决的就是引入jieba库到我们的工作目录了。
我们的cppjieba
库存储在~/mylib
中。
所以我可以这样引入:
ln -s ~/mylib/cppjieba/include/cppjieba cppjieba
ln -s ~/mylib/cppjieba/dict dict
这样就成功在我们的工作目录成功建立其了软链接了。
4.3.5 工具类的补充——分词功能
在上面的倒排索引中,我们使用到了工具类,所以我们的工具类中肯定还要补充分词功能。
首先我们就要引用头文件
#include "cppjieba/Jieba.hpp"
#include <mutex>
引入互斥锁的目的是为了,实现单例模式。没错这个分词类我也打算设计成单例模式。
这个分词类主要在工作的函数就是它了jieba.CutForSearch(src, *out);
,把字符串src给它就就会分好词如何存储在out
当中,调库大法就这么简单。
当然那样就太简单了,所以我们这个类是实现了去除暂停词的功能。
那么,肯定有人不知道上面是暂停词了。
停用词是指在信息检索中,为节省存储空间和提高搜索效率,在处理自然语言数据(或文本)之前或之后会自动过滤掉某些字或词,这些字或词即被称为Stop Words(停用词)。这些停用词都是人工输入、非自动化生成的,生成后的停用词会形成一个停用词表。但是,并没有一个明确的停用词表能够适用于所有的工具。甚至有一些工具是明确地避免使用停用词来支持短语搜索的。
这是百度百科的解释
其实举个例子:因为xxx,所以xxx。里的因为、所以就是暂停,这对搜索没有帮助
还要英文中的 the is 这些也都是暂停。
打开,jieba库中的stop_words.utf8
这里面的都是暂停词
有了这些暂停词,我们就可以去除分完词后的out
里的内容了,写一个循环即可。
下面看代码是如何实现的把:
//首先先引入词库
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:
cppjieba::Jieba jieba;
std::unordered_map<std::string, bool> stop_words;//判断暂停词
private:
JiebaUtil()
: jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH)
{
}
JiebaUtil(const JiebaUtil &) = delete;
public:
static JiebaUtil *get_instance()
{
if (nullptr == instance)
{
mtx.lock();
if (nullptr == instance)
{
instance = new JiebaUtil();
instance->InitJiebaUtil();
}
mtx.unlock();
}
return instance;
}
void InitJiebaUtil()
{
std::ifstream in(STOP_WORD_PATH);
if (!in.is_open())
{
LOG(FATAL, "load stop wordsfile error");
return;
}
std::string line;
while (std::getline(in, line))
{
stop_words.insert({line, true});
}
in.close();
}
void CutStringHelper(const std::string &src, std::vector<std::string> *out)
{
jieba.CutForSearch(src, *out);
for (auto iter = out->begin(); iter != out->end();)
{
auto it = stop_words.find(*iter);
if (it != stop_words.end())
{
// 说明当前的string是暂停词,需要去除
iter = out->erase(iter);
}
else
{
iter++;
}
}
}
public:
static void CutString(const std::string &src, std::vector<std::string> *out)
{
ns_util::JiebaUtil::get_instance()->CutStringHelper(src, out);
}
private:
static JiebaUtil *instance;
static std::mutex mtx;
};
JiebaUtil *JiebaUtil::instance = nullptr;
std::mutex JiebaUtil::mtx;
至此
工具类全部完成
4.3.6 index类补充——终
其实我们的index类并没有完成完成,毕竟我们还没有提供根据doc_id
找到相对应文档和根据字符串找到倒排拉链的功能。
这两功能都很重要的,要不然光建立了正排索引和倒排索引搜索不到内容啊。
这两个功能都很好实现,因为就是简单的搜索
根据doc_id
找到文档内容
DocInfo* GetForwardIndex(uint64_t doc_id)
{
if(doc_id >= forward_index.size())
{
LOG(ERROR,"doc_id out range,error!");
return nullptr;
}
return &forward_index[doc_id];
}
根据关键词找到倒排拉链
InvertedList* GetInvertedList(const std::string& word)
{
auto iter = inverted_index.find(word);
if(iter == inverted_index.end())
{
LOG(WARNING,"%s have no InvertedList",word.c_str());
return nullptr;
}
return &(iter->second);
}
5. 检索与排序
我们应该怎么检索呢?
在我们日常的搜索过程中,肯定都是在搜索框内输入一串字符串,然后就是等待搜索结果。
那么在我们等待的过程中,搜索引擎都做了什么呢?假设是搜索引擎启动前。
这就是我们目前要考虑的
首先它肯定会经历初始化,初始化就是建立正排索引和倒排索引。
只有初始化完成后,我们才可以进行搜索。
然后搜索引擎会拿到我要搜索的内容query
,进行搜索.
最后就是显示出搜索结果。
没错,这就是我待会要实现的2个功能
- 初始化
- 搜索
下面就是我们要实现Searcher
类的基本结构了
功能就是上面3个,属性肯定需要索引类。
#pragma once
#include "index.hpp"
#include "util.hpp"
#include "Log.hpp"
#include <algorithm>
#include <unordered_map>
namespace ns_searcher
{
struct InvertedElemPrint
{
uint64_t doc_id;
int weight;
std::vector<std::string> words;
InvertedElemPrint()
:doc_id(0),weight(0)
{}
};
class Searcher
{
private:
ns_index::Index* index;//提供查找需要的索引
public:
Searcher(){}
~Searcher(){}
public:
void InitSearcher(const std::string& input)
{}
void Search(const std::string& query,std::string*json_string)
{}
};
}
5.2 初始化
Searcher类的初始化化非常的简单,只需要两步,创建正排索引、创建倒排索引
void InitSearcher(const std::string& input)
{
//1.获取或者创建index对象
index = ns_index::Index::GetInstance();
LOG(INFO,"获取index单例成功...");
//2.根据index对象创建索引
index->BuildIndex(input);
LOG(INFO,"建立正排和倒排索引成功...");
}
5.2 实现Search
函数
搜索就是你提供一个搜索关键词,然后返回结果。在这个过程中,我们需要搜索关键词分词,然后再拿分词后的结果在倒排索引中搜索,得到搜索结果后,按照权重weight来排降序。然后就是根据查找出来的结果,进行序列化,这里可以使用Jsoncpp
库。
总结就是4点:
- 对搜索关键词进行分词。
- 根据分词进行index查找。
- 汇总查找结果,按照权重weight来排降序。
- 根据查找出来的结果,进行序列化,构建json串。
同时我们要注意,一般在我搜索时,并不能看到文档的全部内容,都是节选内容
所以我们也需要实现,这一功能,我们需要在写一个节选content
的函数来实现这个功能,这个函数就是GetDesc
void Search(const std::string& query,std::string*json_string)
{
//1.对搜索关键词进行分词
std::vector<std::string> words;
ns_util::JiebaUtil::CutString(query,&words);
//2.根据分词进行index查找
std::vector<InvertedElemPrint> inverted_list_all;
std::unordered_map<uint64_t,InvertedElemPrint> tokens_map;
for(std::string word:words)
{
boost::to_lower(word);
ns_index::InvertedList* inverted_list = index->GetInvertedList(word);
if(nullptr == inverted_list)
{
continue;
}
for(const auto&elem:*inverted_list)
{
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));
}
//3.汇总查找结果,按照权重weight来排降序
std::sort(inverted_list_all.begin(),inverted_list_all.end(),\
[](const InvertedElemPrint&e1,const InvertedElemPrint&e2)
{
return e1.weight>e2.weight;
});
//4.根据查找出来的结果,进行序列化,构建json串
Json::Value root;
for(auto& item:inverted_list_all)
{
ns_index::DocInfo* doc = index->GetForwardIndex(item.doc_id);
if(nullptr == doc)
{
continue;
}
Json::Value elem;
elem["title"] = doc->title;
elem["desc"] = GetDesc(doc->content,item.words[0]);
elem["url"] = doc->url;
elem["id"] = (int)item.doc_id;
elem["weight"] = item.weight;
root.append(elem);
}
Json::FastWriter write;
*json_string = write.write(root);
}
5.2.1 实现内容节选GetDesc
函数
我们需要需要需要内容节选,这里我打算选取关键词首次出现的前50个字符到后100个字符。
首先还是需要找到关键词首次出现的位置,我们可以直接调库std::search
,同时我们还要忽略大小写,所以还写一个自定义函数给它。
然后就是截取关键词出现的前后位置,注意可能关键词前后没有那么多字符,这时候就要特判一下了。
std::string GetDesc(const std::string& html_content,const std::string&word)
{
//节选部分内容
const int prev_step = 50;
const int next_step = 100;
//1.找到首次出现的位置
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.获取start,end下标
int start = 0;
int end = html_content.size() - 1;
//判断前方是否有足够字符
if(pos>start+prev_step) start = pos - prev_step;
if(pos<end-next_step) end = pos+next_step;
// 3.截取字串
if(start>=end) return "None2";
std::string desc = html_content.substr(start,end-start);
desc+="...";
return desc;
}
6. 简单Debug
我们写一个小小的debug.cpp
来看看效果吧。后续就要让它上网了
#include "searcher.hpp"
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
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 << "Please Enter You Search 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;
}
编写makefile文件
.PHONY:all
all:parser debug
parser:parser.cpp
g++ -o $@ $^ -std=c++11 -lboost_system -lboost_filesystem
debug:debug.cpp
g++ -o $@ $^ -std=c++11 -ljsoncpp
.PHONY:clean
clean:
rm -rf parser debug
输入make
便会生成两个可执行文件,我先运行parser
文件先生成文档数据,然后运行debug
文件
注意:可能执行时间会比较长(笔者是低配云服务器)
大概会建立这么多的索引,然后你就可以输入你先搜索的内容,这里我也是搜索了split
这个关键词。
可以看到,非常的不美观,这是因为我们的Json
串的格式没有选好,我们选择的格式为Json::FastWriter
,它会把内容挤在一起类似这样:
{"name":"Alice","age":25,"city":"New York"}
所以可以选择别的格式,如Json::StyledWriter
它的格式类似于
{
"name": "Alice",
"age": 25,
"city": "New York"
}
当然这里我就不修改了,因为最后我们是显示在网页的,终端的格式就无所谓了。
7. 将搜索引擎入网
现在我们搜索引擎再厉害也是自娱自乐,如果我想让别人也可以访问呢?为了让别人也能够访问,这样的话,我们就必须再写一个服务器。这个当然可以直接写,只要要学过socket
相关的知识即可,再过去的文章中,我们也写过一些简单的聊天服务器。不过你知道的,还是现成的香,而且还经过了大家的检验,不容易出错。所以我们下面将引入cpp-httplib
库
7.1 cpp-httplib
库的引入
打开github搜索cpp-httplib
即可
不想搜索可以点击链接cpp-httplib
还是熟悉的操作,在终端输入git clone https://github.com/yhirose/cpp-httplib.git
即可把文件下载到当前目录下。
你可以把它放在工作目录,当然也可以和我一样。
我把它放在了~/mylib
下,然后引入软连接
ln -s ~/mylib/cpp-httplib-master cpp-httplib
7.2 编写http_server.cc
文件
创建一个服务器需要创建套接字socket
以及bind结构体下面就是listen和accept。但是现在我什么都不用做,直接调库。
先创建一个http实例,然后就是使用Get
来等待消息即可。
#include "cpp-httplib/httplib.h"
#include "searcher.hpp"
// 定义输入文件路径和服务器根目录
const std::string input = "data/raw_html/raw.txt";
const std::string root_path = "./wwwroot";
int main()
{
// 初始化搜索器
ns_searcher::Searcher searcher;
searcher.InitSearcher(input);
// 创建HTTP服务器实例
httplib::Server svr;
svr.set_base_dir(root_path.c_str());//添加主网页
// 设置GET请求处理函数
svr.Get("/s", [&searcher](const httplib::Request& req, httplib::Response& res) {
// 检查请求参数中是否包含'query'
if(!req.has_param("query"))
{
res.set_content("param query is required", "text/plain");
return;
}
// 获取查询参数
std::string query = req.get_param_value("query");
// 记录日志
LOG(INFO,"query:%s",query.c_str());
std::string json_string;
// 执行搜索并获取结果
searcher.Search(query, &json_string);
// 设置响应内容类型为JSON并返回结果
res.set_content(json_string,"text/json");
});
// 记录服务器启动日志
LOG(INFO,"start server");
// 启动服务器监听
svr.listen("0.0.0.0", 8080);
return 0;
}
当然现在我们还没有写前端代码。
我们先来看看效果:
现在我们没有输入请求
当我们输入请求query=split
时
可以看到显示内容了
8. 前端内容
目前笔者仅仅只是学习完了html/css
的基础语法,对前端的知识并没有特别熟练,下面关于前端的讲解也只是简单的进行说明。
我先设计下搜索界面应该是什么样的
感觉怎么样,是不是有点样子了。
我们先写写html
文件看看吧
8.1 html
部分
要想实现搜索框,那么在html里面什么和其最匹配呢?
当然就是input
标签了,确认搜索框就是一个按钮嘛,也就是button
标签。
我们把它们都放在search
类中,方便后续美化。
关于,显示的格式,我们也可以先写,后续就是用js生成了。
注意<button onclick="Search()">搜索一下</button>
中onclick="Search()
会直接处罚调取js中的Search
函数
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div class="container">
<div class="search">
<input type="text" value="请输入搜索关键字">
<button onclick="Search()">搜索一下</button>
</div>
<div class="result">
<!-- 动态生成网页内容 -->
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://blog.csdn.net/2303_79015671?spm=1001.2101.3001.5343</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://blog.csdn.net/2303_79015671?spm=1001.2101.3001.5343</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://blog.csdn.net/2303_79015671?spm=1001.2101.3001.5343</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://blog.csdn.net/2303_79015671?spm=1001.2101.3001.5343</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://blog.csdn.net/2303_79015671?spm=1001.2101.3001.5343</i>
</div>
</div>
</div>
</body>
</html>
这大概就是搜索后的界面吧,不过现在这样肯定是不行的,我们需要用CSS美化,同时下面的内容只有在搜索后才会出现,没有搜索前是不显示的,所以后面我们还要用js处理下。
8.2 CSS
部分
我们先来看看百度的搜索框是怎么实现的吧
我们可以提取到的特征有:圆角矩形、搜索框和确定搜索框相连、确定搜索框是蓝底白字等等等
<style>
/* 去掉网页中的所有的默认内外边距,html的盒子模型 */
* {
/* 设置外边距 */
margin: 0;
/* 设置内边距 */
padding: 0;
}
/* 将我们的body内的内容100%和html的呈现吻合 */
html,
body {
height: 100%;
}
/* 类选择器.container */
.container {
/* 设置div的宽度 */
width: 800px;
/* 通过设置外边距达到居中对齐的目的 */
margin: 0px auto;
/* 设置外边距的上边距,保持元素和网页的上部距离 */
margin-top: 15px;
}
/* 复合选择器,选中container 下的 search */
.container .search {
/* 宽度与父标签保持一致 */
width: 100%;
/* 高度设置为52px */
height: 52px;
}
/* 先选中input标签, 直接设置标签的属性,先要选中, input:标签选择器*/
/* input在进行高度设置的时候,没有考虑边框的问题 */
.container .search input {
/* 设置left浮动 */
float: left;
width: 600px;
height: 50px;
/* 设置边框属性:边框的宽度,样式,颜色 */
border: 1px solid black;
/* 去掉input输入框的有边框 */
border-right: none;
/* 设置内边距,默认文字不要和左侧边框紧挨着 */
padding-left: 10px;
/* 设置input内部的字体的颜色和样式 */
color: #CCC;
font-size: 14px;
border-top-left-radius:10px;
border-bottom-left-radius:10px;
}
/* 先选中button标签, 直接设置标签的属性,先要选中, button:标签选择器*/
.container .search button {
/* 设置left浮动 */
float: left;
width: 150px;
height: 52px;
/* 设置button的背景颜色,#4e6ef2 */
background-color: #4e6ef2;
/* 设置button中的字体颜色 */
color: #FFF;
/* 设置字体的大小 */
font-size: 19px;
font-family:Georgia, 'Times New Roman', Times, serif;
border-top-right-radius:10px;
border-bottom-right-radius:10px;
}
.container .result {
width: 100%;
}
.container .result .item {
margin-top: 15px;
}
.container .result .item a {
/* 设置为块级元素,单独站一行 */
display: block;
/* a标签的下划线去掉 */
text-decoration: none;
/* 设置a标签中的文字的字体大小 */
font-size: 20px;
/* 设置字体的颜色 */
color: #4e6ef2;
}
.container .result .item a:hover {
text-decoration: underline;
}
.container .result .item p {
margin-top: 5px;
font-size: 16px;
font-family:'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
}
.container .result .item i{
/* 设置为块级元素,单独站一行 */
display: block;
/* 取消斜体风格 */
font-style: normal;
color: green;
}
</style>
这就是美化后的样子
是不是非常有感觉了
8.3 JS部分
光美化可不行,要人这个网页可以连接上我们的服务器,为了做到这一点,我们就必须开始书写jsd代码。
我需要取得用户输入的数据,然后发起http请求和服务器进行交互。
注意:记得在前面引入JQuery
库
<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
<script>
function Search(){
// 1. 提取数据, $可以理解成就是JQuery的别称
let query = $(".container .search input").val();
console.log("query = " + query); //console是浏览器的对话框,可以用来进行查看js数据
//2. 发起http请求,ajax: 属于一个和后端进行数据交互的函数,JQuery中的
$.ajax({
type: "GET",
url: "/s?query=" + query,
success: function(data){
console.log(data);
BuildHtml(data);
}
});
}
function BuildHtml(data){
// 获取html中的result标签
let result_lable = $(".container .result");
// 清空历史搜索结果
result_lable.empty();
for( let elem of data){
// console.log(elem.title);
// console.log(elem.url);
let a_lable = $("<a>", {
text: elem.title,
href: elem.url,
// 跳转到新的页面
target: "_blank"
});
let p_lable = $("<p>", {
text: elem.desc
});
let i_lable = $("<i>", {
text: elem.url
});
let div_lable = $("<div>", {
class: "item"
});
a_lable.appendTo(div_lable);
p_lable.appendTo(div_lable);
i_lable.appendTo(div_lable);
div_lable.appendTo(result_lable);
}
}
</script>
9.项目总结
虽然本项目写的是boost搜索引擎,但是其中的思想可以运用到任何的文档搜索引擎中,如果你以后先编写其他文档的搜索引擎其实对于现在的代码并不需要修改太多的地方,大概我们只需要修改parser.cc
中的构建url那段。其他地方的代码和文档根本也没什么关联,毕竟我们的程序只是一个工具,搜索什么都是搜索嘛。
全部写下来,还是会很有收获的。
9.1 小吐槽
可能因为我的云服务器配置太低了,每次运行到构建索引哪里都卡我十几分钟,所以我也学聪明了,要调试的话,我直接把raw.txt
里的数据清空,直接秒开了,哈哈。
还有这个centos真是懒得喷啊,为什么要限制GCC的版本啊,等我看教程升级吧,下载SCL
后,我直接安装不了软件了,一查才知道CentOS 7 的官方软件源在 2024 年 7 月 1 日之后已停止维护,导致默认的镜像源无法访问,换了清华源也不行,只能转战Ubuntu了。