基于Boost库实现的站内搜索引擎

基于Boost库实现的搜索引擎

  • 项目实现相关背景
  • 搜索引擎相关宏观原理
  • 正排索引和倒排索引
    • 正排索引
    • 倒排索引
      • 模拟一次查找大概流程
  • 项目技术栈和开发环境配置
    • 项目所使用到的技术栈
    • 开发环境的配置
      • VSCode和插件Remote - SSH的安装
      • Boost库的安装
      • Jsoncpp库的安装
      • cpp-httplib库的下载
      • cppjieba库的下载
        • cppjieba库的补充
        • cppjieba库的使用(使用软连接)
  • 项目实际开发
    • 编写日志模块(log.hpp)
    • 编写工具模块(util.hpp)
      • 读取文件所需功能实现
      • 字符串处理所需功能实现
      • 基于jieba库实现的分词所需功能实现
    • 编写解析模块(Parser.cc)
      • 遍历所有文件获取指定文件路径所需功能实现
      • 遍历并解析文件内容
        • 解析HTML文件列表,提取文档信息函数
        • 三个子函数的实现逻辑
      • 以特定格式将解析好的内容保存到文件中
      • Parser.cc模块的main函数的实现
    • 编写索引模块(index.hpp)
      • 所需结构体的定义和typedef
      • 创建Index类以及普通参数定义
      • 设计单例以及锁
      • 构建索引
      • 构建索引的两个子函数
        • 构建正排索引
        • 构建倒排索引
      • 编写查找函数
        • 根据文档ID获取文档信息
        • 根据关键词获取倒排列表
    • 编写搜索模块(searcher.hpp)
      • 所需结构体的定义
      • 创建Searcher类以及普通参数定义
      • 编写初始化模块
      • 编写搜索模块
      • 编写获取关键词摘要子函数
    • 编写网络模块(http_server.cc)
  • 项目完结

这是本项目 Gitee链接,如果想要完整源码,还是建议直接去Gitee。

项目实现相关背景

  • 成熟好用的搜索引擎有很多,个人实现一个完整的搜索引擎非常具有挑战性。
  • 所以我们自己实现的搜索引擎主要是站内搜索(搜索数据垂直,数据量小,提升我们的编码技能和实际项目经验)。
  • 正好Boost库官网没有搜索功能,所以就基于Boost官方文档来建立这个项目,同时也使用他的相关函数进行开发

搜索引擎相关宏观原理

基于Boost库实现的站内搜索引擎_第1张图片

正排索引和倒排索引

首先来一个实例:

  • 文档1:他来到了网易大厦
  • 文档2:我来到北京网易了

正排索引

其本质就是通过文档ID找到文档内容
模拟一下正排索引:

文档ID(doc_id) 文档内容(content)
1 他来到了网易大厦
2 我来到北京网易了

总结:

  • 按照文档的ID来存储文档的信息。每个文档都有一个唯一的ID,正排索引就是按照这些ID来组织文档的内容。
  • 简单直观,直接从文档ID出发,查找文档的内容。
  • 不便于高效查询,如果要找出包含特定词语的所有文档,需要遍历所有文档。

倒排索引

其本质就是根据文档内容,进行分词处理,整理出不同并且不重复的各个关键字,然后对应联系到对应文档ID
模拟一下倒排索引:

关键字(word) 文档ID(doc_id)&&权重(weight)
1
来到 1, 2
1, 2
网易 1, 2
大厦 1
2
北京 2

总结:

  • 倒排索引是一种更为高效的索引结构,它颠倒了文档与词语之间的关系。
  • 词语到文档映射,每个词语对应一个文档列表,这些文档都包含该词语。
  • 高效查询:通过词语可以直接查找到含有该词语的所有文档。
  • 节省空间:通常情况下,文档数量远多于词语数量,因此倒排索引占用的空间比正排索引少。

模拟一次查找大概流程

基于Boost库实现的站内搜索引擎_第2张图片

项目技术栈和开发环境配置

项目所使用到的技术栈

后端所使用到的技术栈是:C/C++C++11STLBoost标准库Jsoncpp标准库cppjieba标准库cpp-httplib-0.7.15(lambda表达式)

其中前端网页所使用到的技术栈是:html5cssjsjQueryAjax(本人并没有专注学习前端相关技术栈,本项目前端代码是粘贴的`(>﹏<)′ )。

开发环境的配置

基本环境是阿里云 2核(vCPU) 2GiB Ubuntu 22.04 64位云服务器。
其中编译器版本是:gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04)(云服务器默认配置)。

VSCode和插件Remote - SSH的安装

  • VSCode的安装在全网有很多教程,这里就不做过多介绍只需要安装上就OK,不需要额外配置。
  • 安装完成VSCode后,如有需要可以安装Chinese (Simplified) (简体中文) Language Pack for Visual Studio Code中文插件。
  • 安装Remote - SSH插件,方便连接云服务器,在连接好服务器后就可以直接开始代码的编写了(网上有很多使用教程,在这里就不做过多介绍了)。
  • 这里VSCode上所需要的配置就完成了,接下来上Xshell(因为感觉他更方便,所以就在Xshell上操作,其实VSCode上的终端也可以的)。

Boost库的安装

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,然后输入所设置的密码即可安装。

Jsoncpp库的安装

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,然后输入所设置的密码即可安装。

cpp-httplib库的下载

这个库需要我们自行下载导入到云服务器中,在GitHub或Gitee或GitCode上都可以搜索到,搜索名称就是cpp-httplib(本项目使用的cpp-httplib-0.7.15版本)。

注:从这里开始就需要在家目录或者是自己可以找到的地方创建一个项目文件夹了,我这里命名就是boost_searcher,以下操作都是在boost_searcher目录下进行的!!!

  • 这里演示就从GitCode上下载了
    基于Boost库实现的站内搜索引擎_第3张图片

  • 进入对应页面
    基于Boost库实现的站内搜索引擎_第4张图片

  • 下载对应版本
    基于Boost库实现的站内搜索引擎_第5张图片
    在这里感谢各位开源作者 (/ω\)

  • 在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)

cppjieba库的下载

这个库需要我们自行下载导入到云服务器中,在GitHub或Gitee或GitCode上都可以搜索到,搜索名称就是cppjieba

  • 这里演示就从GitCode上下载了
    基于Boost库实现的站内搜索引擎_第6张图片
  • 下载操作还是与之上面一样,导入到云服务器中解压即可。
cppjieba库的补充
  1. 首先查看使用指令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
    
  2. 导入limonp文件夹(如果有就不用了)

     server@:~/boost_searcher$ cp -rf ./cppjieba/deps/limonp/ ./cppjieba/include/cppjieba/
    
  3. 导入之后查看是否导入成功即可

     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,这样就是导入成功了。

cppjieba库的使用(使用软连接)
  1. 软连接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//
    
  2. 软连接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. & 	   :对应输入和输出参数

下载数据源:

  • 数据来源:Boost库下载最新版即可(在主页点击Download下载就是最新版,下载适用版本即可)。
  • 还是使用lrzsz相关指令将下载好的压缩包传入云服务器,然后使用对应解压缩指令进行解压即可。
  • 由于我们刚开始开发,以及云服务器配置原因,就只使用boost_1_85_0/doc/html目录下的html⽂件,⽤它们来进⾏建⽴索引,完成本项目。

编写日志模块(log.hpp)

#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;
}

在日志模块中使用到了宏定义、预处理器定义的宏等技术…

编写工具模块(util.hpp)

针对头文件的引入和命名空间的定义:

#pragma once

#include 
#include 
#include 
#include 

#include "inc/Jieba.hpp"
#include "log.hpp"

namespace ns_util
{
};

以下功能代码都是属于ns_util命名空间代码。

读取文件所需功能实现

为项目提供文件读取操作,主要使用到的知识点:

  1. 静态成员函数:可以在不创建类实例的情况下被调用。
  2. 文件输入流:使用 std::ifstream 类型的对象 in 来读取文件内容。
  3. 文件流打开模式:std::ios::in指定文件应以输入模式打开。
  4. 文件流状态检查:in.is_open() 函数用于检查文件流是否已经成功打开。
        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;
        }
    };

字符串处理所需功能实现

为项目提供字符串处理操作,主要使用到的知识点:

  1. boost::split: Boost 库中的算法函数,用于将字符串按照指定的分隔符进行分割。
  2. boost::is_any_of: Boost 库中的谓词,用于匹配 sep(分割符)中的任何一个字符作为分隔符。
  3. boost::token_compress_on: Boost 库中的标志,表示是否合并连续的分隔符产生的空白字段(该处的意思是表示 boost::split 应该跳过这些额外的分隔符,只将它们视为一个分隔符。这样可以避免在结果中产生空字符串)。
    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库实现的分词所需功能实现

为项目提供基于jieba库实现的分词处理操作,主要使用到的知识点:

  1. CutForSearch:它是cppjieba提供的一个分词方法,它适用于搜索引擎的场景,能够提供更高效、更准确的分词结果。
    // 配置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的基本能实现。

编写解析模块(Parser.cc)

大概实现思路:

  1. 首先将需要遍历的文件目录下的所有文件,将文件路径保存该到std::vector< std::string > files_list中,方便后续的遍历操作。
  2. 开始遍历文件(也就是遍历files_list),同时解析每一个文件,分为“标题”、“内容”、“URL”,三个部分,可以创建一个struct,其中包含title、content、url,再将所有解析到的文件保存该到std::vector< struct > results中。
  3. 讲解析好的文件全部写入到指定raw.txt文件中,方便后续建立索引。

首先在项目文件夹中创建一个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;

遍历所有文件获取指定文件路径所需功能实现

为项目提供遍历文件路径操作,主要使用到的知识点:

  1. 命名空间使用:这里使用了命名空间别名来简化boost::filesystem的使用,避免每次调用库中的函数或类时都要加上完整的命名空间前缀。
  2. 路径操作: fs::path类提供了丰富的路径操作功能,包括路径的拼接、分割、规范化以及获取扩展名等。
  3. Boost.Filesystem库:在这个函数中,使用了boost::filesystem来遍历目录、检查文件类型和获取文件路径(boost::filesystem是一个强大的文件系统操作库,它提供了高级的文件和目录操作接口,如路径处理、文件属性获取、文件和目录的创建与删除等)。
  4. 递归目录迭代器:fs::recursive_directory_iterator这个迭代器用于遍历目录树中的所有文件和子目录(它是一种高效的遍历方式,可以自动处理递归逻辑,使得遍历整个目录结构变得简单)。
  5. 检查一个路径是否存在:boost::filesystem::exists函数。
  6. 判断一个路径是否指向一个常规文件(非目录、符号链接等):boost::filesystem::is_regular_file函数。
  7. 获取路径的扩展名部分:extension函数,扩展名是从最后一个点号开始到字符串末尾的部分,不包括点号。

主要实现思路是:

  1. 使用了boost库中的path对象。
  2. 首先判断传入路径是否为合法路径。
  3. 使用了boost库中的recursive_directory_iterator迭代器,来遍历该路径其下的子路径。
  4. is_regular_file和extension函数判断遍历到的路径是否合法
  5. 如果符合要求就将路径添加到容器当中
/// @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;
}

遍历并解析文件内容

为项目提供遍历并解析文件操作,主要使用到的知识点:

  1. string的find函数:在字符串中查找子串或字符首次出现的位置。如果找到了子串或字符,则返回该子串或字符的起始位置(从 0 开始计数);如果没有找到,则返回 std::string::npos
  2. string的substr函数:从字符串中提取一个子串。
  3. std::string::npos:npos 是 std::string 类的一个静态常量,表示未找到子串或字符时的返回值。它通常被定义为 std::string::size_type(-1),即最大可能的 size_t 值减一。
  4. 小型状态机的应用:状态机是一种抽象的概念,用于描述一个系统在不同状态之间转换的行为(在计算机科学中,状态机经常用于解析文本、实现有限自动机、设计游戏逻辑等场景。状态机由一组状态、一个初始状态、一系列状态间的转移规则以及可能的终止状态组成)。
解析HTML文件列表,提取文档信息函数

主要实现思路:

  1. 直接范围for遍历HTML文件列表
  2. 调用ReadFile函数读取文件内容,写入到result缓冲区。
  3. 定义DocInfo临时变量。
  4. 使用ParseTitle函数提取title,保存到DocInfo临时变量中。
  5. 分别调用ParseContent和ParseUrl函数提取所需关键字,写入到DocInfo临时变量中即可。
  6. 将DocInfo临时变量push_back到results变量中返回即可,为了提高效率可以使用std::move函数。
/// @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文件中的标题内容函数的实现

主要实现思路是:

  1. 首先查找“< title >”标签的开始位置,找到之后计算到标签结尾位置,当作截取开始位置。
  2. 再查找"< /title >"标签的结束位置,找到之后当作标题结束位置。
  3. 判断位置合法性,使用substr截取标题内容。
/// @brief 解析HTML文件中的标题
/// @param file 输入的HTML文件内容
/// @param title 用于存储提取的标题的指针(DocInfo_t中的title)
static bool ParseTitle(const std::string &file, std::string *title)
{
    // 寻找""标签的开始位置</span>
    std<span class="token double-colon punctuation">::</span>size_t begin <span class="token operator">=</span> file<span class="token punctuation">.</span><span class="token function">find</span><span class="token punctuation">(</span><span class="token string">"<title>"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token comment">// 如果找不到"<title>"标签,则返回false</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span>begin <span class="token operator">==</span> std<span class="token double-colon punctuation">::</span>string<span class="token double-colon punctuation">::</span>npos<span class="token punctuation">)</span>
    <span class="token punctuation">{</span>
        <span class="token keyword">return</span> <span class="token boolean">false</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>
    <span class="token comment">// 将开始位置移动到"<title>"标签之后,准备提取标题内容</span>
    begin <span class="token operator">+=</span> std<span class="token double-colon punctuation">::</span><span class="token function">string</span><span class="token punctuation">(</span><span class="token string">"<title>"</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">size</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// 注意控制起始位置</span>

    <span class="token comment">// 寻找""标签的结束位置
    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;
}

以特定格式将解析好的内容保存到文件中

为项目提供遍历文件路径操作,主要使用到的知识点:

  1. 标准文件流:使用std::ofstream以二进制模式打开文件,允许直接写入字节流而无需转换,这对于写入二进制数据或者避免文本模式下可能发生的字符转换(如换行符转换)非常有用。
  2. 文件操作标志: std::ios_base::out和std::ios_base::binary这些标志告诉std::ofstream以输出模式打开文件,并且以二进制模式进行操作,避免了文本模式下的任何转换。
  3. 流状态检查:is_open()函数在写入文件之前,检查文件流的状态,确认文件已经被成功打开。
  4. 二进制写入:使用write方法将out_string的内容以二进制形式写入文件。这要求提供指针和要写入的字节数。

主要实现思路:

  1. 使用std::ofstream操作文件的写入,同时以二进制形式写入文件(数据是以原始字节序列的形式保存的,编译器或文件系统不会对写入的数据进行额外的解释或转换)。
  2. 遍历结果集,对每个文档信息进行处理并写入文件。
  3. 在遍历时使用"\3"构造分割符,使用这样的格式==title ‘\3’ content ‘\3’ url ‘\n’ title ‘\3’ content ‘\3’ url ‘\n…’==保存进指定文件中。
/// @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;
}

Parser.cc模块的main函数的实现

主要实现思路:

  1. 使用files_list保存遍历得到的HTML文件路径。
  2. 使用ParseHtml函数遍历files_list,将解析到的信息保存到results中。
  3. 使用SaveHtml函数将解析好的results,保存进指定文件中,方便index模块读取。
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;
}

编写索引模块(index.hpp)

针对头文件的引入和命名空间的定义:

#pragma once

#include 
#include 
#include 
#include 
#include 
#include 

#include "util.hpp"

namespace ns_index
{
};

以下功能代码都是属于ns_index命名空间的代码。

所需结构体的定义和typedef

	// 正排索引元素结构体,用于存储一个文档的数据内容
    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;

创建Index类以及普通参数定义

	// 索引类
    class Index
    {
    private:
        // 正排索引,存储所有文档的信息,通过文档编号(doc_id)可以快速定位到文档(vector的下标就是doc_id)
        std::vector<DocInfo> forward_index;

        // 倒排索引,存储所有词的信息,通过词可以快速找到包含该词的文档列表(关键字和倒排拉链映射关系)
        std::unordered_map<std::string, InvertedList> inverted_index;
	};

以下代码都是在Index类中的!!!

设计单例以及锁

为项目提供创建单例和锁的操作,主要使用到的知识点:

  1. 单例模式:在这个类中,Index 类使用单例模式确保整个应用程序中只有一个 Index 实例(这是一种设计模式,用于限制类的实例化过程,确保一个类只有一个实例,并提供一个全局访问点)。
  2. 私有构造函数:将构造函数声明为私有,防止从类的外部创建 Index 的实例。
  3. 删除拷贝构造函数和赋值操作符:使用 “= delete” 关键字来明确禁用拷贝构造函数和赋值操作符,这防止了 Index 实例的拷贝和赋值,从而保持单例特性。
  4. std::mutex 类型的静态成员变量:用于实现线程安全的实例化过程。
  5. 双检查锁定模式:最小化锁的使用,提高性能(这种模式首先在未加锁的情况下检查实例是否已经存在,如果不存在才加锁并再次检查,确保在多线程环境中只创建一次实例)。

主要实现思路:

  1. 首先将构造函数私有化,析构函数还是公开,删除拷贝构造函数、赋值运算符,声明静态的instance实例指针和mutex互斥锁。
  2. 构建静态成员函数GetInstance(),并返回Index类的单例实例指针。
  3. 在函数中使用双检查锁定模式锁。
  4. 类外部定义静态成员变量。
	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;

构建索引

主要实现思路:

  1. 使用std::ifstream打开指定文件路径的文件,以二进制模式和输入模式打开。
  2. 以行为单位读取文件,每读取一行数据,就尝试构建正排索引和倒排索引,使用std::getline函数读取每一行数据。
  3. 调用BuildForwardIndex函数,传入读取的一行数据,该函数将返回一个DocInfo对象的指针,如果构建失败则返回nullptr。
  4. 如果正排索引构建成功,即BuildForwardIndex返回非空指针,则调用BuildInvertedIndex函数,传入DocInfo对象,构建倒排索引。
        /// @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;
        }
    };

构建索引的两个子函数

构建正排索引

主要实现思路:

  1. 接收一个HTML数据行作为输入,通过定义的分隔符 “\3“ 将其分割成标题、内容和URL三个部分。
  2. 检查分割结果是否包含三个字段。
  3. 创建一个DocInfo对象,填充其属性(标题、内容、URL和文档ID),并将该对象移动到forward_index正排索引容器中(使用std::move可以提高效率)。
  4. 返回指向forward_index正排索引中最后一个元素的指针,以便构建倒排索引。
	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());
        }
构建倒排索引

主要实现思路:

  1. 接收一个DocInfo对象(来自正排索引构建好的forward_index正排索引),对其中的标题和内容进行分词,统计每个词在标题和内容中的出现次数。
  2. 其中使用boost::to_lower字符串转换:boost::to_lower将所有词转换为小写。
  3. 计算word_cnt中每个词的相关性权重,基于标题和内容的词频,使用预设的权重系数。
  4. 将word_cnt中每个词及其相关信息(文档ID、词频、权重)新建一个InvertedElem临时变量,将这个临时变量存储到倒排索引inverted_index中。
        /// @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;
        }

编写查找函数

根据文档ID获取文档信息

主要实现思路:

  1. 在正排索引中文档ID就是下标,首先判断文档ID是否合法。
  2. 直接在forward_index中使用文档ID索引即可。
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];
        }
根据关键词获取倒排列表

主要实现思路:

  1. 首先在inverted_index中使用find函数查找该关键字是否存在。
  2. 再分情况处理,找到就返回InvertedList的指针即可。
        /// @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);
        }

编写搜索模块(searcher.hpp)

针对头文件的引入和命名空间的定义:

#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;                     // 针对重复关键词的权重
    };

创建Searcher类以及普通参数定义

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, "索引建立完成...");
        }

编写搜索模块

主要实现思路:

  1. 使用jieba分词对查询字符串进行分词。
  2. 首先使用关键词在inverted_index倒排索引中查找。
  3. 将查找到的InvertedList遍历,通过remove_duplicates去重,只通过去重时,doc_id不变,权重进行累加,形成新的权重,将关键词使用vector存储到一起。
  4. 将所有关键词搜索去重后,合并到inverted_list_all中后。
  5. 使用std::sort函数对inverted_list_all中的搜索结果按权重进行降序排序
  6. 之后遍历inverted_list_all构建Json串,通过doc_id即可在正排序列中查找到相关信息。
  7. 最后将信息序列化后返回即可。
        /// @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);
        }

编写获取关键词摘要子函数

主要实现思路:

  1. 以word出现第一次位置开始向前50字节向后100字节,提取摘要。
  2. 使用std库的search函数进行搜索,同时还使用std::tolower函数过滤掉大小写。
  3. 找到关键词首次出现位置后,按照要求进行提取内容即可。
	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()";
        }

编写网络模块(http_server.cc)

在网络模块中主要使用了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;
}

项目完结

针对本项目还可以扩展的方向:

  1. 将其完善成Boost整站搜索。
  2. 不使用别人设计好的库,尝试自己手写实现(比如cpp-httplib)。

感谢各位看到最后,在本项目中如有问题,欢迎各位大佬指出,我会作出修改的( •̀ ω •́ )y

你可能感兴趣的:(C++,c++,搜索引擎,后端,学习,c语言,vscode)