C++之基于正倒排索引的Boost搜索引擎项目正倒排索引部分代码及详解
首先我们通过前面的文章知道正倒排索引在搜索引擎项目中是非常重要的,正排索引与倒排索引协同工作,实现我们对内容的搜索。
这边要说明一点,正排索引是把文档内容放到文档ID里面,同时倒排索引根据文档来映射文档ID。搜索引擎在对文档进行处理和索引构建时,会先创建正排索引,然后基于正排索引进一步生成倒排索引。当用户输入查询关键词时,搜索引擎会利用倒排索引快速定位包含该关键词的文档,再结合正排索引等其他信息进行结果展示。
PS:他们两个是提前创建好的,并不是在外面进行搜索的时候临时创建的。
1.正倒排索引的结构
1.1 正排索引
这个是正排索引所需要的,就是文档的一些内容和他的ID。
//这个是正排索引中需要用到的 typedef struct DocInfo{ std::string title;//文档的标题 std::string content;//文档的内容 std::string url;//文档的url int doc_id;//文档的ID }DocInfo1;1.2 倒排索引
这个是倒排索引所需要的,文档的ID和他对应的关键字还有他的权重。至于下面那个InvertedList,一般称呼它为倒排拉链,因为一般来说一个关键字不会只有一个对应的文档,比如说关键字A,他可能出现在10个文档里面,那这个A的拉链里面就有这10个文档。
//倒排索引中需要用到的 struct InvertedElem{ int doc_id;//文档的ID std::string word;//文档的word int weight;//这个文档的权重 }; typedef std::vector<InvertedElem> InvertedList;2. 正倒排序部分class的private部分
2.1 正倒排序的准备工作
正排索引的forward_index就是通过创建vector,因为vector的下表就是文档的ID,所以使用起来会很方便,同时也减少了我们的代码工作。
然后那个inverted_index就是给倒排索引准备的,因为是倒排拉链的缘故,所以要使用哈希,然后一个关键字映射对个倒排文档。
private: //正排索引使用vector,因为他的下标就是文档的id std::vector<DocInfo1> forward_index; //使用哈希来进行映射 std::unordered_map<std::string,InvertedList> inverted_index; 2.2 正倒排序部分的单例模式
在这边的代码中我使用的是单例模式,好处如下:
- 减少资源浪费:正排索引相关的排序组件,可能需要加载大量文档元数据到内存,或持有数据库连接、缓存连接等重型资源。单例模式能保证整个程序运行期间,只存在这一个组件实例,避免反复创建或者销毁实例导致的内存占用过高、IO 开销增加问题。
- 确保全局逻辑统一:排序逻辑需要全局一致 —— 如果存在多个实例,可能因初始化参数不同,导致对同一批文档输出不同的排序结果,破坏搜索结果的稳定性。单例模式能强制所有搜索请求复用同一套逻辑,避免结果混乱。
- 简化资源管理与调用:单例模式的实例全局可访问,搜索引擎中其他模块需要调用排序功能时,无需单独创建实例,直接复用同一个单例即可,降低了模块间的耦合度,也减少了代码冗余。
同时因为是单例模式,所以我们需要把拷贝和运算符=给禁用掉。并且我们还需要对其进行上锁的操作,这样可以防止多线程并发操作创建多个实例。
PS:这边之所以需要两个 if(instance==nullptr),是因为在进入锁前和后度需要判断,主要是因为线程太快了,不这一写容易引发线程安全问题。
private:////////////////////////// Index(){}; Index(const Index&)=delete; Index& operator=(const Index&)=delete; //这边的话就是禁用拷贝构造函数和运算符重载,因为是单例模式 static Index* instance; static std::mutex log; public: ~Index();//这边框起来的这段代码是使用单例模式的方式来进行编程, public: static Index* Getinstance() { if(instance==nullptr) { log.lock();//上锁 if(instance==nullptr) { instance=new Index(); } log.unlock();//解锁 } return instance; }///////////////////////////////////////3. 去标签后的文档来构建正倒排索引
3.1 创建正排索引代码
这边的话就是通过Split函数来进行分词,(我们后面会讲到一份代码usuallytool,这份代码里面是一些常用函数。)然后创建临时变量doc,先把result里面的结果给到doc,接着doc再给forward_index。
PS:有人看到这里可能会疑惑为什么在这里results和doc不可以只用一个呢?这是因为这个Split函数在我设计的时候里面的类型是string,因为是通用工具,所以Split的类型不可能是DocInfo1 。
DocInfo1* BuildForwardIndex(const std::string& line) { std::vector<std::string> results; ns_util::StringUtil::Split(line,&results,"\3");//这边就是进行字符窜切割,把title,content,url分开 if(results.size()!=3) return nullptr; DocInfo1 doc;//创建一个临时变量 doc.title=results[0]; doc.content=results[1]; doc.url=results[2]; doc.doc_id=forward_index.size(); //这上面表示把切分好的内容分给doc //注意,这个id就是下标的个数,所以我们这边就直接所以size()就可以了 //然后我们把处理好的docpush进forward_index里面 forward_index.push_back(doc); return &forward_index.back(); }3.2 创建倒排序索引代码
这份代码由以下四步组成:
- 第一步:分词(标题→
title_words,正文→content_words)。 - 第二步:词频统计(区分标题 / 正文,存入
word_map)。 - 第三步:权重计算(基于预设规则)。
- 第四步:填充倒排索引(更新
inverted_index)。
首先我们创建一个结构体,用来存放title和content里面的关键字的出现次数,接着使用word_map来统计对应词的出现频率。然后使用usuallytool里面的CutString函数把doc里面的title和content部分取出来,交给title_words和content_words。那个to_lower用来把关键字全部小写化,因为不能说把大小写区分成两个字。
然后创建一个临时变量item,然后把ID,word和对应的权重交给item,接着再交给inverted_index。
PS:inverted_index的本质是倒排拉链,它的value是一个数组,里面存储倒排索引的结构体,所以最后一步可以这么写
bool BuildInvertedIndex(const DocInfo1& doc) { struct word_cnt{ int title_cnt;//title出现的次数 int content_cnt;//content出现的次数 word_cnt() :title_cnt(0) ,content_cnt(0) {} };//这边的话就是先创建一个结构体,然后用这个结构体来存储词频 std::unordered_map<std::string,word_cnt> word_map; //这边就是通过KV的结构来存储 std::vector<std::string> title_words; ns_util::JiebaUsutl::CutString(doc.title,&title_words); for(auto& tw:title_words) { boost::to_lower(tw); word_map[tw].title_cnt++; } //这三行代码就是先创建一个存储分好后的词的容器,接着对其进行计数 std::vector<std::string> content_words; ns_util::JiebaUsutl::CutString(doc.content,&content_words); for(auto& cw:content_words) { boost::to_lower(cw); word_map[cw].content_cnt++; } //这三行代码就是先创建一个存储分好后的词的容器,接着对其进行计数 #define X 10 #define Y 1 for(auto& word_pair:word_map) { InvertedElem item;//InvertedElem这个类是专门为了倒排创建的,里面就只有文档的ID,权重和word item.doc_id=doc.doc_id;//这里就是把分好的文档的ID给item item.word=word_pair.first;//这是是把分好的那个词给item item.weight=X*word_pair.second.title_cnt+Y*word_pair.second.content_cnt; //这边就是模拟进行权重的计算 inverted_index[word_pair.first].push_back(item); //这个inverted_index一开始是空的,然后我们不断的push_back就可以人里面存在key和value } return true; } };3. 同时调用上面两个函数创建正倒排索引
这边的话就是先以二进制的方式读取input(这边这个input不是用户输入的部分,而是相当于我们的搜索引擎的原始文档)。首先我们使用getline函数,把内容读取到line里面,接着调用BuildInvertedIndex函数和BuildForwardIndex函数来实现构建索引。
至于下面那个count的话是一种简单的日志,这个在后面也会有介绍。
bool BuildIndex(const std::string& input) { std::ifstream in(input,std::ios::in | std::ios::binary); if(!in.is_open())//如果文件打开失败就返回 { std::cout<<input<<"open error"<<std::endl; return false; } int count = 0; std::string line;//设置一个临时变量用来接收去标签后的文档 while(std::getline(in,line)) { //这边先建立正排索引,然后通过if条件判断来确定是否创建成功 DocInfo1* doc=BuildForwardIndex(line); if(doc==nullptr) { std::cout<<"BuildIndex error"<<std::endl; continue; } BuildInvertedIndex(*doc); count++; if(count%50==0) LOG1(NORMAL,"索引建立到:" + std::to_string(count)); } return true; }4. 获取正倒排索引
4.1 获取正排索引
首先防御性编程,判断一下doc_id与forward_index.size()的大小,没问题的话就返回这个forward_index下标对应的文档内容。
//正排索引,通过id来找到文档内容 DocInfo1* GetForwardIndex(uint64_t doc_id) { if(doc_id>=forward_index.size()) { std::cout<<"doc_id out range, error!"<<std::endl; return nullptr; } return &forward_index[doc_id]; }4.2 获取倒排索引
倒排比正排稍微麻烦一点点,因为我们要查找这个关键字存在不存在,存在的话就直接返回该关键词对应的倒排拉链的地址。
//通过word(也就是我们的关键字)来获取倒排拉链 //倒排拉链就是说对应的那一串文档的ID InvertedList* GetInvertedList(const std::string& word) { auto iter=inverted_index.find(word); if(iter==inverted_index.end()) { std::cout<<word<<"get error"<<std::endl; return nullptr; } return &(iter->second); }5. 总结
本文档围绕搜索引擎核心的正倒排索引模块,从设计思路、数据结构、实现流程到核心接口,系统梳理了完整实现逻辑,明确了正倒排索引如何协同支撑 “关键词搜索→结果展示” 的全流程。以下是这一部分的完整代码:
#pragma once #include<iostream> #include<string> #include<vector> #include<unordered_map> #include<fstream> #include<mutex> #include"usuallytool.hpp" #include<boost/algorithm/string.hpp> #include"log.hpp" namespace ns_index{ //这个是正排索引中需要用到的 typedef struct DocInfo{ std::string title;//文档的标题 std::string content;//文档的内容 std::string url;//文档的url int doc_id;//文档的ID }DocInfo1; //倒排索引中需要用到的 struct InvertedElem{ int doc_id;//文档的ID std::string word;//文档的word int weight;//这个文档的权重 }; typedef std::vector<InvertedElem> InvertedList; class Index{ private: //正排索引使用vector,因为他的下标就是文档的id std::vector<DocInfo1> forward_index; //使用哈希来进行映射 std::unordered_map<std::string,InvertedList> inverted_index; private:////////////////////////// Index(){}; Index(const Index&)=delete; Index& operator=(const Index&)=delete; //这边的话就是禁用拷贝构造函数和运算符重载,因为是单例模式 static Index* instance; static std::mutex log; public: ~Index();//这边框起来的这段代码是使用单例模式的方式来进行编程, public: static Index* Getinstance() { if(instance==nullptr) { log.lock();//上锁 if(instance==nullptr) { instance=new Index(); } log.unlock();//解锁 } return instance; }/////////////////////////////////////// public: //正排索引,通过id来找到文档内容 DocInfo1* GetForwardIndex(uint64_t doc_id) { if(doc_id>=forward_index.size()) { std::cout<<"doc_id out range, error!"<<std::endl; return nullptr; } return &forward_index[doc_id]; } //通过word(也就是我们的关键字)来获取倒排拉链 //倒排拉链就是说对应的那一串文档的ID InvertedList* GetInvertedList(const std::string& word) { auto iter=inverted_index.find(word); if(iter==inverted_index.end()) { std::cout<<word<<"get error"<<std::endl; return nullptr; } return &(iter->second); } //根据去标签后的文档来构建正倒排索引 bool BuildIndex(const std::string& input) { std::ifstream in(input,std::ios::in | std::ios::binary); if(!in.is_open())//如果文件打开失败就返回 { std::cout<<input<<"open error"<<std::endl; return false; } int count = 0; std::string line;//设置一个临时变量用来接收去标签后的文档 while(std::getline(in,line)) { //这边先建立正排索引,然后通过if条件判断来确定是否创建成功 DocInfo1* doc=BuildForwardIndex(line); if(doc==nullptr) { std::cout<<"BuildIndex error"<<std::endl; continue; } BuildInvertedIndex(*doc); count++; if(count%50==0) LOG1(NORMAL,"索引建立到:" + std::to_string(count)); } return true; } private: DocInfo1* BuildForwardIndex(const std::string& line) { std::vector<std::string> results; ns_util::StringUtil::Split(line,&results,"\3");//这边就是进行字符窜切割,把title,content,url分开 if(results.size()!=3) return nullptr; DocInfo1 doc;//创建一个临时变量 doc.title=results[0]; doc.content=results[1]; doc.url=results[2]; doc.doc_id=forward_index.size(); //这上面表示把切分好的内容分给doc //注意,这个id就是下标的个数,所以我们这边就直接所以size()就可以了 //然后我们把处理好的docpush进forward_index里面 forward_index.push_back(doc); return &forward_index.back(); } bool BuildInvertedIndex(const DocInfo1& doc) { struct word_cnt{ int title_cnt;//title出现的次数 int content_cnt;//content出现的次数 word_cnt() :title_cnt(0) ,content_cnt(0) {} };//这边的话就是先创建一个结构体,然后用这个结构体来存储词频 std::unordered_map<std::string,word_cnt> word_map; //这边就是通过KV的结构来存储 std::vector<std::string> title_words; ns_util::JiebaUsutl::CutString(doc.title,&title_words); for(auto& tw:title_words) { boost::to_lower(tw); word_map[tw].title_cnt++; } //这三行代码就是先创建一个存储分好后的词的容器,接着对其进行计数 std::vector<std::string> content_words; ns_util::JiebaUsutl::CutString(doc.content,&content_words); for(auto& cw:content_words) { boost::to_lower(cw); word_map[cw].content_cnt++; } //这三行代码就是先创建一个存储分好后的词的容器,接着对其进行计数 #define X 10 #define Y 1 for(auto& word_pair:word_map) { InvertedElem item;//InvertedElem这个类是专门为了倒排创建的,里面就只有文档的ID,权重和word item.doc_id=doc.doc_id;//这里就是把分好的文档的ID给item item.word=word_pair.first;//这是是把分好的那个词给item item.weight=X*word_pair.second.title_cnt+Y*word_pair.second.content_cnt; //这边就是模拟进行权重的计算 inverted_index[word_pair.first].push_back(item); //这个inverted_index一开始是空的,然后我们不断的push_back就可以人里面存在key和value } return true; } }; Index* Index::instance=nullptr; //std::mutex Index::; std::mutex Index::log; } 愿我的文章对您有所帮助。