站内搜索引擎——02_正排与倒排索引构建

请添加图片描述

✨✨欢迎来到T_X_Parallel的博客!!
    🛰️博客主页:T_X_Parallel
    🛰️项目代码仓库:站内搜索引擎项目代码仓库
    🛰️专栏 : 站内搜索引擎项目
    🛰️欢迎关注:👍点赞🙌收藏✍️留言

项目环境:vscode、wsl(Ubuntu22.04)、g++/CMake

技术栈:C/C++ C++11、STL、标准库 Boost、Jsoncpp、cppjieba、cpp-httplib、html

博主服务器到期,改用vscode上的插件wsl来创建一个Ubuntu22.04环境

1. 正排与倒排索引回顾

让我们先回顾一下正排索引和倒排索引(详情请看00_项目介绍),正排索引就是通过文档ID找到对应的文档,倒排索引就是不同的关键词对应联系一个或多个文档ID。

根据正排索引和倒排索引的对应关系,可以知道建立索引模块需要用到上一个数据清洗与去标签模块处理完的数据,同时还需要将这个处理完的数据进行文档与文档之间的分离,文档中各内容的分离,内容中进行分词等操作


2. 建立索引模块基本结构

首先要确定的是正排索引和倒排索引的自己对应关系在代码实现中应该如何表示。

第一个先看正排索引,正排索引是文档ID对应文档,而每个文档分为文档标题、文档内容和文档链接(详情请看01_数据去标签与数据清洗,后面还会涉及文档内容,如有忘记可以去回顾一下)。这样的对应关系可以使用哈希表存储,但是我们可以使用数组来存储更加方便,数组的下标就是很不错的文档ID

在这里插入图片描述

这样我们就可以创建一个类似于数据清洗模块中的文档内容的结构体,但是不一样的是,在这里我们需要加上一个doc_id,虽然我们的数组下标已经是文档id了,但是这里多增加一个doc_id是为了方便后面的建立倒排索引,到时候讲到那部分内容自然就会明白这里的用意。

struct DocInfo
{
    std::string title;   // 文档标题
    std::string content; // 文档内容
    std::string url;     // 文档地址
    uint64_t doc_id;     // 文档ID——倒排和正排索引都需要,很关键
};
// 使用uint64_t是防止文档过多导致超出int最大值

std::vector<DocInfo> forward_index; // 正排索引

在这里插入图片描述

第二个我们看倒排索引,倒排索引是关键词对应一个或多个文档ID,因为后面的搜索模块要对搜索出的文档进行排序展示,所以我们还需要对每个文档中的关键词进行求权重,所以还需要一个权重,所以可以像正排索引创建一个结构体,考虑到万一在结构体中添加关键词类型会在后面有用,所以我们可以提前加上,然而和正排索引不同的是,不能使用数组来存,是要有一个对应关系关键词—>文档ID数组,所以我们使用哈希表来存储这对对应关系

struct InvertedElem
{
    uint64_t doc_id;  // 文档ID
    std::string word; // 关键词
    int weight;       // 关键词在文档中的权重,简单定义

    InvertedElem() : weight(0) {} // 构造函数,初始化weight
};

typedef std::vector<InvertedElem> InvertedElemList; // 为了方便使用typedef重命名这个类型


std::unordered_map<std::string, InvertedElemList> inverted_index; // 倒排索引

在这里插入图片描述

第三个我们看这个模块需要实现哪些功能,第一个功能肯定就是正排索引查找,第二个功能是倒排索引查找,这两个功能就是为后面的搜索模块提供的功能,使用文档ID找到对应的文档,使用关键词找到对应的文档ID,这两个功能比较好实现,但是要注意检查输入值是否合法。具体实现请看下面的模块具体实现部分

// 获取正排索引
// 输入doc_id,返回DocInfo
DocInfo *GetForwardIndex(const uint64_t doc_id) 
{
}
// 获取倒排索引
// 输入keyword,返回InvertedElemList
InvertedElemList *GetInvertedIndex(const std::string &keyword)
{
}

然后就是这个模块的核心功能——建立索引功能,这个功能就是将数据清洗模块处理后的数据进行分离文档,每个文档单独处理建立正排索引和倒排索引。这个功能实现中用到了文件打开方式,还有文件读取方式,因为数据清洗模块我们在将处理完的数据写入文件中时,文档与文档之间设置了分隔符'\n',所以这里的文件读取方式可以使用std::getline()函数来读取每个文档内容。具体实现请看下面的模块具体实现部分

// 建立索引
bool BuildIndex(const std::string file_path) // 传parser处理后的文件的地址
{
}

3. 模块具体实现

获取索引函数实现

这个部分的代码很容易实现,因为我们之前设计的正排索引和倒排索引结构简单,能够轻松获取需要的数据。唯一要注意的就是传入数据是否合法。

获取正排索引只需传入doc_id即可,先判断doc_id是否存在即doc_id是否大于forward_index的元素数量,然后返回DocInfo指针

获取倒排索引只需传入关键词,先判断该关键词在inverted_index中是否存在,然后返回InvertedElemList指针

// 获取正排索引
DocInfo *GetForwardIndex(const uint64_t doc_id)
{
    if (doc_id >= forward_index.size())
    {
        std::cout << "error!doc_id out range" << std::endl;
        return nullptr;
    }
    return &forward_index[doc_id];
}
// 获取倒排索引
InvertedElemList *GetInvertedIndex(const std::string &keyword)
{
    if (inverted_index.find(keyword) == inverted_index.end())
    {
        std::cout << keyword << " have no InvertedElemList" << std::endl;
        return nullptr;
    }
    return &inverted_index[keyword];
}

建立索引函数实现

这个部分主要是对文件的打开操作和读取操作,上面也讲到了,因为数据清洗模块中的设计,可以使用getline()函数来获取每个文档内容,所以我们就一个文档一个文档来进行正排索引构建和倒排索引构建。需要注意的是要判断文件的打开情况,防止报错。

// 建立索引
bool BuildIndex(const std::string file_path) // 传parser处理后的文件的地址
{
    std::ifstream in(file_path, std::ios::in | std::ios::binary);

    if (!in.is_open())// 检查文件是否打开
    {
        std::cout << "error! " << file_path << " open fail" << std::endl;
        return false;
    }
    std::string line;
    while (std::getline(in, line))
    {
        DocInfo *doc = BuildForwardIndex(line);// 正排索引构建

        if (doc == nullptr)
        {
            std::cout << "Error! Forward build" << line << "fail" << std::endl;
            continue;
        }

        BuildIndexInvertedIndex(*doc);// 倒排索引构建
    }
    return true;
}

从上面的代码看出,接下来就要实现正排索引构建函数BuildForwardIndex()和倒排索引构建函数BuildIndexInvertedIndex()

建立正排索引函数实现

首先我们需要考虑函数的输入和输出值,输入肯定就是getline()获取的一行字符串,即一个文档的内容,输出可以考虑输出bool类型的值,但是由于下一步的倒排索引构建仍然需要这个文档内容,而且需要的是DocInfo类型的值,不妨我们在正排索引构建过程中将一个文档内容分割后构建成DocInfo类型的值,先添加进正排索引,然后返回,方便倒排索引构建,不需要再次进行文档分割。所以该函数的返回值为DocInfo指针,可以作为倒排索引的输入值。

然后再实现函数的正排索引构建代码,步骤很简单,就是将输入值进行解析,分成title+content+url,然后填入DocInfo,最后插入进forward_index正排索引中。第一步解析输入值,在数据清洗模块中设计了titlecontenturl之间使用分隔符'\3'进行分割,我们可以使用这个特征进行分割。

文档内容:title+'\3'+content+'\3'

字符串分割可以使用C++或者C语言中的一些函数进行分割,但是为了方便可以直接boost库中的split()函数进行分割,具体使用方法,见下面util模块中字符串分割和分词函数实现部分的内容

分割好的内容填入DocInfo和填好的DocInfopushforward_index中,这部分比比较简单,都是结构体和vector的语法使用,就不过多阐述,可以参考下面代码理解

// 正排索引
DocInfo *BuildForwardIndex(const std::string &line)
{
    // 1.解析line,分成title+content+url
    std::vector<std::string> results;

    ns_util::StringUtil::Split(ns_util::sep, line, &results);
    if (results.size() != 3)
    {
        std::cout << "Error! split fail" << std::endl;
        return nullptr;
    }

    // 2.填入DocInfo
    DocInfo doc;
    doc.title = results[0];
    doc.content = results[1];
    doc.url = results[2];
    doc.doc_id = forward_index.size();

    // 3.push进forward_index
    forward_index.push_back(std::move(doc));
    return &forward_index.back();
}

ns_util::StringUtil::Split()函数在下面util模块新增方法实现部分会详细讲解和实现

建立倒排索引函数实现

这个函数的主要目的就是将每个文档标题和内容进行分词并且统计词频算出权重,而按常规来说,一个词出现在标题和出现在文档内容中的权重会不一样,出现在标题时的权重会比出现在文档内容中的权重要大很多。

大型的搜索引擎在计算权重会采用很多复杂的计算公式去计算,而我们这个项目中就简单计算一下权重,出现在标题的权重比上在内容的权重是10:1

因此,我们应该对文档标题和文档内容分别进行分词和词频统计,然后计算权重。同样的我们可以像倒排索引一样建立一个有标题词的词频和内容词的词频的结构体,然后使用哈希表让每个词和这个结构体对应,先对文档标题和文档内容分别进行分词和词频统计,并填入词频统计的哈希表中,然后再遍历这个哈希表计算每个词所对应的文档的权重来构建一个一个InvertedElem类,最后将其插入到inverted_index倒排索引中

  • 字符串分词我这里用的是cppjieba库中的函数,为了方便使用,我直接在util模块中封装了这个方法,便于使用,具体解释和封装实现请看下一个部分util模块中字符串分割和分词函数实现的内容

  • 从下面大型搜索引擎的截图来看,搜索结果是不分大小写的,所有在对标题和内容的分词和词频统计部分需要特殊处理每一个词

    在这里插入图片描述

// 倒排索引
bool BuildIndexInvertedIndex(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;

    // 对title进行分词和词频统计
    std::vector<std::string> title_words;
    ns_util::JiebaUtil::CutString(doc.title, &title_words);

    for (std::string word : title_words)
    {
        boost::to_lower(word);
        word_map[word].title_cnt++;
    }
    // 对content进行分词和词频统计
    std::vector<std::string> content_words;
    ns_util::JiebaUtil::CutString(doc.content, &content_words);

    for (std::string word : content_words)
    {
        boost::to_lower(word);
        word_map[word].content_cnt++;
    }

    // 插入inverted_index中
    for (const auto &[word, cnt] : word_map)
    {
        InvertedElem elem;
        elem.doc_id = doc.doc_id;
        elem.word = word;
        elem.weight = TITLE_WEIGHT * cnt.title_cnt + CONTENT_WEIGHT * cnt.content_cnt;
        inverted_index[word].push_back(std::move(elem));
    }
    return true;
}

// 上面的TITLE_WEIGHT和CONTENT_WEIGHT在util.hpp中进行了#define定义了的

util模块中字符串分割和分词函数实现

第一个就是字符串分割函数实现,函数实现不难,只是封装了boost库中的split()函数,接下来介绍一下这个split()函数的用法以及各参数的含义。

为什么不用C语言和C++中的函数进行分割?有现成的为什么不用,对吧。一方面这个比较方便,另一方面,有些函数会改动原字符串,如果不嫌麻烦可以自己尝试尝试

// 首先需要引入头文件
#include <boost/algorithm/string.hpp>
// 使用方法
boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
// 第一个参数是分词的结果,是个vector数组类型
// 第二个参数是需要分词的字符串,是一个string类型
// 第三个参数是分隔符,但是传入方法是boost::is_any_of(sep),sep是一个分隔符,string类型
// 第四个参数是一个选项,选项的作用是当存在多个分割符时是否压缩分割符,具体选项不同的效果下面进行测试

//测试代码如下
#include <iostream>
#include <string>
#include <vector>
#include <boost/algorithm/string.hpp>

int main()
{
    std::string s = "aaaa\3\3\3\3bbbbb\3cccccc";
    std::string sep = "\3";

    std::vector<std::string> ans;
    boost::split(ans, s, boost::is_any_of(sep));
    std::cout << "第四个参数不写的情况下的结果: " << std::endl;
    for (auto& s : ans)
        std::cout << s << std::endl;
    ans.clear();

    boost::split(ans, s, boost::is_any_of(sep), boost::token_compress_off);
    std::cout << "第四个参数为off的情况下的结果: " << std::endl;
    for (auto& s : ans)
        std::cout << s << std::endl;
    ans.clear();

    boost::split(ans, s, boost::is_any_of(sep), boost::token_compress_on);
    std::cout << "第四个参数on的情况下的结果: " << std::endl;
    for (auto& s : ans)
        std::cout << s << std::endl;
    ans.clear();

    return 0;
}
//结果如下
第四个参数不写的情况下的结果:
aaaa



bbbbb
cccccc
第四个参数为off的情况下的结果:
aaaa



bbbbb
cccccc
第四个参数on的情况下的结果:
aaaa
bbbbb
cccccc

// 说明默认选项时off即不对重复分割符进行压缩

第二个就是字符串分词函数的实现了,这个函数同样也是封装了cppjieba第三方库中的函数,接下来先讲如何安装以及正确使用这个第三方库。

为了整理第三方库可以在家目录下新创建一个目录thirdpart用来装第三方库

# 先转到thirdpart目录下 
cd ~/thirdpart/

# 然后使用git(如果没有安装,请先去安装一个git,比较方便)下载cppjieba
git clone https://gitee.com/imboy-tripartite-deps/cppjieba.git
git clone https://gitee.com/albertix/limonp.git

#然后将limonp目录下的一些文件移入cppjieba/deps/limonp/下
cp ./limonp/include/limonp/* ./cppjieba/deps/limonp/

#再执行一条命令即可安装完成(不执行不能正常使用)
cd cppjieba; cp -rf deps/limonp include/cppjieba/

下面这个代码是从test目录下的demo.cpp文件中修改过来的,是一个测试代码,可以让我们知道如何使用

#include "cppjieba/Jieba.hpp"

using namespace std;

const char* const DICT_PATH = "../dict/jieba.dict.utf8";
const char* const HMM_PATH = "../dict/hmm_model.utf8";
const char* const USER_DICT_PATH = "../dict/user.dict.utf8";
const char* const IDF_PATH = "../dict/idf.utf8";
const char* const STOP_WORD_PATH = "../dict/stop_words.utf8";

int main(int argc, char** argv) {
  cppjieba::Jieba jieba(DICT_PATH,
        HMM_PATH,
        USER_DICT_PATH,
        IDF_PATH,
        STOP_WORD_PATH);
  vector<string> words;
  string s;
  string result;

  s = "小明硕士毕业于中国科学院计算所,后在日本京都大学深造";
  cout << s << endl;
  cout << "[demo] CutForSearch" << endl;
  jieba.CutForSearch(s, words);
  cout << limonp::Join(words.begin(), words.end(), "/") << endl;

}
// 我们主要用到的就是CutForSearch()函数,第一个参数是要分词的对象,第二个参数是存放分词结果的数组

下一步就是将这个cppjieba库引入到我们的项目中

# 将我们要用到的模块软链接到我们的项目目录下,因为我们的库安装在统一的目录下
ln -s ~/thirdpart/cppjieba/include/cppjieba cppjieba
ln -s ~/thirdpart/cppjieba/dict dict
# 注意:如果软链接建错的话使用unlink指令来解除

然后就是仿照上面的测试代码在util.hpp中使用即封装CutForSearch()函数,就不过多阐述,具体请看下面的代码

#include <boost/algorithm/string.hpp>
#include "cppjieba/Jieba.hpp"

namespace ns_util
{
    const std::string sep = "\3";


    class StringUtil
    {
    public:
        static void Split(const std::string &sep, const std::string &target, std::vector<std::string> *out)
        {
            boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
        }
    };

    const char *const DICT_PATH = "./dict/jieba.dict.utf8";
    const char *const HMM_PATH = "./dict/hmm_model.utf8";
    const char *const USER_DICT_PATH = "./dict/user.dict.utf8";
    const char *const IDF_PATH = "./dict/idf.utf8";
    const char *const STOP_WORD_PATH = "./dict/stop_words.utf8";

    class JiebaUtil
    {
    private:
        static cppjieba::Jieba jieba;

    public:
        static void CutString(const std::string &src, std::vector<std::string> *out)
        {
            jieba.CutForSearch(src, *out);
        }
    };
    cppjieba::Jieba JiebaUtil::jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH);//注:静态成员需要在类外初始化
}
// 上面的sep分隔符在之前的数据清洗模块实现时已经在ns_util空间中定义了

注:本模块的测试在下一个searcher模块实现后一起测试,同时会对该模块进行改动


4. 建立索引模块代码

// index.hpp
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <unordered_map>
#include <fstream>
#include "util.hpp"

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;  // 文档ID
        std::string word; // 关键字
        int weight;       // 关键字在文档中的权重,简单定义

        InvertedElem() : weight(0) {} // 构造函数,初始化weight
    };

    typedef std::vector<InvertedElem> InvertedElemList;

    class Index
    {
    private:
        // 数组下标就是文档对应的文档ID
        std::vector<DocInfo> forward_index; // 正排索引

        // 使用哈希表存储倒排索引,一个关键字对应一组InvertedElem,使用vector<InvertedElem>
        std::unordered_map<std::string, InvertedElemList> inverted_index; // 倒排索引
    private:
        Index() {}  // 构造函数
        ~Index() {} // 析构
    public:
        // 获取正排索引
        DocInfo *GetForwardIndex(const uint64_t doc_id)
        {
            if (doc_id >= forward_index.size())
            {
                std::cout << "error!doc_id out range" << std::endl;
                return nullptr;
            }
            return &forward_index[doc_id];
        }
        // 获取倒排索引
        InvertedElemList *GetInvertedIndex(const std::string &keyword)
        {
            if (inverted_index.find(keyword) == inverted_index.end())
            {
                std::cout << keyword << " have no InvertedElemList" << std::endl;
                return nullptr;
            }
            return &inverted_index[keyword];
        }
        // 建立索引
        bool BuildIndex(const std::string file_path) // 传parser处理后的文件的地址
        {
            std::ifstream in(file_path, std::ios::in | std::ios::binary);

            if (!in.is_open()) // 检查文件是否打开
            {
                std::cout << "error! " << file_path << " open fail" << std::endl;
                return false;
            }
            std::string line;
            while (std::getline(in, line))
            {
                DocInfo *doc = BuildForwardIndex(line);

                if (doc == nullptr)
                {
                    std::cout << "Error! Forward build" << line << "fail" << std::endl;
                    continue;
                }

                BuildIndexInvertedIndex(*doc);
            }
            return true;
        }

    private:
        // 正排索引
        DocInfo *BuildForwardIndex(const std::string &line)
        {
            // 1.解析line,分成title+content+url
            std::vector<std::string> results;

            ns_util::StringUtil::Split(ns_util::sep, line, &results);
            if (results.size() != 3)
            {
                std::cout << "Error! " << "split fail" << std::endl;
                return nullptr;
            }

            // 2.填入DocInfo
            DocInfo doc;
            doc.title = results[0];
            doc.content = results[1];
            doc.url = results[2];
            doc.doc_id = forward_index.size();

            // 3.push进forward_index
            forward_index.push_back(std::move(doc));
            return &forward_index.back();
        }
        // 倒排索引
        bool BuildIndexInvertedIndex(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;

            // 对title进行分词和词频统计
            std::vector<std::string> title_words;
            ns_util::JiebaUtil::CutString(doc.title, &title_words);

            for (std::string word : title_words)
            {
                boost::to_lower(word);
                word_map[word].title_cnt++;
            }
            // 对content进行分词和词频统计
            std::vector<std::string> content_words;
            ns_util::JiebaUtil::CutString(doc.content, &content_words);

            for (std::string word : content_words)
            {
                boost::to_lower(word);
                word_map[word].content_cnt++;
            }

            // 插入inverted_index中
            for (const auto &[word, cnt] : word_map)
            {
                InvertedElem elem;
                elem.doc_id = doc.doc_id;
                elem.word = word;
                elem.weight = TITLE_WEIGHT * cnt.title_cnt + CONTENT_WEIGHT * cnt.content_cnt;
                inverted_index[word].push_back(std::move(elem));
            }
            return true;
        }
    };
}

5. util模块更新后代码

#pragma once

#include <string>
#include <vector>
#include <fstream>
#include <iostream>
#include <boost/filesystem.hpp>
#include <boost/algorithm/string.hpp>
#include "cppjieba/Jieba.hpp"

#define TITLE_WEIGHT 10
#define CONTENT_WEIGHT 1

namespace ns_util
{
    const std::string sep = "\3";
    class FileUtil
    {
    public:
        static bool ReadFile(const std::string &file, std::string &content)
        {
            // 打开文件
            std::ifstream in(file.c_str());
            if (!in.is_open())
            {
                std::cerr << "Open file " << file << " failed!" << std::endl;
                return false;
            }
            // 使用getline读取文件内容
            std::string line;
            while (std::getline(in, line))
            {
                content += line;
            }
            // 关闭文件
            in.close();
            return true;
        }
    };

    class StringUtil
    {
    public:
        static void Split(const std::string &sep, const std::string &target, std::vector<std::string> *out)
        {
            boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
        }
    };

    const char *const DICT_PATH = "./dict/jieba.dict.utf8";
    const char *const HMM_PATH = "./dict/hmm_model.utf8";
    const char *const USER_DICT_PATH = "./dict/user.dict.utf8";
    const char *const IDF_PATH = "./dict/idf.utf8";
    const char *const STOP_WORD_PATH = "./dict/stop_words.utf8";

    class JiebaUtil
    {
    private:
        static cppjieba::Jieba jieba;

    public:
        static void CutString(const std::string &src, std::vector<std::string> *out)
        {
            jieba.CutForSearch(src, *out);
        }
    };
    cppjieba::Jieba JiebaUtil::jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH);
}

请添加图片描述

专栏:站内搜索引擎项目
项目代码仓库:站内搜索引擎项目代码仓库(随博客更新)
都看到这里了,留下你们的珍贵的👍点赞+⭐收藏+📋评论吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

T_X_Parallel〆

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值