【项目】Boost 搜索引擎

项目背景

百度,搜狗,360,都做了搜索引擎;包括手机端的头条新闻客户端,也具备了相关的搜索功能

而这些搜索引擎是全网搜索,技术要求太高了,所以我们实现的是站内搜索。

站内搜索只搜索网站内的内容,搜索的数据更垂直(搜索数据具有很强的相关性),数据量更小。

项目搜索要展示的三部分内容:

image-20240806081745204

点击 title,就可以跳转到 url。

项目原因:了解站内搜索的基本原理,以搜索引擎呈现内容的方式呈现 boost 官网的内容;虽然 boost 官网最近实现了站内搜索,但是我觉得从学习站内搜索的原理上来看,自己去实践一下还是很有必要的。

宏观原理

image-20240806094444180

搜索引擎技术栈和项目环境

  • 技术栈: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/

image-20240806152149631

image-20240806152251444

点击下载,并上传到服务器上:

rz -E

解压:

 tar xzf boost_1_85_0.tar.gz

image-20240806153054150

解压的目录中,保存着所有 boost 内容:

image-20240806153514827

/boost_1_85_0/doc/html 是 boost 组件对应的手册内容,这也是项目的数据源

image-20240806153833989

建立目录存放数据源:

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 文件进行去标签动作,即数据清洗。

image-20240807111920317

补充:

  • 标签:

image-20240807080150172

  • <> :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

测试结果:

image-20240808083609833

[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

image-20240808143909643

找到左 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;
}

image-20240809091948492

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 

image-20240810074231013

行数正确,一个 ‘\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))
	{
   
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

安 度 因

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

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

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

打赏作者

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

抵扣说明:

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

余额充值