今天讲的项目是基于C++的Boost库的站内搜索引擎。因为Boost库内没有搜索关键字功能,所以在这里我们来手动实现一个这样的搜索引擎。当用户在输入框输入要查询的关键字后,就会快速查询出相关的 boost 库中的文档,弥补 boost 在线文档没有搜索功能的缺陷。
目录
项目介绍
开发环境
项目流程
搜索引擎的相关宏观原理
项目代码
parser.cc
index.hpp
searcher.hpp
log.hpp
util.hpp
Makefile
debug.cc
http_server.cc
wwwroot/index.html
项目演示
界面
搜索测试
CentOS7、vim、g++、Makefile、vscode
1、编写数据去标签与数据清洗模块,将原 html 文档解析成一个行文本文件。
2、读取处理好的行文本进行分词,权重计算等操作,在内存中构建出正排索引和倒排索引。
3、对查询词进行分词、触发,依据查询权重值对结果对结果进行排序,并以 Json 格式序列化为字符串返回。4、通过 HTTP 服务器搭建搜索页面,为外部提供服务。
客户端把要搜索的关键字通过GET传参方式交给服务端的searcher模块,searcher根据提前建立好的索引,通过用户关键字查找到对应的内容,构建成网页返回给用户。
#include
#include
#include
#include
#include
#include "util.hpp"
//是一个目录,下面放的是源未处理的所有html网页
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;
}DocInfo_t;
//const & 输入
//* 输出
//& 输入输出
bool EnumFile(const std::string& src_path, std::vector* file_list);
bool ParselfHtml(const std::vector& file_list, std::vector* results);
bool SaveHtml(const std::vector& results, const std::string& output);
int main()
{
//第一步: 递归式的把每个html文件名带路径,保存到file_list中, 方便后期一个一个的文件读取
std::vector files_list;
if(!EnumFile(src_path, &files_list))
{
std::cerr << "enum file error!" << std::endl;
return 1;
}
//第二步: 按照file_list读取的每个文件的内容, 并进行操作
std::vector results;
if(!ParselfHtml(files_list, &results))
{
std::cerr << "parsr html error!" << std::endl;
return 2;
}
//第三步: 把解析完的各个文件内容, 写入到output, 按照\3作为每个文档的分隔符
if(!SaveHtml(results, output))
{
std::cerr << "save html error!" << std::endl;
return 3;
}
return 0;
}
//处理该路径下的所有文件, 一般的读文件操作处理这种大批的不适用,所以借用boos库中的
bool EnumFile(const std::string& src_path, std::vector* files_list)
{
// bool exists(const path& p); //path特是一个命名空间中的类型
namespace fs = boost::filesystem;
fs::path root_path(src_path);
//判断路径是否存在, 如果不存在就没必要往后走了
if(!fs::exists(root_path)) //没找到
{
std::cerr << src_path << " not exists" << std::endl;
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
{
continue;
}
if(iter->path().extension() != ".html") //判断文件路径名后缀是否符合要求
{
continue;
}
//当前带路径一定是一个合法的, 以.html结束的普通文件
//将所有带路径的html保存在file_list, 方便后续进行文本分析
files_list->push_back(iter->path().string());
}
return true;
}
static bool ParseTitle(const std::string file, std::string* title)
{
size_t begin = file.find("");
if(begin == std::string::npos)
{
return false;
}
size_t end = file.find(" ");
if(end == std::string::npos)
{
return false;
}
begin += std::string("").size(); //标题部分
if(begin > end)
{
return false;
}
*title = file.substr(begin, end - begin); //相减的话, 左闭右开
return true;
}
static bool ParseContent(const std::string file, std::string* content)
{
//去标签, 基于一个简易的状态机
enum status
{
LABLE, //不读
CONTENT //有可能读
};
enum status s = LABLE;
for(char c : file)
{
switch(s)
{
case LABLE:
if(c == '>')
{
s = CONTENT;
}
break;
case CONTENT:
if(c == '<')
{
s = LABLE;
}
else
{
//不保留原始文件中的\n, 因为我们想用\n作为html解析之后的文本分隔符
if(c == '\n')
{
c = ' ';
}
content->push_back(c);
}
break;
default:
break;
}
}
return true;
}
static bool ParseUrl(const std::string file_path, std::string* url)
{
std::string url_head = "https://www.boost.org/doc/libs/1_82_0/doc/html";
std::string url_tail = file_path.substr(src_path.size());
*url = url_head + url_tail; //形成新的官网链接
return true;
}
static void ShowDoc(const DocInfo_t& doc)
{
std::cout << "title: " << doc.title << std::endl;
std::cout << "content: " << doc.content << std::endl;
std::cout << "url: " << doc.url << std::endl;
}
bool ParselfHtml(const std::vector& files_list, std::vector* results)
{
for(const std::string& file : files_list)
{
// 1、读取文件
std::string result;
if(!ns_util::FileUtil::ReadFile(file, &result))
{
continue;
}
DocInfo_t doc;
// 2、解析指定的文件, 提取title
if(!ParseTitle(result, &doc.title))
{
continue;
}
// 3、解析指定文件的content,就是去标签
if(!ParseContent(result, &doc.content))
{
continue;
}
// 4、解析指定的文件路径, 构建url
// html中自己有跳转,但是自己没有url,需要自己拼接
if(!ParseUrl(file, &doc.url)) //file是当前文本路径内容
{
continue;
}
//完成了解析任务
results->push_back(std::move(doc)); //move提高效率,move以后变成右值了->移动构造
// ShowDoc(doc);
// break;
}
return true;
}
bool SaveHtml(const std::vector& results, const std::string& output)
{
#define SEP '\3'
//按照二进制方法进行写入
std::ofstream out(output, std::ios::out | std::ios::binary);
if(!out.is_open())
{
std::cerr << "open " << output << " failed!" << std::endl;
return false;
}
//写文件内容
for(auto& item : results)
{
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;
}
#pragma once
#include
#include
#include
#include
#include
#include
#include"log.hpp"
#include"util.hpp"
namespace ns_index
{
struct DocInfo
{
std::string title; //文档标题
std::string content; //文档对应的去标签之后的内容
std::string url; //官网文档url
int doc_id; //文档的ID, 暂时不做过多理解
};
struct InvertedElem
{
uint64_t doc_id; //文档id
std::string word; //关键字
int weight; //文档权重
};
//倒排拉链
typedef std::vector InvertedList;
class Index
{
private:
//正排索引的数据结构用数组, 数组的下标天然是文档的ID
std::vector forward_index; //正排索引
//倒排索引一定是一个关键字和一个组InvertedElem对应[关键字和倒排拉链映射关系]
std::unordered_map inverted_index;
private:
Index() //设置成单例模式
{}
Index(const Index& ) = delete;
Index& operator=(const Index&) = delete; //传过来的Index是否为const都会被禁止
static Index* instance; //单例指针
static std::mutex mtx;
public:
static Index* GetInstance() //对外提供获取单例对象方法
{
if(instance == nullptr)
{
mtx.lock();
if(instance == nullptr)
{
instance = new Index(); //返回创建好对象的地址
}
mtx.unlock();
}
return instance;
}
//根据doc_id找到文档内容
DocInfo* GetForwardIndex(uint64_t doc_id)
{
if(doc_id >= forward_index.size())
{
std::cerr << "doc_id out range, error!" << std::endl;
return nullptr;
}
return &forward_index[doc_id];
}
//根据关键字string, 获得倒排拉链
InvertedList* GetInvertedList(const std::string& word)
{
//std::unordered_map::iterator
auto iter = inverted_index.find(word);
if(iter == inverted_index.end())
{
std::cerr << word << " have no InvertedList" << std::endl;
return nullptr;
}
return &(iter->second);
}
//根据去标签, 格式化之后的文档, 构建正排和倒排索引
// data/raw_html/raw.txt
bool BuildIndex(const std::string& input) //prase处理完毕的数据交给我
{
std::ifstream in(input, std::ios::in | std::ios::binary);
if(!in.is_open())
{
std::cerr << "sorry, " << input << " open error" << std::endl;
return false;
}
std::string line;
int count = 0;
while(std::getline(in, line))
{
DocInfo* doc = BuildForwardIndex(line);
if(doc == nullptr)
{
std::cerr << "build " << line << " error" << std::endl;
continue;
}
BuildInvertedIndex(*doc);
count++;
if(count % 50 == 0)
{
//std::cout << "当前已经建立的索引文档: " << count << std::endl;
LOG(NORMAL, "当前已经建立的搜索文档: " + std::to_string(count));
}
}
in.close();
return true;
}
private:
//编写正排索引
DocInfo* BuildForwardIndex(const std::string& line)
{
// 1、解析line, 字符串切分
//line -> 3 string title, content, url
std::vector results;
std::string sep = "\3";
ns_util::StringUtil::Split(line, &results, sep);
if(results.size() != 3)
{
return nullptr;
}
//2、 字符串进行填充到DocInfo
DocInfo doc;
doc.title = results[0];
doc.content = results[1];
doc.url = results[2];
//先进行保存id, 再插入, 对应的id就是当前doc在vector中的下标
doc.doc_id = forward_index.size();
//3、插入到正排索引的vector
forward_index.push_back(std::move(doc)); //doc.html文件文件内容比较大, move可以提高效率
return &forward_index.back(); //返回插入doc的地址, 因为是拷贝过去的, 不能返回&doc!!
}
//编写倒排索引
bool BuildInvertedIndex(const DocInfo& doc)
{
//DocInfo(title, content, url, doc_id)
//word -> 倒排拉链
struct word_cnt
{
int title_cnt;
int content_cnt;
word_cnt()
:title_cnt(0)
,content_cnt(0)
{}
};
std::unordered_map word_map; //用来暂存词频的映射表
//对标题进行分词
std::vector title_words;
ns_util::JiebaUtil::CutString(doc.title, &title_words);
//对标题进行词频统计
for(auto s : title_words)
{
boost::to_lower(s); //将我们的分词进行统一转换成小写
word_map[s].title_cnt++;
}
//对文档内容进行分词
std::vector content_words;
ns_util::JiebaUtil::CutString(doc.content, &content_words);
for(auto s : content_words)
{
boost::to_lower(s); //将我们的分词进行统一转换成小写
word_map[s].content_cnt++;
}
//接下来以小写的形式统计词频, 并进行倒排拉链
#define X 10
#define Y 1
//Hello, hello, HELLO 不区分大小写, 搜索时不区分
for(auto& word_pair : word_map)
{
InvertedElem item;
item.doc_id = doc.doc_id; //对应数组下标
item.word = word_pair.first;
item.weight = X*word_pair.second.title_cnt + Y*word_pair.second.content_cnt; //相关性
//typedef std::vector InvertedList;
//一个string对应一批关键字和倒排拉链的关系, 返回的是一个vector
//每次不同的html文档遍历时, 每一个词对应插入进去:
// 后面的文档有相同的词时,就直接在数组中插入文档id;没有相同的词,就当第一次出现的词插入
InvertedList& inverted_list = inverted_index[word_pair.first];
inverted_list.push_back(item);
}
return true;
}
};
Index* Index::instance = nullptr; //静态成员变量类外初始化
std::mutex Index::mtx;
}
//统计词频时, 标题出现的词,在正文中也会匹配到的话,会被当做content中的词多被统计一次
//实际页面又写了一遍标题(有的有有的html没有),当做content
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include"index.hpp"
#include"util.hpp"
#include"log.hpp"
namespace ns_searcher
{
struct InvertedElemPrint
{
uint64_t doc_id;
int weight;
std::vector words; //多个词对应一个文档id
InvertedElemPrint()
:doc_id(0)
,weight(0)
{}
};
class Searcher
{
private:
ns_index::Index* index; //供系统进行查找的索引
public:
Searcher()
{}
~Searcher()
{}
public:
void InitSearcher(const std::string& input)
{
// 1、获取或创建index对象
index = ns_index::Index::GetInstance();
//std::cout << "获取index单例成功..." << std::endl;
LOG(NORMAL, "获取index单例成功...");
//2、根据index对象建立索引
index->BuildIndex(input);
//std::cout << "建立正排和倒排索引成功..." << std::endl;
LOG(NORMAL, "建立正排和倒排索引成功...");
}
//query: 搜索关键字
//json_string: 返回给用户浏览器的搜索结果
void Search(const std::string& query, std::string* json_string)
{
//1、[分词]: 对我们query进行按照searcher的要求进行分词
std::vector words;
ns_util::JiebaUtil::CutString(query, &words);
//2、[触发]: 就是根据分词的各个"词", 进行index差找
//倒排拉链
// typedef std::vector InvertedList;
// ns_index::InvertedList inverted_list_all; //内部是InvertedElem
//去重优化写法
std::vector inverted_list_all;
std::unordered_map tokens_map; //去重
for(std::string word : words)
{
boost::to_lower(word);
ns_index::InvertedList* inverted_list = index->GetInvertedList(word);
if(inverted_list == nullptr)
{
continue;
}
//不完美的地方: 文档ID会有重复
//你/是/一个/好人/ 不同的词会对应各自的文档ID,这些词的ID就会重复显示
//所以内容也会重复,在这里要进行去重
//inverted_list_all.insert(inverted_list_all.end(), inverted_list->begin(), inverted_list->end());
//去重优化算法
for(const auto& elem : *inverted_list)
{
auto& item = tokens_map[elem.doc_id];
//item一定是doc_id相同的的print结点
item.doc_id = elem.doc_id; //保证id相同
item.weight += elem.weight; //搜索的词分词后,多个词匹配到同一个文档,将它们的权重相加
item.words.push_back(elem.word);
}
}
//遍历把去重后的文档加入
for(const auto& item : tokens_map)
{
inverted_list_all.push_back(std::move(item.second));
}
//3、[合并排序]: 汇总查找结果, 按照相关性(weight)降序排序
// std::sort(inverted_list_all.begin(), inverted_list_all.end(),
// [](const ns_index::InvertedElem& e1, const ns_index::InvertedElem& e2)
// {return e1.weight > e2.weight;});
//结合去重优化的更新
std::sort(inverted_list_all.begin(), inverted_list_all.end(), [](const InvertedElemPrint& e1, const InvertedElemPrint& e2){
return e1.weight > e2.weight;
});
//4、[构建]: 根据查找出来的相关结果, 构建json串 -- jsoncpp
Json::Value root;
for(auto& item : inverted_list_all)
{
ns_index::DocInfo* doc = index->GetForwardIndex(item.doc_id); //对应文档基本信息找到
if(doc == nullptr)
{
continue;
}
Json::Value elem;
elem["title"] = doc->title;
//item.word是搜索关键字
//content是去标签的一部分,但是不是我们想要的(摘要)
//elem["desc"] = GetDesc(doc->content, item.word);
//结合去重优化的更新
elem["desc"] = GetDesc(doc->content, item.words[0]); //[0]肯定存在 根据分的第一个词获取摘要
elem["url"] = doc->url;
//for debug, for delete --- 用户不需要这两行
elem["id"] = (int)item.doc_id;
elem["weight"] = item.weight;
root.append(elem);
}
Json::StyledWriter writer;
//Json::FastWriter writer;
*json_string = writer.write(root);
}
std::string GetDesc(const std::string& html_content, const std::string word)
{
//找到word在html_content中的首次出现, 然后往前找50字节(如果没有, 从begin开始)
//然后往后找100字节(如果没有, 到end就可以), 截出这部分
const int prev_step = 50;
const int next_step = 100;
//1、找到首次出现
auto iter = std::search(html_content.begin(), html_content.end(), word.begin(), word.end(), [](int x, int y){
return std::tolower(x) == std::tolower(y); //tolower/toupper参数是int
});
if(iter == html_content.end())
{
return"None1";
}
//拿到first(开头)到iter迭代器间的个数 --->下标位置
int pos = std::distance(html_content.begin(), iter); //获取下标位置
//word是处理过的小写词,再到原网页中找,可能有匹配不到大写的!!
//这里是不能使用find,这是有坑的!!
// size_t pos = html_content.find(word); //find是精准匹配
// if(pos == std::string::npos)
// {
// return "None1";
// }
//2、获取start, end
int start = 0;
int end = html_content.size()-1;
//如果之前有50+字符, 就更新开始位置
if(pos > start + prev_step) //这里有一个大坑, size_t是一个无符号整数,所以换成int更好解决繁琐问题
{
start = pos - prev_step;
}
if(pos < end - next_step)
{
end = pos + next_step;
}
// if(pos-prev_step > start) //这里有一个大坑,size_t是一个无符号整数
// {
// start = pos-prev_step;
// }
// if(pos+next_step < end)
// {
// end = pos+next_step;
// }
//3、截取子串
if(start >= end)
{
return "None2";
}
//摘要部分加最后
std::string desc = html_content.substr(start, end-start);
desc += "...";
return desc;
}
};
}
#pragma once
#include
#include
#include
#define NORMAL 1
#define WARNING 2
#define DEBUG 3
#define FATAL 4
//#... 宏名称转字符串
//在调用的地方就会进行宏替换(#写在后面)
#define LOG(LEVEL, MESSAGE) log(#LEVEL, MESSAGE, __FILE__, __LINE__)
//级别 文件内容信息 哪个文件 哪一行
void log(std::string level, std::string message, std::string file, int line)
{
std::cout << "[" << level << "]" << "[" << time(nullptr) << "]"
<< "[" << message << "]" << "[" << file << " : " << line << "]" << std::endl;
}
#pragma once
#include
#include
#include
#include
#include
#include"cppjieba/include/cppjieba/Jieba.hpp"
#include
#include
#include"log.hpp"
namespace ns_util
{
class FileUtil
{
public:
static bool ReadFile(const std::string& file_path, std::string* out)
{
std::ifstream in(file_path, std::ios::in);
if(!in.is_open())
{
std::cerr << "open file " << file_path << " error" << std::endl;
return false;
}
std::string line;
while(std::getline(in, line))
{
*out += line;
}
in.close();
return true;
}
};
class StringUtil
{
public:
static void Split(const std::string& target, std::vector* out, const std::string& sep)
{
// boost::split 不建议使用strtok
// aaa\3bbb\3\3\3ccc
boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
}
};
//按照demo.cpp写
//词库路径
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";
// load这个文件本身就是需要load一次的行为, 所以最好设计成单例模式
// class JiebaUtil
// {
// private:
// cppjieba::Jieba jieba;
// std::unordered_map stop_words;
// static JiebaUtil* instance;
// static std::mutex mtx;
// private:
// JiebaUtil() //构造函数初始化
// :jieba(DICT_PATH,HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH)
// {}
// JiebaUtil(const JiebaUtil&) = delete;
// JiebaUtil operator=(const JiebaUtil&) = delete;
// public:
// static JiebaUtil* get_instance()
// {
// if(instance == nullptr)
// {
// mtx.lock();
// if(instance == nullptr)
// {
// instance = new JiebaUtil(); //堆上开辟对象
// instance->InitJiebaUtil();
// }
// mtx.unlock();
// }
// return instance;
// }
// void InitJiebaUtil() //这个instance(静态指针)指向的对象不是静态的,所以这里可以调用
// {
// std::ifstream in(STOP_WORD_PATH);
// if(!in.is_open())
// {
// //std::cout << "load stop words file error" << std::endl;
// LOG(FATAL, "load stop words file error");
// return;
// }
// std::string line;
// while(std::getline(in, line))
// {
// stop_words.insert({line, true}); //添加记录暂停词
// }
// in.close();
// }
// void CutStringHelper(const std::string& src, std::vector* out)
// {
// jieba.CutForSearch(src, *out);
// for(auto iter = out->begin(); iter != out->end(); ) //不适合用下标遍历,不能一味的iter++,要考虑迭代器失效问题
// {
// auto it = stop_words.find(*iter);
// if(it != stop_words.end())
// {
// //说明当前的string是暂停词, 需要去掉
// //注意迭代器失效的问题
// iter = out->erase(iter);
// }
// else
// {
// iter++;
// }
// }
// }
// static void CutString(const std::string& src, std::vector* out)
// {
// get_instance()->CutStringHelper(src, out);//静态成员函数调用静态..
// }
// };
// JiebaUtil* JiebaUtil::instance = nullptr;
// std::mutex JiebaUtil::mtx;
//静态对象初始化
//cppjieba::Jieba JiebaUtil::jieba(DICT_PATH,HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH);
//
//不去掉暂停词的版本
//load这个文件本身就是需要load一次的行为,所以最好设计成单例模式
class JiebaUtil
{
private:
static cppjieba::Jieba jieba;
public:
static void CutString(const std::string& src, std::vector* out)
{
jieba.CutForSearch(src, *out);
}
};
//静态对象初始化
cppjieba::Jieba JiebaUtil::jieba(DICT_PATH,HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH);
}
.PHONY:all
all:parser debug http_server
parser:parser.cc
g++ -o $@ $^ -std=c++11 -lboost_system -lboost_filesystem
debug:debug.cc
g++ -o $@ $^ -std=c++11 -ljsoncpp
http_server:http_server.cc
g++ -o $@ $^ -std=c++11 -lpthread -ljsoncpp
.PHONY:clean
clean:
rm -rf parser debug http_server
#include
#include
#include
#include"searcher.hpp"
const std::string input = "data/raw_html/raw.txt"; //处理完的文本的路径
int main()
{
// for test
ns_searcher::Searcher* search = new ns_searcher::Searcher();
search->InitSearcher(input);
char buffer[1024];
std::string query;
std::string json_string;
while(true)
{
std::cout << "Plase Enter You Search Query# ";
//std::cin >> query; //有bug, 读到空格或者换行符就不连续了
fgets(buffer, sizeof(buffer)-1, stdin); //-1是为了预留\0位置
buffer[strlen(buffer)-1] = 0; //strlen计算到\0就停止,取消换行符
query = buffer;
search->Search(query, &json_string);
std::cout << json_string << std::endl;
}
return 0;
}
#include"searcher.hpp"
#include"cpp-httplib-v0.7.15/httplib.h"
#include
#include
#include"log.hpp"
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::Server svr;
svr.set_base_dir(root_path.c_str()); //指明外部根目录在这里
// "/s?"后面跟参数
svr.Get("/s", [&search](const httplib::Request& req, httplib::Response& rep)
{
//rep.set_content("hello world", "text/plain; charset=utf-8");
//根据浏览器get传参特性
if(!req.has_param("word")) // "word="后面跟关键字, has_param判断是否有该关键字
{
rep.set_content("必须要有关键字", "text/plain; charset=utf-8");
return;
}
std::string word = req.get_param_value("word"); //提取请求参数
//std::cout << "用户在搜索: " << word << std::endl;
LOG(NORMAL, "用户搜索的: " + word);
std::string json_string;
search.Search(word, &json_string);
rep.set_content(json_string, "application/json");
});
LOG(NORMAL, "服务器启动成功...");
svr.listen("0.0.0.0", 7781); //"0.0.0.0" 接收任意ip
return 0;
}
boost 搜索引擎
项目链接:http://119.23.208.209:7781/
点击跳转
看到这里,给博主点个赞吧~