首先来一个实例:
其本质就是通过文档ID找到文档内容
模拟一下正排索引:
文档ID(doc_id) | 文档内容(content) |
---|---|
1 | 他来到了网易大厦 |
2 | 我来到北京网易了 |
总结:
其本质就是根据文档内容,进行分词处理,整理出不同并且不重复的各个关键字,然后对应联系到对应文档ID
模拟一下倒排索引:
关键字(word) | 文档ID(doc_id)&&权重(weight) |
---|---|
他 | 1 |
来到 | 1, 2 |
了 | 1, 2 |
网易 | 1, 2 |
大厦 | 1 |
我 | 2 |
北京 | 2 |
总结:
后端所使用到的技术栈是:C/C++、C++11、STL、Boost标准库,Jsoncpp标准库,cppjieba标准库,cpp-httplib-0.7.15。(lambda表达式)
其中前端网页所使用到的技术栈是:html5、css、js、jQuery、Ajax(本人并没有专注学习前端相关技术栈,本项目前端代码是粘贴的`(>﹏<)′ )。
基本环境是阿里云 2核(vCPU) 2GiB Ubuntu 22.04 64位云服务器。
其中编译器版本是:gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04)(云服务器默认配置)。
Xshell中如下显示(因为我已经安装过了,所以显示这样):
server@:~$ sudo apt install libboost-all-dev
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
libboost-all-dev is already the newest version (1.74.0.3ubuntu7).
0 upgraded, 0 newly installed, 0 to remove and 5 not upgraded.
安装指令是 apt install libboost-all-dev。
注:如果云服务器中非root用户就需要在指令前添加sudo,然后输入所设置的密码即可安装。
Xshell中如下显示(因为我已经安装过了,所以显示这样):
server@:~$ sudo apt install libjsoncpp-dev
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
libjsoncpp-dev is already the newest version (1.9.5-3).
0 upgraded, 0 newly installed, 0 to remove and 5 not upgraded.
安装指令是 apt install libjsoncpp-dev。
注:如果云服务器中非root用户就需要在指令前添加sudo,然后输入所设置的密码即可安装。
这个库需要我们自行下载导入到云服务器中,在GitHub或Gitee或GitCode上都可以搜索到,搜索名称就是cpp-httplib(本项目使用的cpp-httplib-0.7.15版本)。
注:从这里开始就需要在家目录或者是自己可以找到的地方创建一个项目文件夹了,我这里命名就是boost_searcher,以下操作都是在boost_searcher目录下进行的!!!
在Xshell中使用lrzsz相关指令从Win电脑中上传到云服务器中即可(上传到boost_searcher文件夹中,也就是在这个文件夹中使用相关上传指令),然后使用对应解压缩指令解压即可。
注:安装lrzsz指令命令是apt install lrzsz,解压缩相关指令需要看下载的是zip还是tar,使用与之对应的解压缩指令即可,如果不会的话可以使用通义千问,个人还是喜欢使用这个。
注:cpp-httplib在使⽤的时候需要使⽤较新版本的gcc,centos 7下默认gcc 4.8.5,但是在Ubuntu 22.04下不用管(gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04))
这个库需要我们自行下载导入到云服务器中,在GitHub或Gitee或GitCode上都可以搜索到,搜索名称就是cppjieba。
首先查看使用指令ls -l ./cppjieba/include/cppjieba查看该文件夹中有没有limonp文件夹,如果没有就需要我们手动导入一下。
server@:~$ ls -l ./cppjieba/include/cppjieba/
total 88
-rw-rw-r-- 1 server server 7534 Jul 28 17:46 DictTrie.hpp
-rw-rw-r-- 1 server server 2528 Jul 28 17:46 FullSegment.hpp
-rw-rw-r-- 1 server server 3278 Jul 28 17:46 HMMModel.hpp
-rw-rw-r-- 1 server server 5005 Jul 28 17:46 HMMSegment.hpp
-rw-rw-r-- 1 server server 3496 Jul 28 17:46 Jieba.hpp
-rw-rw-r-- 1 server server 4284 Jul 28 17:46 KeywordExtractor.hpp
-rw-rw-r-- 1 server server 3009 Jul 28 17:46 MixSegment.hpp
-rw-rw-r-- 1 server server 3640 Jul 28 17:46 MPSegment.hpp
-rw-rw-r-- 1 server server 1872 Jul 28 17:46 PosTagger.hpp
-rw-rw-r-- 1 server server 1199 Jul 28 17:46 PreFilter.hpp
-rw-rw-r-- 1 server server 2629 Jul 28 17:46 QuerySegment.hpp
-rw-rw-r-- 1 server server 1008 Jul 28 17:46 SegmentBase.hpp
-rw-rw-r-- 1 server server 413 Jul 28 17:46 SegmentTagged.hpp
-rw-rw-r-- 1 server server 6350 Jul 28 17:46 TextRankExtractor.hpp
-rw-rw-r-- 1 server server 4515 Jul 28 17:46 Trie.hpp
-rw-rw-r-- 1 server server 6201 Jul 28 17:46 Unicode.hpp
导入limonp文件夹(如果有就不用了)
server@:~/boost_searcher$ cp -rf ./cppjieba/deps/limonp/ ./cppjieba/include/cppjieba/
导入之后查看是否导入成功即可
server@:~/boost__searcher$ ls -l ./cppjieba/include/cppjieba/
total 92
-rw-rw-r-- 1 server server 7534 Jul 28 17:46 DictTrie.hpp
-rw-rw-r-- 1 server server 2528 Jul 28 17:46 FullSegment.hpp
-rw-rw-r-- 1 server server 3278 Jul 28 17:46 HMMModel.hpp
-rw-rw-r-- 1 server server 5005 Jul 28 17:46 HMMSegment.hpp
-rw-rw-r-- 1 server server 3496 Jul 28 17:46 Jieba.hpp
-rw-rw-r-- 1 server server 4284 Jul 28 17:46 KeywordExtractor.hpp
drwxrwxr-x 2 server server 4096 Jul 28 17:59 limonp
-rw-rw-r-- 1 server server 3009 Jul 28 17:46 MixSegment.hpp
-rw-rw-r-- 1 server server 3640 Jul 28 17:46 MPSegment.hpp
-rw-rw-r-- 1 server server 1872 Jul 28 17:46 PosTagger.hpp
-rw-rw-r-- 1 server server 1199 Jul 28 17:46 PreFilter.hpp
-rw-rw-r-- 1 server server 2629 Jul 28 17:46 QuerySegment.hpp
-rw-rw-r-- 1 server server 1008 Jul 28 17:46 SegmentBase.hpp
-rw-rw-r-- 1 server server 413 Jul 28 17:46 SegmentTagged.hpp
-rw-rw-r-- 1 server server 6350 Jul 28 17:46 TextRankExtractor.hpp
-rw-rw-r-- 1 server server 4515 Jul 28 17:46 Trie.hpp
-rw-rw-r-- 1 server server 6201 Jul 28 17:46 Unicode.hpp
可以看到已经多了一个文件夹drwxrwxr-x 2 server server 4096 Jul 28 17:59 limonp,这样就是导入成功了。
软连接dict
server@:~/boost_searcher$ ln -s ./cppjieba/dict/ dict
server@:~/boost_searcher$ ll
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cpp-httplib-0.7.15/
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cppjieba/
lrwxrwxrwx 1 server server 16 Jul 28 18:07 dict -> ./cppjieba/dict//
软连接inc
server@:~/boost__searcher$ ln -s ./cppjieba/include/cppjieba/ inc
server@:~/boost__searcher$ ll
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cpp-httplib-0.7.15/
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cppjieba/
lrwxrwxrwx 1 server server 16 Jul 28 18:07 dict -> ./cppjieba/dict//
lrwxrwxrwx 1 server server 28 Jul 28 18:07 inc -> ./cppjieba/include/cppjieba//
方便后续头文件的使用,以及使用以下软连接加深理解。
首先定义一下函数传参命名规则:
1. const & :对应输入参数
2. * :对应输出参数
3. & :对应输入和输出参数
下载数据源:
#pragma once
#include
#include
#include
#define NORMAL 1
#define WARNING 2
#define DEBUG 3
#define FATAL 4
/// @brief log调用宏定义
/// @param #LEVEL #操作符会将宏参数转换为字符串字面量
/// @param MESSAGE 日志消息本身
/// @param __FILE__ 预处理器定义的宏,它扩展为包含当前源文件的名称
/// @param __LINE__ 预处理器定义的宏,它扩展为包含当前行号的整数
#define LOG(LEVEL, MESSAGE) log(#LEVEL, MESSAGE, __FILE__, __LINE__)
/// @brief log调用显示函数
/// @param level 消息等级
/// @param message 消息内容
/// @param file 文件位置
/// @param line 该log所在文件行数
void log(const std::string level, const std::string message, const std::string file, const int line)
{
std::cout << "[" << level << "]" << "[" << time(nullptr) << "]" << "[" << message << "]" << "[" << file << ":" << line << "]" << std::endl;
}
在日志模块中使用到了宏定义、预处理器定义的宏等技术…
针对头文件的引入和命名空间的定义:
#pragma once
#include
#include
#include
#include
#include "inc/Jieba.hpp"
#include "log.hpp"
namespace ns_util
{
};
以下功能代码都是属于ns_util命名空间代码。
为项目提供文件读取操作,主要使用到的知识点:
class FileUtil // 提供了读取文件内容的静态方法
{
public:
/// @brief 读取文件
/// @param file_path 文件路径
/// @param out 用于存储文件内容的字符串指针
/// @return 成功读取返回true,否则返回false
static bool ReadFile(const std::string &file_path, std::string *out)
{
// 以输入模式打开文件,即从文件读取数据
std::ifstream in(file_path, std::ios::in); // 可以使用std::ios_base::in
// 判断文件是否打开成功,失败直接返回false
if (!in.is_open())
{
// std::cerr << "Open file " << file_path << " error" << std::endl;
LOG(WARNING, "Open file error: " + file_path);
return false;
}
// 逐行读取文件内容,并将其追加到输出字符串中
std::string line; // 缓冲区
while (getline(in, line))
{
*out += line; // 直接追加即可
}
in.close(); // 读取完成,记得关闭文件
return true;
}
};
为项目提供字符串处理操作,主要使用到的知识点:
class StringUtil // 提供字符串处理功能的工具类
{
public:
/// @brief 分割字符串
/// @param line 需要分割的字符串
/// @param result 存放分割结果的向量,每个元素为一个分割后的子字符串
/// @param sep 分隔符,用于指定字符串分割的位置
static void Split(const std::string &line, std::vector<std::string> *result, const std::string &sep)
{
// 使用boost库的split函数进行字符串分割,并压缩空字符
boost::split(*result, line, boost::is_any_of(sep), boost::token_compress_on);
}
};
为项目提供基于jieba库实现的分词处理操作,主要使用到的知识点:
// 配置cppjieba所需的各种字典路径,这些字典是jieba分词引擎正常工作的必要文件
// 它们包含了词的定义、HMM模型、用户自定义词典、IDF信息和停用词表
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";
// 提供了基于jieba分词库的字符串分词功能
// 该类使用静态成员变量cppjieba::Jieba实现了jieba分词器的初始化和配置
class JieBaUtil
{
private:
// jieba分词器的静态成员变量,用于全局初始化和配置
static cppjieba::Jieba jieba;
public:
/// @brief 对给定的字符串进行分词
/// @param src 需要进行分词处理的原始字符串
/// @param out 分词结果将被存储到这个vector中
static void CutString(const std::string &src, std::vector<std::string> *out)
{
jieba.CutForSearch(src, *out);
}
};
// 初始化jieba分词器,配置字典路径
cppjieba::Jieba JieBaUtil::jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH);
}
以上就是工具模块util.hpp的基本能实现。
大概实现思路:
首先在项目文件夹中创建一个data目录,其中包含input文件夹和raw_html文件夹:
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cpp-httplib-0.7.15/
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cppjieba/
drwxrwxr-x 4 server server 4096 Jul 28 20:21 data/
lrwxrwxrwx 1 server server 16 Jul 28 18:07 dict -> ./cppjieba/dict//
lrwxrwxrwx 1 server server 28 Jul 28 18:07 inc -> ./cppjieba/include/cppjieba//
server@:~/boost_searcher$ tree data
data
├── input
└── raw_html
└── raw.txt
2 directories, 1 file
input文件夹中存放boost_1_85_0/doc/html目录下的html⽂件,使用cp命令进行拷贝。
drwxr-xr-x 8 server server 4096 Apr 12 03:26 boost_1_85_0/
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cpp-httplib-0.7.15/
drwxrwxr-x 6 server server 4096 Jul 28 17:46 cppjieba/
drwxrwxr-x 4 server server 4096 Jul 28 20:21 data/
lrwxrwxrwx 1 server server 16 Jul 28 18:07 dict -> ./cppjieba/dict//
lrwxrwxrwx 1 server server 28 Jul 28 18:07 inc -> ./cppjieba/include/cppjieba//
server@:~/boost__searcher$ cp -rf ./boost_1_85_0/doc/html/ ./data/input/
raw_html文件夹中创建一个raw.txt用于存放解析好的文件内容。
公共所需的头文件、文件路径、struct的定义等
#include
#include
#include
#include
#include "util.hpp"
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;
为项目提供遍历文件路径操作,主要使用到的知识点:
主要实现思路是:
/// @brief 遍历指定路径下的文件,找出所有.html文件并保存到指定位置
/// @param src_path 需要遍历的目录路径
/// @param files_list 用于存储找到的.html文件路径的vector
bool EnumFile(const std::string &src_path, std::vector<std::string> *files_list)
{
// 使用boost::filesystem库简化文件操作
namespace fs = boost::filesystem; // 设置命名空间,这样更好,避免命名污染
// 将输入的字符串路径转换为path对象
fs::path root_path(src_path);
// std::cout << "root_path: " << root_path << std::endl;
// 判断路径是否存在
if (!fs::exists(root_path))
{
// std::cerr << "exists error" << std::endl;
LOG(DEBUG, "exists error");
return false;
}
// 定义递归目录迭代器,用于遍历指定路径及其子目录(空的迭代器,用来判断递归是否结束)
fs::recursive_directory_iterator end;
for (fs::recursive_directory_iterator iter(root_path); iter != end; iter++)
{
// 如果当前项不是普通文件,则跳过(html文件是普通文件,这可以忽略目录和其他特殊文件类型)
if (!fs::is_regular_file(*iter))
{
continue;
}
// 如果文件的扩展名不是.html,则跳过(这确保了只收集.html文件的路径)
if (iter->path().extension() != ".html")
{
continue;
}
// 将找到的.html文件的路径添加到文件列表中(写入到files_list)
files_list->push_back(iter->path().string());
// std::cout << "Dbug: " << iter->path().string() << std::endl;
}
return true;
}
为项目提供遍历并解析文件操作,主要使用到的知识点:
主要实现思路:
/// @brief 解析HTML文件列表,提取文档信息
/// @param files_list HTML文件列表,每个元素是一个文件路径
/// @param results 用于存储解析结果的vector指针,每个结果包含文档的标题、内容和URL
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results)
{
// 遍历文件列表中的每个文件
for (const std::string &file : files_list) // 使用&增加效率
{
// 读取文件内容到字符串result中
std::string result; // 缓冲区
// 如果读取失败,则跳过当前文件
if (!ns_util::FileUtil::ReadFile(file, &result))
continue;
DocInfo_t doc; // 初始化DocInfo_t对象,用于存储当前文件的文档信息
// 解析出title
if (!ParseTitle(result, &doc.title))
continue;
// 解析出content
if (!ParseContent(result, &doc.content))
continue;
// 解析出url
if (!ParseUrl(file, &doc.url))
continue;
// 将提取到的文档信息移动到结果向量中
// 这里使用std::move来避免复制,提高效率
results->push_back(std::move(doc)); // move将一个左值转换为右值引用
// results->push_back(doc); // 遗留Bug:doc直接push_back会发生拷贝,数据量过大效率低下
}
return true;
}
子函数解析HTML文件中的标题内容函数的实现
主要实现思路是:
/// @brief 解析HTML文件中的标题
/// @param file 输入的HTML文件内容
/// @param title 用于存储提取的标题的指针(DocInfo_t中的title)
static bool ParseTitle(const std::string &file, std::string *title)
{
// 寻找""标签的开始位置
std::size_t begin = file.find("" );
// 如果找不到""标签,则返回false
if (begin == std::string::npos)
{
return false;
}
// 将开始位置移动到""标签之后,准备提取标题内容
begin += std::string("" ).size(); // 注意控制起始位置
// 寻找""标签的结束位置
std::size_t end = file.find("");
// 如果找不到""标签,则返回false
if (end == std::string::npos)
{
return false;
}
// 检查开始位置是否在结束位置之前,防止提取错误的部分
if (begin > end)
{
return false;
}
// 从开始到结束位置提取字符串,得到标题内容
*title = file.substr(begin, end - begin);
return true;
}
子函数解析HTML文件内容,提取纯文本内容(使用到了小状态机)的实现
主要实现思路:
4. 设计状态机的两个状态:LABLE 表示当前正在解析一个 HTML 标签,CONTENT 表示当前正在解析文本内容。
5. 初始状态是 LABLE,意味着函数开始时假定输入的第一个字符是 HTML 标签的开始。
6. 定义规则如何转移:当状态为 LABLE 且遇到 ‘>’ 时,状态转移到 CONTENT,当状态为 CONTENT 且遇到 ‘<’ 时,状态转移到 LABLE。
7. 当状态为 CONTENT 时,如果遇到的不是 ‘<’ 字符,则将该字符添加到输出内容中。此外,如果遇到换行符 ‘\n’,则将其替换为空格 ’ '。
8. 知道内容读取完后就结束了。
/// @brief 解析HTML文件内容,提取纯文本内容(使用到了小状态机)
/// @param file 输入的HTML文件内容
/// @param content 输出的纯文本内容(DocInfo_t中的content)
static bool ParseContent(const std::string &file, std::string *content)
{
// 解析该内容获取所需要的内容,也就是去标签,可以使用一个简易的”状态机“来解决该问题
// 定义两种状态:LABLE表示处于标签中,CONTENT表示处于内容中
enum status
{
LABLE,
CONTENT
};
// 初始化状态为LABLE,即初始时认为处于标签状态
enum status st = LABLE;
// 遍历输入的HTML文件每个字符
for (char ch : file)
{
switch (st)
{
// 当前处于标签状态,找到结束标签符号">",切换到内容状态
case LABLE:
if (ch == '>')
st = CONTENT;
break;
// 当前处于内容状态,找到开始标签符号"<",切换到标签状态
case CONTENT:
if (ch == '<')
st = LABLE;
else
{
// 如果遇到换行符,替换为空格,以避免内容中出现多余的换行
if (ch == '\n')
ch = ' ';
// 将当前字符添加到输出内容中
content->push_back(ch);
}
break;
default:
break;
}
}
return true;
}
子函数解析文件路径以生成相应的URL的实现
主要实现思路:
9. 定义固定url_head。
10. 从文件路径动态提取用于拼接的url_end。
11. 返回 url_head + url_end的结果即可。
/// @brief 解析文件路径以生成相应的URL
/// @param file_path 文件路径,包含在URL中的相对路径部分
/// @param content 指向一个字符串的指针,该字符串将存储生成的URL(DocInfo_t中的url)
static bool ParseUrl(const std::string &file_path, std::string *url)
{
// file = data/input/BOOST_YAP_U_1_3_46_8_2_7_2.html
// 定义URL的头部,这是一个固定的字符串,包含了基本的URL信息
std::string url_head = "https://www.boost.org/doc/libs/1_85_0/doc/html/";
// 从文件路径中提取出相对路径部分,这一部分将被加到URL头部
std::string url_end = file_path.substr(src_path.size());
// 将URL头部和相对路径拼接成完整的URL
*url = url_head + url_end;
// std::cout << "file_path: " << file_path << std::endl;
// std::cout << "url_head: " << url_head << std::endl;
// std::cout << "url_end: " << url_end << std::endl;
// std::cout << "url: " << *url << std::endl;
return true;
}
为项目提供遍历文件路径操作,主要使用到的知识点:
主要实现思路:
/// @brief 保存HTML文档信息到文件,以特定的分隔符隔开,并以二进制形式写入文件
/// @param results 文档信息的集合,包含标题、内容和URL
/// @param output 文件输出路径
bool SaveHtml(const std::vector<DocInfo_t> &results, const std::string &output)
{
// 以二进制模式打开输出文件流
std::ofstream out(output, std::ios_base::out | std::ios_base::binary); // 二进制模式写入文件,数据是以原始字节序列的形式保存的,编译器或文件系统不会对写入的数据进行额外的解释或转换。
if (!out.is_open())
{
// std::cerr << "open " << output << " error" << std::endl;
LOG(DEBUG, "Open error " + output);
return false;
}
// 遍历结果集,对每个文档信息进行处理并写入文件
for (const DocInfo_t &item : results)
{
// 定义分隔符,用于区分文档的标题、内容和URL
// 每一个HTML网页内容使用'\3'分隔,使用'\n'分割每个HTML网页
std::string sep = "\3";
// 构建包含文档信息的字符串
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;
}
主要实现思路:
int main()
{
// 初始化文件列表,用于存储遍历得到的HTML文件路径
std::vector<std::string> files_list;
// 遍历指定路径下的HTML文件,将文件路径添加到文件列表中
if (!EnumFile(src_path, &files_list))
{
// std::cerr << "EnumFile error" << std::endl;
LOG(DEBUG, "EnumFile error");
return 1;
}
LOG(NORMAL, "文件路径保存成功...");
// 初始化文档信息列表,用于存储解析后的文档信息
std::vector<DocInfo_t> results;
// 解析文件列表中的HTML文件,将解析结果存储到文档信息列表中
if (!ParseHtml(files_list, &results))
{
// std::cerr << "ParseHtml error" << std::endl;
LOG(DEBUG, "ParseHtml error");
return 2;
}
LOG(NORMAL, "文件解析成功...");
// 将解析结果保存到指定输出文件
if (!SaveHtml(results, output)) // SaveHtml函数把解析完成的results文件写入到output,按照'\3'作为每个文档的分隔符
{
// std::cerr << "SaveHtml error" << std::endl;
LOG(DEBUG, "SaveHtml error");
return 2;
}
LOG(NORMAL, "文件写入完成...");
return 0;
}
针对头文件的引入和命名空间的定义:
#pragma once
#include
#include
#include
#include
#include
#include
#include "util.hpp"
namespace ns_index
{
};
以下功能代码都是属于ns_index命名空间的代码。
// 正排索引元素结构体,用于存储一个文档的数据内容
struct DocInfo
{
std::string title; // 文档标题
std::string content; // 文档内容
std::string url; // 文档在官网中的url
uint64_t doc_id; // 方便在构建倒排拉链时使用
};
// 倒排索引元素结构体,用于存储文档中某个词出现的信息
struct InvertedElem
{
uint64_t doc_id;
std::string word; // 关键词
int weight; // 权重
};
// 倒排列表,即包含所有文档中某个词出现信息的集合(typedef倒排拉链为InvertedList)
typedef std::vector<InvertedElem> InvertedList;
// 索引类
class Index
{
private:
// 正排索引,存储所有文档的信息,通过文档编号(doc_id)可以快速定位到文档(vector的下标就是doc_id)
std::vector<DocInfo> forward_index;
// 倒排索引,存储所有词的信息,通过词可以快速找到包含该词的文档列表(关键字和倒排拉链映射关系)
std::unordered_map<std::string, InvertedList> inverted_index;
};
以下代码都是在Index类中的!!!
为项目提供创建单例和锁的操作,主要使用到的知识点:
主要实现思路:
private:
Index() {} // 私有化构造函数,防止外部直接实例化对象
Index(const Index &) = delete; // 删除拷贝构造函数,防止对象被拷贝
Index &operator=(const Index &) = delete; // 删除赋值运算符,防止对象被赋值
static Index *instance; // 单例模式所保存的唯一实例指针
static std::mutex mtx; // 用于线程安全的互斥锁
public:
~Index() {} // 析构函数,确保单例模式下的资源正确释放
public:
/// @brief 获取Index类的单例实例
/// @return Index类的单例实例指针
static Index *GetInstance()
{
// 该函数保证在整个程序中只返回一个Index类的实例,实现了单例模式
// 使用互斥锁来确保多线程环境下的线程安全,防止多个线程同时创建实例
if (nullptr == instance)
{
mtx.lock();
if (nullptr == instance)
{
instance = new Index();
}
mtx.unlock();
}
return instance;
}
// 在类外部定义静态成员变量
Index *Index::instance = nullptr;
std::mutex ns_index::Index::mtx;
主要实现思路:
/// @brief 根据已有数据,构建正排索引和倒排索引
/// @param input 数据文件路径
/// @return bool 构建是否成功的标志
bool BuildIndex(const std::string &input)
{
std::ifstream in(input, std::ios_base::in | std::ios_base::binary); // 打开文件
if (!in.is_open()) // 判断是否打开
{
// std::cerr << input << " open error" << std::endl;
LOG(DEBUG, input + " open error");
return false;
}
// 开始以一行为单位读取(也就是以一个HTML文件来读取)
std::string line;
int cont = 0;
while (std::getline(in, line))
{
// 正排索引
DocInfo *doc = BuildForwardIndex(line);
// 如果失败直接跳过该文件,倒排索引也不构建
if (doc == nullptr)
{
// std::cerr << "build " << line << " error" << std::endl; // Debug
LOG(DEBUG, "build error " + line);
continue;
}
// 倒排索引
BuildInvertedIndex(*doc);
++cont;
if (cont % 100 == 0)
{
// std::cout << "已构建: " << cont << std::endl;
LOG(NORMAL, "当前已建立: " + std::to_string(cont));
}
}
return true;
}
};
主要实现思路:
private:
/// @brief 构建forward_index正排索引(解析输入的HTML数据行,提取标题、内容和URL,并创建一个DocInfo对象,最后将该对象添加到forward_index中)
/// @param line 读取到的一个HTML数据
/// @return 构建好的DocInfo指针,若构建失败返回nullptr
DocInfo *BuildForwardIndex(const std::string &line)
{
// 定义字段分隔符
const std::string sep = "\3";
// 存放分割好的结果
std::vector<std::string> results;
// 使用分隔符分割输入的HTML数据行
ns_util::StringUtil::Split(line, &results, sep);
// 检查分割结果是否符合预期(包含3个字段)
if (results.size() != 3)
{
// std::cerr << "Split error" << std::endl;
LOG(WARNING, "Split error");
return nullptr;
}
// 创建并初始化DocInfo对象
DocInfo doc;
doc.title = results[0]; // results第一位对应title
doc.content = results[1]; // results第二位对应content
doc.url = results[2]; // results第三位对应url
doc.doc_id = forward_index.size(); // 提前将doc_id填入,避免push_back之后填入
// 将DocInfo对象添加到forward_index中(插入到forward_index正排索引的vector中)
forward_index.push_back(std::move(doc)); // move提升效率
return &(forward_index.back());
}
主要实现思路:
/// @brief 构建inverted_index倒排索引(此函数接收一个DocInfo对象,对其中的标题和内容进行分词,然后根据分词结果构建inverted_index倒排索引)
/// @param doc 构建forward_index正排索引返回的DocInfo结构体
bool BuildInvertedIndex(const DocInfo &doc)
{
// 定义一个结构体用于存储单词在标题和内容中的词频
struct word_cnt
{
int title_cnt;
int content_cnt;
word_cnt() : title_cnt(0), content_cnt(0) {} // 初始化
};
// 创建一个map用于存储单词及其对应的word_cnt对象
std::unordered_map<std::string, word_cnt> word_cnt;
// 对标题进行分词,并统计词频(对title进行分词)
std::vector<std::string> title_word;
ns_util::JieBaUtil::CutString(doc.title, &title_word);
// title词频统计(不加引用,避免to_lower修改源字符串)
for (std::string it : title_word)
{
// 全部转换为小写
boost::to_lower(it);
word_cnt[it].title_cnt++;
}
// 对content进行分词
std::vector<std::string> content_word;
ns_util::JieBaUtil::CutString(doc.content, &content_word);
// content词频统计(不加引用,避免to_lower修改源字符串)
for (std::string it : content_word)
{
// 全部转换为小写
boost::to_lower(it);
word_cnt[it].content_cnt++;
}
// 定义相关性计算的权重系数
const int x = 10;
const int y = 1;
// 遍历word_cnt map,为每个单词创建一个InvertedElem对象
for (const auto &it : word_cnt)
{
InvertedElem elem;
elem.doc_id = doc.doc_id;
elem.word = it.first;
// 根据标题和内容的词频计算相关性权重
elem.weight = x * it.second.title_cnt + y * it.second.content_cnt; // 现在暂时就这样计算
// 获取单词对应的inverted_list(如果这个关键词在inverted_index中不存在,则创建一个空的inverted_list)
InvertedList &inverted_list = inverted_index[it.first];
// 将elem添加到列表中
inverted_list.push_back(elem);
}
return true;
}
主要实现思路:
public:
///@brief 根据文档ID获取文档信息(在正排索引中查找)
///@param doc_id 文档ID
///@return DocInfo* 文档信息指针,如果ID超出范围则返回nullptr
DocInfo *GetForwardIndex(const uint64_t doc_id)
{
// 判断doc_id合法性(下标就是doc_id)
if (doc_id >= forward_index.size())
{
// std::cerr << "doc_id out range, error" << std::endl;
LOG(DEBUG, "doc_id out range, error");
return nullptr;
}
// 返回DocInfo的指针
return &forward_index[doc_id];
}
主要实现思路:
/// @brief 根据关键词获取倒排列表
/// @param word 关键词(小写)
/// @return 倒排列表指针,如果关键词不存在则返回nullptr
InvertedList *GetInvertedList(const std::string &word)
{
// 查找word关键词
std::unordered_map<std::string, ns_index::InvertedList>::iterator iter = inverted_index.find(word);
// 找不到
if (iter == inverted_index.end())
{
// std::cerr << word << " have no inverted index" << std::endl;
LOG(DEBUG, word + " have no inverted index");
return nullptr;
}
// 返回InvertedList的指针
return &(iter->second);
}
针对头文件的引入和命名空间的定义:
#pragma once
#include "index.hpp"
#include
#include
namespace ns_searcher
{
};
以下功能代码都是属于ns_searcher命名空间的代码。
// 定义一个结构体,用于存储文档的重复关键词及其相关信息
struct repetition_index
{
uint64_t doc_id; // doc_id
std::vector<std::string> words; // 关键词集合(word关键词vector)
int weight; // 针对重复关键词的权重
};
class Searcher // 搜索引擎类
{
private:
ns_index::Index *index; // 指向索引对象的指针
public:
Searcher() {};
~Searcher() {};
};
以下代码都是在Searcher类中的!!!
public:
/// @brief 初始化,构造索引index单例
/// @param input 已经由parser模块提前处理好的文档路径
void InitSearcher(const std::string &input)
{
// 获取索引单例
index = ns_index::Index::GetInstance();
// std::cout << "单例获取完成..." << std::endl;
LOG(NORMAL, "单例获取完成...");
// 调用索引类建立索引库
index->BuildIndex(input);
// std::cout << "索引建立完成..." << std::endl;
LOG(NORMAL, "索引建立完成...");
}
主要实现思路:
/// @brief 执行搜索操作
/// @param query 查询字符串
/// @param json_string 用于存储搜索结果的JSON字符串的指针
void Search(const std::string &query, std::string *json_string)
{
// 使用分词工具对查询进行分词,使用jieba进行分词
std::vector<std::string> words;
ns_util::JieBaUtil::CutString(query, &words);
// 处理重复关键词(使用哈希表去重)
std::unordered_map<uint64_t, repetition_index> remove_duplicates;
// 进行搜索(倒排索引),统计搜索到的结果(在index中搜索,注意忽略大小写)
std::vector<repetition_index> inverted_list_all;
for (std::string word : words)
{
boost::to_lower(word); // 转换成小写
// 根据分词获取倒排列表
ns_index::InvertedList *inlist = index->GetInvertedList(word);
if (nullptr == inlist) // 查找不到的情况
continue;
// 根据分词获取倒排列表(优化重复关键字,会在搜索结果显示多个重复结果)
for (auto &iter : *inlist)
{
auto &it = remove_duplicates[iter.doc_id];
it.doc_id = iter.doc_id;
it.weight += iter.weight;
it.words.push_back(iter.word);
}
}
// 将去重后的结果合并到总的搜索结果中
for (const auto &iter : remove_duplicates)
{
inverted_list_all.push_back(std::move(iter.second));
}
// 对搜索结果按照权重进行降序排序(按照相关性weight降序排列,使用sort排序)
std::sort(inverted_list_all.begin(), inverted_list_all.end(), [](const repetition_index &e1, const repetition_index &e2)
{ return e1.weight > e2.weight; });
// 构建返回内容(根据正排索引),根据查找的内容构建json串,使用jsoncpp
Json::Value root;
for (auto &iter : inverted_list_all)
{
// 根据文档ID获取文档信息
ns_index::DocInfo *doc = index->GetForwardIndex(iter.doc_id);
if (nullptr == doc)
{
continue;
}
// 构建单个搜索结果的JSON对象
Json::Value elem;
elem["title"] = doc->title;
elem["desc"] = GetDesc(doc->content, iter.words[0]);
elem["url"] = doc->url;
root.append(elem);
}
// 序列化到json_string中
Json::StyledWriter writer;
// Json::FastWriter writer;
*json_string = writer.write(root);
}
主要实现思路:
private:
/// @brief 获取关键词的摘要
/// @param content 文本内容
/// @param word 关键词
/// @return 摘要字符串
std::string GetDesc(const std::string &content, const std::string &word)
{
// 定义摘要的前后长度(以word出现第一次位置开始向前50字节向后100字节,提取摘要)
std::size_t forward = 50;
std::size_t backwards = 100;
std::size_t begin = 0;
std::size_t end = content.size() - 1;
// 使用std库的search函数进行搜索,过滤掉大小写区别
auto iter = std::search(content.begin(), content.end(), word.begin(), word.end(), [](char x, char y)
{ return (std::tolower(x) == std::tolower(y)); });
// 如果找到了关键词
if (iter != content.end())
{
std::size_t cnt = std::distance(content.begin(), iter);
// 调整摘要的起始和结束位置
if (begin + forward < cnt)
begin = cnt - forward;
if (cnt + backwards < end)
end = cnt + backwards;
// 截取摘要并添加省略号
if (begin > end)
{
return "error: begin > end";
}
std::string ret = content.substr(begin, end - begin);
ret += "..."; // 为摘要最后添加一个"..."
return ret;
}
return "error: iter == content.end()";
}
在网络模块中主要使用了cpp-httplib-0.7.15构成的http服务,可以快速搭建一个HTTP服务器,简化了我的代码。
其基本使用和实现如下,在后续会将其改成自己手写一个http服务器的。
#include "searcher.hpp"
#include "cpp-httplib-0.7.15/httplib.h"
// 定义静态常量,用于指定Web服务器的根目录和原始HTML数据文件的位置
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
httplib::Server svr;
// 设置服务器的根目录,用于处理静态文件请求
svr.set_base_dir(root_path.c_str());
// 设置处理函数,处理搜索请求
svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rep)
{
// 检查请求中是否包含搜索关键字
if(!req.has_param("word"))
{
// 如果没有提供搜索关键字,返回提示信息
rep.set_content("请输入搜索词!", "text/plain; charset=UTF-8");
return ;
}
// 提取搜索关键字
std::string word = req.get_param_value("word");
//std::cerr << "用户正在搜索:" << word << std::endl;
LOG(NORMAL, "用户正在搜索:"+ word);
//进行搜索
std::string json_string;
search.Search(word, &json_string);
// 设置响应内容为搜索结果的JSON字符串,并指定内容类型为JSON
rep.set_content(json_string, "application/json"); });
// 建立连接监听
LOG(NORMAL, "http监听启动...");
// 启动HTTP服务器,监听指定的IP和端口
svr.listen("0.0.0.0", 6550);
return 0;
}
针对本项目还可以扩展的方向:
感谢各位看到最后,在本项目中如有问题,欢迎各位大佬指出,我会作出修改的( •̀ ω •́ )y