文章目录
项目背景
百度,搜狗,360,都做了搜索引擎;包括手机端的头条新闻客户端,也具备了相关的搜索功能
而这些搜索引擎是全网搜索,技术要求太高了,所以我们实现的是站内搜索。
站内搜索只搜索网站内的内容,搜索的数据更垂直(搜索数据具有很强的相关性),数据量更小。
项目搜索要展示的三部分内容:
点击 title,就可以跳转到 url。
项目原因:了解站内搜索的基本原理,以搜索引擎呈现内容的方式呈现 boost 官网的内容;虽然 boost 官网最近实现了站内搜索,但是我觉得从学习站内搜索的原理上来看,自己去实践一下还是很有必要的。
宏观原理
搜索引擎技术栈和项目环境
- 技术栈:
C/C++, C++11, STL, Boost, jsoncpp, cppjieba, cpp-httplib, html, css, js, jQuery, Ajax
- 项目环境:
Centos 7 云服务器,vim/gcc(g++)/Makefile, vscode
正排索引和倒排索引原理
了解正排和倒排索引的特点,来知道它们在搜索引擎中承担的角色。
- 文档1:雷军买了四斤小米
- 文档2:雷军发布了小米手机
正排索引:从文档 ID 找到文档内容(文档内的关键字)
文档 ID | 文档内容 |
---|---|
1 | 雷军买了四斤小米 |
2 | 雷军发布了小米手机 |
扩展:
对目标文档进行 分词 :方便建立倒排索引和查找:
- 文档1:雷军买了四斤小米 --(分词)-- > 雷军/买/四斤/小米/四斤小米
- 文档2:雷军发布了小米手机 --(分词)-- > 雷军/发布/小米/手机/小米手机
注:
停止词:了,的,吗,a,the 等出现频率很高的词,一般在分词的时候可以去掉,因为这些词出场频率太高了,保留下来区分唯一性的价值不大,而且会增加建立索引和搜索的成本。
倒排索引:根据文档内容进行分词,整理不重复的关键字,联系到文档 ID的方案
提取两个文档的关键字,如果提取的关键字已存在,则不提取,只保留一份
关键字(具有唯一性) | 文档 ID |
---|---|
雷军 | 文档1, 文档2 |
买 | 文档1 |
四斤 | 文档1 |
小米 | 文档1, 文档2 |
四斤小米 | 文档1 |
发布 | 文档2 |
手机 | 文档2 |
小米手机 | 文档2 |
模拟一次查找的过程:
用户输入:小米 --> 倒排索引中查找 --> 提取出文档 ID – 1, 2 --> 根据正排索引找到文档内容 --> 对文档内容进行 title + desc + url
的摘要 --> 构建相应结果
其中搜索小米的时候,内容在文档 1, 2 都有,其中先显示谁,就需要根据权值来决定,在上面我们并没有呈现,这块之后再讲解。
下载数据源
boost 官网:
https://www.boost.org/
点击下载,并上传到服务器上:
rz -E
解压:
tar xzf boost_1_85_0.tar.gz
解压的目录中,保存着所有 boost 内容:
/boost_1_85_0/doc/html
是 boost 组件对应的手册内容,这也是项目的数据源:
建立目录存放数据源:
mkdir -p data/input
拷贝数据源到目录下 – 放到 input 下:
cp -rf boost_1_85_0/doc/html/* data/input/
此刻下载的 boost 库和压缩包可以删除了:
rm -r boost_1_85_0/
rm boost_1_85_0.tar.gz
之后就根据 input 下的 html 文件,来建立索引。
数据清洗模块 Parser
概念铺垫
该模块会对 html 文件进行去标签动作,即数据清洗。
补充:
- 标签:
- <> :html 的标签,标签在进行搜索时,是没有价值的,需要去掉这些标签。
把原始数据去标签后,把去标签数据放到 cln_html 目录下:
[lx@VM-4-2-centos data]$ ll
total 20
drwxrwxr-x 2 lx lx 4096 Aug 7 10:24 cln_html # 这里放的是去标签之后的干净文档
drwxrwxr-x 59 lx lx 16384 Aug 7 10:18 input # 这里放的是原始 html 文档
[lx@VM-4-2-centos input]$ ls -R | grep -E ".html" | wc -l
8591
[lx@VM-4-2-centos cln_html]$ touch cln.txt
[lx@VM-4-2-centos cln_html]$ ll
total 0
-rw-rw-r-- 1 lx lx 0 Aug 7 11:15 cln.txt # 放干净文档的内容
Parser 模块目标:把每个文档去标签,写入到同一个文件中;并且方便之后每一次的读取操作。
写入方案:html 文件内部以 ‘\3’ 分割title, content, url,不同文件之间用 ‘\n’ 区分;类似于 title\3content\3url\n
(之后我们会在 ParserContent
中会把文档的 ‘\n’ 全部去掉,保证文档中不会有 ‘\n’,所以用 ‘\n’ 没问题),这样子有个好处,可以用 getline(ifstream, line)
逐次获取各个文档的全部内容,一次 getline 就获取一个文件内容,即title\3content\3url
。
基本结构
Parse.cc
代码结构:
#include <iostream>
#include <string>
#include <vector>
const std::string src_path = "data/input"; // 下面放的是项目的 html 网页
const std::string output = "data/cln_html/cln.txt"; // 放清洗过的数据
typedef struct DocInfo
{
std::string title; // 文档标题
std::string content; // 文档内容
std::string url; // 文档在官网的 url
}DocInfo_t;
bool EnumFile(const std::string& src_path, std::vector<std::string>* files_list);
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results);
bool SaveHtml(const std::vector<DocInfo_t>& results, const std::string& output);
int main()
{
std::vector<std::string> files_list;
// 第一步:递归地把每个 html 文件名带路径保存到 files_list 中, 方便后期对文件进行读取
if (!EnumFile(src_path, &files_list))
{
std::cerr << "enum file name error!" << std::endl;
return 1;
}
// 第二步:按照 files_list 读取每个文件的内容,并进行解析,解析为 DocInfo 格式
std::vector<DocInfo_t> results;
if (!ParseHtml(files_list, &results))
{
std::cerr << "parse html error!" << std::endl;
return 2;
}
// 第三步:把解析完毕的各个文件的内容(results),特定格式写入到 output -- cln.txt 中
if (!SaveHtml(results, output))
{
std::cerr << "save html error" << std::endl;
return 3;
}
return 0;
}
bool EnumFile(const std::string& src_path, std::vector<std::string>* files_list)
{
return true;
}
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results)
{
return true;
}
bool SaveHtml(const std::vector<DocInfo_t>& results, const std::string& output)
{
return true;
}
枚举带路径的 html 文件
C++ 和 stl 对文件系统的支持不是很好,所以要引入 boost 库的 file system
模块:
#include <boost/filesystem.hpp>
boost 开发库的安装:
sudo yum install -y boost-devel
code:
bool EnumFile(const std::string& src_path, std::vector<std::string>* files_list)
{
boost::filesystem::path root_path(src_path);
// 判断文件是否存在,不存在返回假
if (!boost::filesystem::exists(root_path))
{
std::cerr << src_path << " not exists" << std::endl;
return false;
}
// 定义一个空的迭代器,用来判断递归结束
boost::filesystem::recursive_directory_iterator end;
for (boost::filesystem::recursive_directory_iterator iter(root_path); iter != end; iter++)
{
// 文件名筛选,判断是否是 regular file -- 常规文件,不是常规文件 continue
if (!boost::filesystem::is_regular_file(*iter))
{
continue; // 不 push 到 files_list 中,continue 忽略
}
// 判断文件后缀是否为 .html
// path() 获取一个路径对象
if (iter->path().extension() !=".html")
{
continue;
}
std::cout << "debug: " << iter->path().string() << std::endl;
// 当前的路径一定是合法 && 以 html 结尾的普通网页文件
// 把迭代器对应路径转成 string 放入 vector 中
files_list->push_back(iter->path().string());
}
return true;
}
makefile 测试一下现有代码:
cc=g++
parser:parser.cc
$(cc) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11 # 指名第三方库
.PNONY:clean
clean:
rm -f parser
测试结果:
[lx@VM-4-2-centos boost_searcher]$ ./parser | wc -l
8591
每次将文件名放到 files_list 中时,都会 debug 打印有效文件路径,统计打印的总行数;发现打印行数和 data/input 下有效文件个数相同,说明有效文件路径是被一个不漏地放到 files_list 中的。
解析 html 文件
框架:
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results)
{
for (const std::string& file : files_list)
{
std::string result;
// 1. 读取文件 Readfile() 失败直接 continue
if (!ns_util::FileUtil::ReadFile(file, &result))
{
continue;
}
DocInfo_t doc;
// 2. 解析文件,提取 title
// 把解析的内容放到 doc 的 title 中,解析失败, continue
if (!ParseTitle(result, &doc.title))
{
continue;
}
// 3. 解析文件,提取 content
// 提取文档内容,本质就是去标签,把标签去掉后,内容放到 doc 的 content 中
if (!ParseContent(result, &doc.content))
{
continue;
}
// 4. 解析文件路径,构建 url
if (!ParseUrl(file, &doc.url))
{
continue;
}
// 到这里完成了解析任务,当前文档的相关内容都放到了 doc 中
// 右值传过去,push_back 里面就从深拷贝(赋值重载)变为移动赋值,效率提高
results->push_back(std::move(doc));
}
return true;
}
写一个工具集文件:util.hpp
,里面存放各种工具类,目前只有一个 FileUtil
,读文件
#include <iostream>
#include <string>
#include <fstream> // c++ 文件流
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;
}
// 已经打开文件
// 通过 getline 按行读取
std::string line;
while (std::getline(in, line))
{
*out += line;
}
in.close();
return true;
}
};
}
提取 title
:
找到左 title 标签的位置,让指针加上左 title 标签的大小,指针指向提取 title 内容的开头处,从该位置,截取到右 title 标签的起始处。
code:
static bool ParseTitle(const std::string& file, std::string* title)
{
std::size_t begin = file.find("<title>");
if (begin == std::string::npos)
{
return false;
}
std::size_t end = file.find("</title>");
if (end == std::string::npos)
{
return false;
}
begin += std::string("<title>").size();
// 如果 begin > end 说明没有标题,不能截取,虽然 begin == end 也是没有标题,但是下面截取不会出现问题,就当让标题为空,
// 此时和原本默认构造的 title 内容是一样的,也算它对
if (begin > end)
{
return false;
}
*title = file.substr(begin, end - begin);
return true;
}
提取 content
:
本质就是去标签,把有效数据拿出来。
注:content 对应文件的所有正文内容,读取出的 content 里面也会包含 title 的内容 —— 参数 file 就是之前 ReadFile 读取的所有文件内容,上面的
ParseTitle
仅仅是对 title 做提取;而ParseContent
则是对 file 里全部的正文内容进行提取,即从文件开头,去掉所有的标签,提取出正文内容;其中提取的正文内容是包含 title 的,这和ParseTitle
并不冲突(即ParseContent
也可以具有 title 内容)。
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:
// 处于 LABLE 状态时,不需要处理内容,只要判断是否改变状态即可
if (c == '>') s = CONTENT;
break;
case CONTENT:
if (c == '<') s = LABLE; // 如果读到 < 说明把 content 内容读完了
else // 处理内容
{
// 不保留原始文件中的 '\n',因为我们想用 '\n' 作为 html 解析之后的文本的分隔符
if (c == '\n') c = ' ';
content->push_back(c);
}
break;
default:
break;
}
}
return true;
}
在进行遍历时,只要碰到 >
,就意味着当前标签被处理完毕;只要碰到了 <
,就意味着新的标签开始了。
构建 url
:
boost 库的官方文档,和我们下载的文档,是有路径的对应关系的:
https://www.boost.org/doc/libs/1_85_0/doc/html/accumulators.html # 官网的 url
boost_1_85_0/doc/html/accumulators.html # 下载在 Linux 上的文件路径
data/input/accumulators.html # 拷贝到项目中的文件路径
所以构建 url 时,可以将 url_head = https://www.boost.org/doc/libs/1_85_0/doc/html
;再构建 url_tail = /accumulators.html
,也就是把项目路径的 /data/input
干掉。
url = url_head + url_tail # 形成了一个官网链接
code:
static bool ParseUrl(const std::string& file_path, std::string* url)
{
std::string url_head = "https://www.boost.org/doc/libs/1_85_0/doc/html";
std::string url_tail = file_path.substr(src_path.size()); // 从 src_path 处截取,截取的就是 /...html
*url = url_head + url_tail;
return true;
}
此刻,解析 html 文件全部编写完成,测试一下:
// 把 doc 里面的打印一下
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;
}
title, content, url 都有,没问题。
保存数据
该函数需要考虑:将文档内容写入文件中后,之后读取时也要方便操作,故采用如下写入方案。
写入方案:html 文件内部以 ‘\3’ 分割title, content, url,不同文件之间用 ‘\n’ 区分;类似于
title\3content\3url\n
(之后我们会在ParserContent
中会把文档的 ‘\n’ 全部去掉,保证文档中不会有 ‘\n’,所以用 ‘\n’ 没问题),这样子有个好处,可以用getline(ifstream, line)
逐次获取各个文档的全部内容,一次 getline 就获取一个文件全部内容,即title\3content\3url
。
bool SaveHtml(const std::vector<DocInfo_t>& 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;
}
测试:
[lx@VM-4-2-centos boost_searcher]$ ./parser
[lx@VM-4-2-centos boost_searcher]$ cd data/cln_html/
[lx@VM-4-2-centos cln_html]$ vim cln.txt
行数正确,一个 ‘\n’,就是写入一行,8591 行;^c
就是 \3
模块代码
#include <iostream>
#include <string>
#include <vector>
#include <boost/filesystem.hpp>
#include "util.hpp"
const std::string src_path = "data/input"; // 下面放的是所有的 html 网页
const std::string output = "data/cln_html/cln.txt"; // 放清洗过的数据
typedef struct DocInfo
{
std::string title; // 文档标题
std::string content; // 文档内容
std::string url; // 文档的 url
}DocInfo_t;
bool EnumFile(const std::string& src_path, std::vector<std::string>* files_list);
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results);
bool SaveHtml(const std::vector<DocInfo_t>& results, const std::string& output);
int main()
{
std::vector<std::string> files_list;
// 第一步:递归地把每个 html 文件名带路径保存到 files_list 中, 方便后期对文件进行读取
if (!EnumFile(src_path, &files_list))
{
std::cerr << "enum file name error!" << std::endl;
return 1;
}
// 第二步:按照 files_list 读取每个文件的内容,并进行解析,解析为 DocInfo 格式
std::vector<DocInfo_t> results;
if (!ParseHtml(files_list, &results))
{