用C++来设计开发的基于boost文档的站内搜索引擎项目,点赞收藏起来!

1 篇文章 0 订阅
1 篇文章 0 订阅

项目描述

boost官网虽然提供了在线文档,但是没有一个方便的搜索入口,因此我基于这样的一个问题上,自主设计开发了一款基于boost文档的站内搜索引擎,可以让我们通过浏览器精准定位获取到我们所需要查找的信息内容,实现小型搜索引擎的功能。

主要技术

介绍主要项目,更有利于在学习这个项目的时候更好的进行下去:


  • 在这个项目中我使用到的一些相关的功能实现总体而言的话是不需要我们进行深入了解的,如boost标准库的一些函数使用,cppjieba中分词函数进行倒排索引分词,,jsoncpp格式将我们生成的数据以json格式存放,cpphttpliib用来搭建http
    模块之中的html文件,因此对于这些功能函数体我们只需要包含它的头文件并且知道在项目之中使用了相关的那个功能就可以了。
  • 除此之外其中最大的难点是对倒排索引的构建,它和正排索引这两个知识模块是我之前没有接触过的,这也是关于搜索引擎的一个新知识点,也是我在进行这个项目设计时候所遇到的项目中继分词功能实现后最为困难的一个疑难点。
  • 正排索引:根据文档id查找相关的文档内容
  • 倒排索引:根据文档内容查找相关的文档id
  • 其中对于容器STL中vector,string的合理使用是C++中最经典的用法也是比较快捷的,其中在存储倒排索引权重时候使用哈希表,是对数据结构知识模块的考察。
  • 因此整体来说,除过一开始那些相关的函数包含之外,更多的都是对于我所学习知识的一个考验,为了能够更加合理的将所学习过的知识融入到一起。

项目特点

0. 准备工作


  • 在进行所有模块的初始化之前,需要按照我们的模块创建好对应的文件夹,将数据也放入对应的文件夹之中(即boosthtml文档,),因此需要提前设定好读取boost文档文件input的html的路径和存放从html取出的优化信息output的路径。
  • 读取数据函数(Read)——是一个比较底层且使用频率较高的读函数,多个模块都能够使用到,我将其放入到公共目录common文件的util.hpp头文件之中,这样提供一个对外的接口更加便捷我们调用。
  • 分词函(Split)——分词函数被索引模块和搜索模块共同使用,因此将其定义和声明放入到公共函数Util.hpp之中
       基于 boost 中的字符串切分, 封装一下
       delimiter 表示分割符, 按照啥字符来切分.
      理解 token_compress_off:
       例如有个字符串: aaa\3bbb\3\3ccc
       此时按照 \3 进行切分,
       切分结果可能有两种风格:
       1. 结果有三个部分,  aaa bbb ccc       token_compress_on 有分割符相邻时,
       会压缩切分结果
       2. 结果有四个部分,  aaa bbb "" ccc    token_compress_off
       不会压缩切分结果的.
      static void Split(const string &input, const string &delimiter,
                        vector<string> *output) {
          boost::split(*output, input, boost::is_any_of(delimiter),
                       boost::token_compress_off);
     }

  • 索引模块和搜索模块的很多内容是相互用的,也就是同样的函数会在这两个模块之中都被调用到,因此我将其中两个都用到的函数以及HTTP模块所需要调用的函数全定义在同一个头文件searcher.h之中,并且将其两个模块的功能放在同一个具体函数之中进行实现。
    在头文件之中需要定义索引模块(Index)的内容和搜索模块(Searcher)的内容,使用命名空间概念namespace searcher

searcher.h中索引模块使用到的函数API接口

  • 定义一个基于索引所需要用到的结构体DocInfo,这也是正排索引的基础,给定doc_id后映射到文档内容,也就是DocInfo对象;
  struct DocInfo {
      int64_t doc_id;//id在那个文档中出现
      string title;//标题
      string url;//url
      string content;//正文
  };
  • 倒排索引的话则是给定词之后映射到包含该词的文档id列表,此处不光是要有文档的id,还应该有权重信息来对此进行最优化的一个排序,则定义一个倒排索引所需要用的权重信息及词内容的结构体Weight
  struct Weight {
      // 该词在哪个文档中出现
      int64_t doc_id;
      // 对应的权重是多少
      int weight;
      // 词是啥
      string word;
 };

其中要定义一个叫做“倒排拉链”的东西typedef vector<Weight> InvertedList;,倒排拉链中是很多权重,用此来实现对倒排索引的哈希存储中kv键值对的v值类型

  • 提供外部调用的API接口,构造一个Index类表示整个索引结构
  class Index {
    private:
      // 索引结构
       正排索引, 数组下标就对应到 doc_id
      vector<DocInfo> forward_index;
       倒排索引, 使用一个 hash 表来表示这个映射关系
      unordered_map<string, InvertedList> inverted_index;
  
    public:
      Index();
      // 提供一些对外调用的函数
       1. 查正排, 返回指针就可以使用  而NULL 表示无效结果的情况
      const DocInfo* GetDocInfo(int64_t doc_id);
       2. 查倒排
      const InvertedList* GetInvertedList(const string& key);
       3. 构建索引
      bool Build(const string& input_path);
       4. 分词函数
      void CutWord(const string& input, vector<string>* output);
  
    private:
      DocInfo* BuildForward(const string& line);//字符串切分,使用\3将其行文本切分为3部分
      void BuildInverted(const DocInfo& doc_info);//
  
      cppjieba::Jieba jieba;
  };

searcher.h中搜索模块所用到的函数API接口

 class Searcher {
    private:
      Index* index;// 搜索过程依赖索引的. 就需要持有一个 Index 的指针.
 
    public:
      Searcher() : index(new Index()) {//构造函数
      }
      bool Init(const string& input_path);//初始化函数
      bool Search(const string& query, string* results);//搜索函数
  
    private:
      string GenerateDesc(const string& content, const string& word);//在正文之后显示160字的详细描述
 };

  • jieba分词的字符定义
    提前将我们下载好的cppjieba文件解压的doc文档中的信息存放在自己具体的目录之中,之后将其定义出来,这段定义代码是在索引模块函数之中的。
 const char* const DICT_PATH = "../jieba_dict/jieba.dict.utf8";
 const char* const HMM_PATH = "../jieba_dict/hmm_model.utf8";
 const char* const USER_DICT_PATH = "../jieba_dict/user.dict.utf8";
 const char* const IDF_PATH = "../jieba_dict/idf.utf8";
 const char* const STOP_WORD_PATH = "../jieba_dict/stop_words.utf8";

Index::Index() : jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_P    ATH) {
 26 }

1. 预处理模块

读取原始的html文档的内容,进行预处理操作:解析出一些重要的信息,文档标题,文档的URL,文档的正文(是去除原来的html标签,只保留正文)


因boost文档提供了两个版本,离线版本(下载)和在线版本(浏览器访问),所以为了能够在浏览器进行搜索的时候,直接展示出我们想要的内容,我通过基于离线版本分析文档页面的内容,为搜索功能提供支持,之后在浏览器点击进行搜索的时候,通过离线版本的倒排索引找到在线版的相关url信息,将其直接展示在浏览器上。

读取文档之前首先要定义好相关的变量结构体用来存放具体的数据,文档的标题,文档的正文,文档的url

  创建一个重要的结构体, 表示一个文档(一个 HTML)
  struct DocInfo {
       文档的标题
      string title;
       文档的 url
      string url;
       文档的正文
      string content;
  };

我将预处理模块分为三个部分
1. 把input目录中所有的html路径都枚举出来:将文件传入枚举函数

  • 如果自己一个个目录进行枚举的话,对于boost的5500+文件想要枚举遍历完,不仅是对内存和时间会产生非常巨大的消耗,而且会严重的影响到使用者的查找体验,因此我这里使用boost函数来完成;
  • 首先检查所传入的地址目录是否存在,如果存在则成功打开其文件进行递归遍历,在递归遍历的时候使用一个核心的类,之后其迭代器循环实现的时候其内部自动调用递归遍历方式完成递归。
  • 什么样的内容会被枚举:遇见的不是普通文件而是一个目录则直接跳过,或者不是html文件是其他文件也直接跳过,直到找到一个将其路径加入到最终结果vector之中。
      fs::recursive_directory_iterator end_iter;
      for (fs::recursive_directory_iterator iter(root_path); iter != end_iter;
           ++iter) {
          当前的路径对应的是不是一个普通文件. 如果是目录, 直接跳过.
          if (!fs::is_regular_file(*iter)) {
              continue;
          }
          当前路径对应的文件是不是一个 html 文件. 如果是其他文件也跳过.
          if (iter->path().extension() != ".html") {
             continue;
          }
          把得到的路径加入到最终结果的 vector 中
          file_list->push_back(iter->path().string());
      }

2. 根据枚举出来的路径依次遍历读取每个html中内容,进行解析:打开枚举出路径的文件,先将内容一股脑地全部读出来之后使用for循环将每一个html取出来将其内容拿出来解析

       负责从指定的路径中, 读取出文件的整体内容, 读到 output 这个 string 里
      static bool Read(const string &input_path, string *output) {
          std::ifstream file(input_path.c_str());
          if (!file.is_open()) {
              return false;
          }
           读取整个文件内容, 思路很简单, 只要按行读取就行了, 把读到的每行结果,
           追加到 output 中即可
           getline 功能就是读取文件中的一行.
           如果读取成功, 就把内容放到了 line 中. 并返回 true
           如果读取失败(读到文件末尾), 返回 false
          string line;
          while (std::getline(file, line)) {
              *output += (line + "\n");
          }
          file.close();
          return true;
      }

读取完成之后就是对其内容进行解析,解析的话因为需要能够得到需要的正文,url,title标题这三部分,我将其解析过程分为四块来进行解析:


  1. 读取文件内容,一股脑的把文件内容都读取出来
    直接调用common公共文件之中的Util.hpp文件之中的Read函数,来获取所有的文件内容;

  2. 根据文件内容解析出标题(html之中有着一个title标签)
    将每一个读取出的html传入标题解析函数,对其进行解析,解析出来title后放入到doc_info->title之中/
    查找标题的具体实现:要大概了解html的格式,会发现<title>标签引导开始的就是title内容,所以我们只要找到这个标签后,就能够确定后面的内容。

  3. 根据文件路径构造出对应在线文档URL
    获取url的话我是根据本地路径获取到在线文档的路径
    本地路径:…/data/input/html/thread.html
    在线路径形如:https://www.boost.org/doc/libs/1_53_0/doc/html/thread.html
    通过两个的对比,可以轻松的发现两者的差别指出,因此我把本地路径的后半部分截取出来, 拼装上在线路径的前缀就可以了

  4. 对文件中的内容进行去标签后所获取到的信息,作为doc_info中的content正文的内容
    这也是这个第2个局部模块之中较为难的一个地方,首先是因为我们对于一些相关的标签不是特别了解,所以如果很多人不知道这个是什么的话,也可以先看一些html中的内容,就会清晰了(其实就是那些无关紧要的一些字符和标志)
    最后将其去除掉标签的内容拿出来存放到我们实现创建好的目录文档之中,并且保证每一行对应一个原始的html


3. 把解析出的整体结果写入到输出文件中:直接使用ofstream类将解析出的最终数据结果放入输出文件之中,这一操作的话可以和第二步放在同一个循环之中进行实现

  • 这里切记传参数的时候只能够传引用或者指针,不能是const引用,否则无法执行里面的写文件操作,每一个doc_info信息其实就是一行

2. 索引模块

输入内容是解析后得到的行文本文件,通过读取这些内容,在内存中构造出一个正排索引和倒排索引,提供一些API供其他模块可以对其进行直接的调用


  1. 查正排函数:检查所传入的参数id是否小于0或者大于整个文件的最大值size,则返回空,否则返回这个文件id个的地址信息。
  2. 查倒排函数:传入对应的搜索词,通过哈希来查其是否存在,find函数来进行,若存在则返回其所在的位置,也就是unordered_map的第二个参数val,把倒排拉链获取到。
    相对于查找正排函数和查找倒排函数来说,构建索引则相对困难和复杂一些
  3. 构建索引
  • 按行读取输入文件的内容(即预处理模块所生成的raw_input文件:其结构为一个行文本文件每一行都对应一个文档)
  • 针对当前行,解析成DocInfo对象(将其行文本通过切分函数将其分成三部分,用\3 来进行切分,分别为标题,url,正文),并构造为正排索引。

切分函数并将其存储进DocInfo之中构成正排索引

   核心操作: 按照 \3 对 line 进行切分, 第一个部分就是标题, 第二个部分就是 url,第三个部分就是正文
  DocInfo* Index::BuildForward(const string& line) {
       1. 先把 line 按照 \3 切分成 3 个部分
      vector<string> tokens;
      common::Util::Split(line, "\3", &tokens);
      if (tokens.size() != 3) {
           如果切分结果不是 3, 就认为当前这一行是存在问题的,认为该文档构造失败.
          return nullptr;
      }
       2. 把切分结果填充到 DocInfo 对象中
      DocInfo doc_info;
      doc_info.doc_id = forward_index.size();//当前读取文档的大小则就是其对应的id号
      doc_info.title = tokens[0];//第一部分
      doc_info.url = tokens[1];//第二部分
      doc_info.content = tokens[2];//第三部分
      forward_index.push_back(std::move(doc_info));//移动构造
       3. 返回结果注意这里可能存在的野指针问题. C++ 中的经典错误,也是面试中的重要考点!!! //return &doc_info;
     return &forward_index.back();
 }
  • 根据当前的DocInfo对象,进行解析并构造倒排索引
    倒排是一个哈希表,key则是针对文档的分词结果中的词,而value则是倒排拉链,包含若干个Weight对象,因此每一次遍历正排索引的这个文档,就要分析这个文档,将这个文档的信息进行统计词频,计算权重之后将整体的信息更新到倒排索引结构中去。
    为了便于统计词频并且计算权重,需创建用于专门统计词频的结构
struct WordCnt {
      int title_cnt;//标题出现的次数
      int content_cnt;//正文出现的次数
      WordCnt() : title_cnt(0), content_cnt(0) {//标题和正文初始都为0
      }
};
将其和所需要找的词存放在哈希表中

01 针对标题进行分词
将标题传入分词函数,将其分词结果装入vector之中。
02 遍历分词结果统计每个次出现的次数
unordered_map[]有两个功能,key不存在就添加存在就修改,此处我们不考虑大小写的问题,我将其全部转换为小写来进行,使用boost标准库to_lower函数来完成。
for循环遍历整个vector容器,将其中每个日使用to_lower函数进行转换为小写,并把这个词出现的次数存放到标题词出现的次数变量中。
03 针对正文进行分词
将正文传入分词函数,将其分词结果装入vector之中
04 遍历分词结果统计每个词出现的次数
遍历分词的结果,将其转换为小写之后,统计其出现的次数,也就是使用map来进行查找,查找到了value++;
05根据统计结果,整合出Weight对象,并把结果更新到倒排索引中即可
构造Weight对象,将id序号赋给其权重结构体的id,计算权重,这里的话我使用的是标题出现的次数,出现一次我计为10,而在正文中出现一次,则计为1,之后将其出现的次数累加。

weight.weight = 10 * word_pair.second.title_cnt + word_pair.second.content_cnt;
weight.word = word_pair.first;

之后将其weight对象插入到倒排索引中去,因此需要先找到所对应的倒排拉链,然后将其追加到拉链末尾即可。
4. 分词
直接引用jieba函数 jieba.CutForSearch(input,*output);函数来进行分词

3. 搜索模块

1. 分词:先针对查询词进行分词(查询词可能比较长)
2. 触发:根据刚才的分词结果,查找倒排索引,找到那些文档是和当前的查询词相关的
3. 排序:把刚才搜索到的文档按照一定的规则进行排序,相关性越高的文档排的会越靠前
4. 构造结果:刚才触发出来的结果和排序的结果都是包含了一些文档id,而我们希望网页上显示的是标题,url,描述,因此就需要拿着文档id去查正排索引,把结果包装起来返回给发起请求的客户端


  • 先初始化也就是检验所输入的数据是否有效,若有效则直接返回构建成功的索引
  • 把查询词进行搜索,得到搜索结果
    01 分词
    针对查询词进行分词
    02 触发
    根据分词结果,查倒排,把相关的文档都获取到
    因为在做索引的时候把词转换为了小写,因此在查倒排搜因的时候也需要把查询词统一转成小写
    进行倒排查询,若是该词在倒排索引中不存在,则表示此词是生僻词,在所有文档中都没有出现,则返回的倒排拉链就是nullptr。
    从我们所创建的vector容器头部开始将所遍历到的多个倒排拉链的结构全部合并到一起
all_token_result.insert(all_token_result.end(), inverted_list->begin(), i    nverted_list->end());

03 排序
把上面查找到的这些文档的倒排拉链合并在一起后就可以按照之前计算的每一个权重来进行降序排序(因为一般搜索引擎都是按照降序进行,如果我们需要使用升序,也可以使用仿函数来自己实现)
在这里我使用lambda来实现的

std::sort(all_token_result.begin(), all_token_result.end(), [](const Weight&     w1, const Weight& w2) {
         // 如果要实现升序排序, 就写成 w1.weight < w2.weight
         // 如果要实现降序排序, 就写成 w1.weight > w2.weight
         return w1.weight > w2.weight;
     });

04 包装结果:把得到的这些倒排拉链中的文档id获取到后,去查正排
根据weight中的doc_id查正排,查找到之后把doc_info中的内容够造成最终预期的格式,JSON格式,使用jsoncpp这个库来实现json的操作

Json::Value results; // 这个 results 中包含了若干个搜索结果. 每个搜索结果就是一个 JSON 对象

循环遍历查正排,将查到的每一个结果包装成JSON对象
         Json::Value result;
         result["title"] = doc_info->title;
         result["url"] = doc_info->url;
         result["desc"] = GenerateDesc(doc_info->content, weight.word);
         results.append(result);

最后一步,把得到的results这个JSON对象序列化成字符串,写入到output文件之中
05 160字的详细描述
根据正文找到word出现的位置,以该位置为中心,往前找60个字节,作为描述的起始位置,再从起始位置开始找160个字节,作为整个描述内容(需要注意的边界条件就是,前面不够60个或没在正文中出现只在标题中出现过,那就从0开始;如果后面内容不够了那就到末尾结束,如果后面内容显示不下,可以使用…省略号来表示[160这个数字大家可以随意改动])

4. 服务器模块

http服务器,给外部提供服务
因为我更多的是学习的一些关于后端开发的知识和内容,所以对于http的一些内容我可能不是特别的熟悉,在了解http协议的基础上其大概的编程内容还都是从学校的选秀专业课计算机网络中学习到的,而我在整个模块中使用的cpphttplib,则不需要过多的关注HTTP服务器的实现细节,直接调用一些现成的函数即可,这对于大多数朋友来说应该都算是一个比较简捷的途径。


  1. 调用搜索模块:
    创建Searcher搜索对象,是为了进行搜索;
	searcher::Searcher searcher;
	bool ret = searcher.Init("../data/tmp/raw_input");//查看索引的路径是否存在
		if (!ret) {
			std::cout << "Searcher 初始化失败" << std::endl;
			return 1;
		}

  1. 搭建http服务器:
    创建Server对象(Server server;),这是httplib头文件中所包含的类,我们直接包含头文件按即可使用。
server.Get("/searcher",[&searcher](const Request& req,Response& resp){

        if (!req.has_param("query")) {//如果请求参数不存在的话返回空
              resp.set_content("您发的请求参数错误", "text/plain; charset=utf-8");//设置响应数据 
             return;
         }
       	string query = req.get_param_value("query");//请求参数
        string results;
        searcher.Search(query, &results);//将请求参数传入进行搜索
       	resp.set_content(results, "application/json; charset=utf-8");//将result搜索结果进行展示
});

以函数来进行响应数据的展示,在我们浏览器上访问某页面后加上一个/searcher的url,如果检查url中的路径,发现正确即会返回相应的响应数据(即我们访问后,浏览器会发送一个HTTP GET请求,将我们设置好的响应数据展示上去);
告诉服务器静态资源存放在wwwroot目录之中,之后通过服务器启动之后就可以通过http/127.0.0.1.10001/index.html来进行此页面的访问,当然了也不能够忘记了启动我们服务端的服务器,和我们socket编程中的listen设置被动套接字是一样的,直接使用server.listen("0.0.0.0",10001);
我也是设置了相关的query查询词,来表示我们所需要查询的内容,也就是说如果我们访问向filesystem的话,只需要在网页上输入127.0.0.1.10001/searcher?query=filesystem就表示我们所需要查找的关键词是filesystem!

关于对html的实现,因为我自身没有学习过更多的关于html开发设计的内容,只有着一些在学校专业课中简单的设置基础,所以一些大的模块我也是模仿和百度别人的模块,之后将其改过来,做了一些相关的改正和调整,因此对于这一方面我没有办法给大家做过多的解析,因为好多知识我自己也不懂,所以我直接将其放在了GitHub上,里面的每一个注释我也是详细的写上去了。

项目难点和提升

对于搜索引擎项目来说,因基于boost文档的所以它的规模是比较小的,我们开发起来也是比较方便的,但是若是对于搜索引擎有着深度的兴趣的话,我们可以尝试更大一点的搜索引擎,那么我们就需要考虑到:

  1. 支持更大的数据量的问题:也就需要使用“爬虫”方式来主动抓取页面的内容
  2. 请求量更大:需要满足的搜索内容更加多更加广泛
  3. 业务也会更加的复杂:需要更加严格的索引检验,更加准确的权重描述,更加丰富的过滤规则和更完备的调试接口

在整个项目之中对于我来说最难的部分应该就属于索引模块里的分词,构建正排索引和倒排索引这三个部分了,在一开始时候我不太清楚分词可以直接使用cppjieba其自带的分词来实现的,当时也是自己进行一些想法,最后看看发现自己也是傻的可以;
而重点的正排索引和倒排索引,在这一块的时候我也是真正的发挥了搜索一七年的作用,我直接搜索了一些关于搜索引擎的内容,设计开发思路从其中得到了关于正排索引和倒排索引的一些知识,才弥补了索引模块所欠缺的那些知识,到现在也是对其有着一个充分的认识。
正排索引:根据文档id查找相关的文档内容
倒排索引:根据文档内容查找相关的文档id

还有一个可能会出错的点,那就是对于在搜索模块和索引模块中多次出现的一些unordered_map结构,倒排拉链和vector等结构的来回使用,可能会让大家有着一些混淆,如果大家可以将其稍微的写一些或者画一下,那么就会大致明白他们之间的一个关系,并且有着深刻的记忆了,知道他们每一部分都是做什么,且是为何这么做,是为了存储什么的。

结束语

和我前面那个网络工具项目相比,这个的难度肯定是更大的,字数也是破万了,熬过了多少个夜晚才能够有这样的成绩,写到这里也算是我对整个项目的彻底完成了,真的可以说是非常的激动和欣慰了。
虽说网络工具相对简单,但那也是相抵搜索引擎来说简单,可作为底层实现的网络工具,它的每一个步骤都需要我们自己手动进行却显得会更加的繁杂,而在搜索引擎这个大的项目上面,它更多的接口和处理函数我们可以发现是现成的,我们可以借用他们的一些实例来了解,并且之后将其运用到自己的一些项目之中。
通过这一次的项目也是给了我一个全新的经验,让我明白一些项目其实没有我们所想的特别难,所有的成功都是一步步的走过来的,对于一些功能的实现我们可以更好的借用当前所拥有的基础和一些相关的模块来实现,并且一个完整的项目也是需要耗费更多的心血,在自己所不擅长的部分模块里,也应该多问几个为什么或者是多请教别人帮助,对于它才能够有更号的进步,当然也是希望这个项目可以帮助到和我一样在找工作的朋友,让你的秋招之路更加顺畅!(可以收藏起来慢慢研究哦)

项目的GitHub:SoEasy搜索引擎

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值