【项目】Boost 搜索引擎

文章目录

项目背景

百度,搜狗,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 官网:

url 复制代码
https://www.boost.org/

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

shell 复制代码
rz -E

解压:

shell 复制代码
 tar xzf boost_1_85_0.tar.gz

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

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

建立目录存放数据源:

shell 复制代码
mkdir -p data/input

拷贝数据源到目录下 -- 放到 input 下:

shell 复制代码
cp -rf boost_1_85_0/doc/html/* data/input/

此刻下载的 boost 库和压缩包可以删除了:

shell 复制代码
rm -r boost_1_85_0/ 
rm boost_1_85_0.tar.gz

之后就根据 input 下的 html 文件,来建立索引。

数据清洗模块 Parser

概念铺垫

该模块会对 html 文件进行去标签动作,即数据清洗。

补充:

  • 标签:
  • <> :html 的标签,标签在进行搜索时,是没有价值的,需要去掉这些标签。

把原始数据去标签后,把去标签数据放到 cln_html 目录下:

shell 复制代码
[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 代码结构:

cpp 复制代码
#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 模块:

cpp 复制代码
#include <boost/filesystem.hpp> 

boost 开发库的安装:

shell 复制代码
sudo yum install -y boost-devel

code:

cpp 复制代码
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 测试一下现有代码:

make 复制代码
 cc=g++

 parser:parser.cc
	$(cc) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11 # 指名第三方库
.PNONY:clean
clean:
	rm -f parser

测试结果:

cpp 复制代码
[lx@VM-4-2-centos boost_searcher]$ ./parser | wc -l
8591

每次将文件名放到 files_list 中时,都会 debug 打印有效文件路径,统计打印的总行数;发现打印行数和 data/input 下有效文件个数相同,说明有效文件路径是被一个不漏地放到 files_list 中的。

解析 html 文件

框架:

cpp 复制代码
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读文件

cpp 复制代码
#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:

cpp 复制代码
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 内容)。

cpp 复制代码
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 库的官方文档,和我们下载的文档,是有路径的对应关系的:

shell 复制代码
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 干掉。

shell 复制代码
url = url_head + url_tail # 形成了一个官网链接

code:

cpp 复制代码
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 文件全部编写完成,测试一下:

cpp 复制代码
// 把 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

cpp 复制代码
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;
}

测试:

shell 复制代码
[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

模块代码

cpp 复制代码
#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))
	{
		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)
{
	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 -- 常规文件
		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;
}

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 也是没有标题,但是下面截取不会出现问题,也算它对,== 就不处理了
	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 // 在读 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;
}

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;
}

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 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
		// file 就是文件路径
		if (!ParseUrl(file, &doc.url))
		{
			continue;
		}

		// 到这里完成了解析任务,当前文档的相关内容都放到了 doc 中
		results->push_back(std::move(doc)); // bug: 还处理,push_back 本质是拷贝,效率可能比较低

		// for debug
		//ShowDoc(doc);
		//break;
	}

	return true;
}

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;
}

构建索引模块 Index

基本结构

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>

namespace ns_index
{
	struct DocInfo
	{
		std::string title; // 文档标题
		std::string content; // 去标签之后的文档内容
		std::string url; // 文档 url
		uint64_t doc_id; // 文档的 id
	};

	struct InvertedElem
	{
		uint64_t doc_id;
		std::string word;
		int weight;
	};

	// 倒排拉链:拿着关键字,一拿就拿出来一批 InvertedElem
	typedef std::vector<InvertedElem> InvertedList;

	class Index
	{
	private:
		// 正排索引的数据结构用数组实现,数组的下标天然是文档的 id, 把文档内容写到数组中后,数组[下标] 的形式 O(1) 就可以找到文档内容
		std::vector<DocInfo> forward_index; // 正排索引
		// 倒排索引一定是一个关键字和一组(个) InvertedElem 对应 -- 关键字和倒排拉链对应 -- 存放关键字和倒排拉链的映射
		std::unordered_map<std::string, InvertedList> inverted_index;
	public:
		Index() {}
		~Index() {}
	public:
		// 根据 doc_id 找到文档内容
		DocInfo* GetForwardIndex(uint64_t doc_id)
		{
			return nullptr;
		}

		// 根据关键字获取倒排拉链
		InvertedList* GetInveredList(const std::string& word)
		{
			return nullptr;
		}

		// 构建索引 根据去标签,格式化之后的文档,构建正排和倒排索引 -- 数据源为 data/cln_html/cln.txt
		bool BulidIndex(const std::string& input)
		{
			return true;
		}
	};
}

获取正排索引节点

根据 doc_id 找到文档内容

cpp 复制代码
		DocInfo* GetForwardIndex(uint64_t doc_id)
		{
			// doc_id 是被当成数组下标使用的,下标不可能等于 size,只会是 size - 1
			if (doc_id >= forward_index.size())
			{
				std::cerr << "doc_id out range, error!" << std::endl;
				return nullptr;
			}
			return &forward_index[doc_id];
		}

获取倒排拉链

根据关键字 -- string字符串,获取倒排拉链

cpp 复制代码
 		InvertedList* GetInveredList(const std::string& word)
		{
			auto iter = inverted_index.find(word);
			if (iter == inverted_index.end())
			{
				std::cerr << word << " has no InvertedList" << std::endl;
				return nullptr;
			}
			return &(iter->second);
		}

构建索引

根据去标签,内容有特定格式(格式化后)的文档,构建正排和倒排索引;总数据源: data/cln_html/cln.txt,每次读取数据源的一行,进行构建正排和倒排索引

cpp 复制代码
// 构建索引 根据去标签,格式化之后的文档,构建正排和倒排索引 -- 数据源 data/cln_html/cln.txt
		bool BulidIndex(const std::string& input)
		{
			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; // for debug 计数器
			while (std::getline(in, line))
			{
				DocInfo* doc = BuildForwardIndex(line);
				if (doc == nullptr)
				{
					// 失败了,就继续 continue,这行文档就不要了
					std::cerr << "bulid: " << line << " error" << std::endl; // for debug
					continue;
				}

				BulidInvertedIndex(*doc);
				count++;
				if (count % 50 == 0)
				{
					std::cout << "当前已经建立的索引文档" << count << std::endl;
				}
			}
			return true;
		}

构建正排索引

cpp 复制代码
DocInfo* BuildForwardIndex(const std::string& line)
			{
				// 1. 解析 line,字符串切分
				// line 里面有三部分 title content url
				std::vector<std::string> results; // 里面放切分后的结果
				const std::string sep = "\3"; // 行内分隔符
				ns_util::StringUtil::Split(line, &results, sep);
				if (results.size() != 3)
				{
					// 解析 line 失败
					return nullptr;
				}

				// 2. 字符穿填充到 DocInfo
				DocInfo doc;
				doc.title = results[0];
				doc.content = results[1];
				doc.url = results[2];
				doc.doc_id = forward_index.size(); // 即将插入的 doc_id 的值,是当前 forward_index 的 size,
												   // 因为它实际大小为 size - 1,插入后就是 size
												   // 先进行 doc_id 的保存,再插入,相当于提前给它设定了插入后在 vector 中所处的下标							
				// 3. 插入到正排索引的 vector
				forward_index.push_back(doc);
			}

需要在 util.hpp 中新增一个字符串切分的工具类:StringUtil

我们可以使用 findsubstr 进行切分,但是写起来比较复杂;我们项目直接用 boost 中的 split 接口,进行字符串切分。

头文件:

cpp 复制代码
#include <boost/algorithm/string.hpp>

原型:

cpp 复制代码
boost::split(type, select_list, boost::is_any_of(","), boost::token_compress_off)
  • 第一个参数:用于存放切割好的字符串
  • 第二个参数:要切割的字符串
  • 第三个参数 :切割符 -- 分隔符
  • 第四个参数:是一个选项,token_compress_on或者token_compress_off,默认是token_compress_off

如果是token_compress_on:含义就是 将连续多个分隔符压缩为一个, 建议打开!

例如,针对第四个参数: aaa\3\3\3\3bb 如果是token_compress_on:意思就是把字符串压缩成:aaa\3bb,然后切分。

cpp 复制代码
class StringUtil
	{
		public:
			// 需要被切分原字符串 放切分后结果的数组 分隔符
			static void Split(const std::string& target, std::vector<std::string>* out, const std::string& sep)
			{
				// boost split 
				 boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
			}
	};

构建倒排索引

原理

文档:

  • title: 吃葡萄
  • content:吃葡萄不吐葡萄皮
  • url:http://xxxxx
  • doc_id:123

一个个文档处理,一个文档会包含很多 "词",都应当对应到当前的 doc_id

  1. 需要对 titlecontent 分词,大概这么分:

    • title:吃/葡萄/吃葡萄 -- title_word

    • content:吃/葡萄/不吐/葡萄皮/吃葡萄-- content_word

实际上到时候分词我们需要借助 jieba 分词。

  1. 分完之后,词频统计 -- 伪代码:

词频:词汇在标题或内容中出现的次数

cpp 复制代码
// 词频结构体
struct word_cnt
{
    title_cnt;
    content_cnt;
};

// 词和词频结构体的对应关系
unordered_map<string, word_cnt> word_cnt;
// 统计标题词频
for (word : title_word)
{
    word_cnt[word].title_cnt++; // title_cnt:吃 1/葡萄 1/吃葡萄 1
}

// 统计内容词频
for (word : content_word)
{
    word_cnt[word].content_cnt++; // content_cnt:吃 1/葡萄 1/不吐 1/葡萄皮 1/吃葡萄 1
}

这样就知道了,在文档中标题和内容每个词出现的次数 -- 标题和内容分开统计。

注:如果 value 在 title 和 content 都出现了 1 次,则修改 unordered_map 时,会把 title_cnt 和 content_cnt 分别 ++ 一次;

  1. 自定义相关性

针对我们的例子:

cpp 复制代码
for (word : word_cnt)
{
    // 具体一个词和 123 文档的对应关系
    struct InvertedElem elem;
    elem.doc_id = 123;// 这个是例子的 doc_id,同一个文档,都是 123
    elem.word = word.first;
    elem.weight = 10 * word.second.title_cnt + word.second.content_cnt; // 标题当中出现,权重更高.假设在标题出现1次权重为10,在内容出现1次权重为1
	inverted_index[word.first].push_back(elem); // 把 elem 插入到倒排拉链中
}

我们这里的相关性就根据词频统计来计算。

jieba 的使用 ------ cppjieba

克隆到本地:

shell 复制代码
git clone https://gitcode.com/gh_mirrors/cp/cppjieba.git

克隆下来的项目的 lemonp.hpp 是空的,需要到:https://github.com/yanyiwu/limonp/tree/master/include/limonp 下载文件

转到 linux 上,拷贝目录,覆盖之前的空目录。

将库进行调整:

cpp 复制代码
cd cppjieba
cp -rf deps/limonp/ include/cppjieba/

在与 cppjieba 同级目录下建立软连接:

shell 复制代码
ln -s cppjieba/include/cppjieba Cppjieba
ln -s cppjieba/dict dict

cppjieba/test/demo.cpp 下的代码拷到当前目录测试一下:

我们只要测试一个接口:CutForSearch,用也是用这个,所以修改 demo.cpp 代码为:

cpp 复制代码
#include "Cppjieba/Jieba.hpp"
#include <iostream>
#include <string>
#include <vector>

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;

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

  return EXIT_SUCCESS;
}

测试结果:

shell 复制代码
[lx@VM-4-2-centos boost_searcher]$ g++ demo.cpp -std=c++11
[lx@VM-4-2-centos boost_searcher]$ ./a.out 
小明硕士毕业于中国科学院计算所,后在日本京都大学深造
[demo] CutForSearch
小明/硕士/毕业/于/中国/科学/学院/科学院/中国科学院/计算/计算所/,/后/在/日本/京都/大学/日本京都大学/深造

util.hpp 中写一个工具类,类的方法使用 jieba 进行分词。

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";
	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);

构建倒排索引代码:

cpp 复制代码
bool BulidInvertedIndex(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; // 用来暂存词频的映射表

				// 标题分词
				std::vector<std::string> title_words;
				ns_util::JiebaUtil::CutString(doc.title, &title_words);

				// 对标题词频统计
				for (std::string s : title_words)
				{
					boost::to_lower(s);
					word_map[s].title_cnt++; // 存在就直接获取,不存在就新建
				}
				
				// 内容分词
				std::vector<std::string> content_words;
				ns_util::JiebaUtil::CutString(doc.content, &content_words);

				// 对内容词频统计
				for (std::string s : content_words)
				{
					boost::to_lower(s);
					word_map[s].content_cnt++; 
				}

				for (auto &word_pair : word_map)
				{
					InvertedElem item;
					item.doc_id = doc.doc_id;
					item.word = word_pair.first;
                    // 标题当中出现,权重更高.假设在标题出现1次权重为10,在内容出现1次权重为1
					item.weight = 10 * word_pair.second.title_cnt + word_pair.second.content_cnt; // 相关性
					InvertedList& inverted_list = inverted_index[word_pair.first];
					inverted_list.push_back(std::move(item));

				}

				return true;
			}

在进行搜索时,直接输 "HeLLo WoRld",搜索时对大小写是不做区分的。因此,在词频统计时,将词统一转换为小写 --boost::to_lower ,之后在用户搜索时将搜索关键字也转为小写进行搜索即可。

构建为单例模式

去标签化的文档,以正排、倒排的形式加载到内存当中,加载的体积很大; 构建索引时需要调用BuildIndex函数; 在搜索引擎当中,这个索引应该要只有一份;综上所述,所以要将 index 类构建成单例模式。

cpp 复制代码
private:
		Index() {} 
		Index(const Index&) = delete;
		Index& operator=(const Index&) = delete;
		static Index* instance;
		static std::mutex mtx;
	public:
		~Index() {}
	public:
		// 获取单例的函数
		static Index* GetInstance()
		{
			if (nullptr == instance)
			{
				mtx.lock();
				if (nullptr == instance)
				{
					instance = new Index();
				}
				mtx.unlock();
			}
			return instance;
		}

Index* Index::instance = nullptr;
std::mutex Index::mtx;

模块代码

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <fstream>
#include <mutex>
#include "util.hpp"

namespace ns_index
{
	struct DocInfo
	{
		std::string title; // 文档标题
		std::string content; // 去标签之后的文档内容
		std::string url; // 文档 url
		uint64_t doc_id; // 文档的 id
	};

	struct InvertedElem
	{ 
		uint64_t doc_id;
		std::string word;
		int weight;
	};

	// 倒排拉链:拿着关键字,一拿就拿出来一批 InvertedElem
	typedef std::vector<InvertedElem> InvertedList;

	class Index
	{
	private:
		// 正排索引的数据结构用数组实现,数组的下标天然是文档的 id, 把文档内容写到数组中后,数组[下标] 的形式 O(1) 就可以找到文档内容
		std::vector<DocInfo> forward_index; // 正排索引
		// 倒排索引一定是一个关键字和一组(个) InvertedElem 对应 -- 关键字和倒排拉链对应 -- 存放关键字和倒排拉链的映射
		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 mtx;
	public:
		~Index() {}
	public:
		// 获取单例的函数
		static Index* GetInstance()
		{
			if (nullptr == instance)
			{
				mtx.lock();
				if (nullptr == instance)
				{
					instance = new Index();
				}
				mtx.unlock();
			}
			return instance;
		}

		// 根据 doc_id 找到文档内容
		DocInfo* GetForwardIndex(uint64_t doc_id)
		{
			// doc_id 是被当成数组下标使用的,下标不可能等于 size,只会是 size - 1
			if (doc_id >= forward_index.size())
			{
				std::cerr << "doc_id out range, error!" << std::endl;
				return nullptr;
			}
			return &forward_index[doc_id];
		}

		// 根据关键字 string,获取倒排拉链
		InvertedList* GetInveredList(const std::string& word)
		{
			auto iter = inverted_index.find(word);
			if (iter == inverted_index.end())
			{
				std::cerr << word << " has no InvertedList" << std::endl;
				return nullptr;
			}
			return &(iter->second);
		}

		// 构建索引 根据去标签,格式化之后的文档,构建正排和倒排索引 data/cln_html/cln.txt
		bool BulidIndex(const std::string& input)
		{
			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; // for debug 计数器
			while (std::getline(in, line))
			{
				DocInfo* doc = BuildForwardIndex(line);
				if (doc == nullptr)
				{
					// 失败了,就继续 continue,这行文档就不要了
					std::cerr << "bulid: " << line << " error" << std::endl; // for debug
					continue;
				}

				BulidInvertedIndex(*doc);
				

				count++;
				if (count % 50 == 0)
				{
					std::cout << "当前已经建立的索引文档" << count << std::endl;
				}
			}
			return true;
		}
		private:
			DocInfo* BuildForwardIndex(const std::string& line)
			{
				// 1. 解析 line,字符串切分
				// line 里面有三部分 title content url
				std::vector<std::string> results; // 里面放切分后的结果
				const std::string sep = "\3";
				ns_util::StringUtil::Split(line, &results, sep);
				if (results.size() != 3)
				{
					// 解析 line 失败
					return nullptr;
				}

				// 2. 字符穿填充到 DocInfo
				DocInfo doc;
				doc.title = results[0];
				doc.content = results[1];
				doc.url = results[2];
				doc.doc_id = forward_index.size(); // 即将插入的 doc_id 的值,是当前 forward_index 的 size,
												   // 因为它实际大小为 size - 1,插入后就是 size
												   // 先进行 doc_id 的保存,再插入,相当于提前给它设定了插入后在 vector 中所处的下标							
				// 3. 插入到正排索引的 vector
				forward_index.push_back(std::move(doc)); // doc 比较大,move 调用移动版本提高效率

				// 返回最新的 docinfo
				return &(forward_index.back()); 
			}

			bool BulidInvertedIndex(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; // 用来暂存词频的映射表

				// 标题分词
				std::vector<std::string> title_words;
				ns_util::JiebaUtil::CutString(doc.title, &title_words);

				// 对标题词频统计
				for (std::string s : title_words)
				{
					boost::to_lower(s);
					word_map[s].title_cnt++; // 存在就直接获取,不存在就新建
				}
				
				// 内容分词
				std::vector<std::string> content_words;
				ns_util::JiebaUtil::CutString(doc.content, &content_words);

				// 对内容词频统计
				for (std::string s : content_words)
				{
					boost::to_lower(s);
					word_map[s].content_cnt++; // 存在就直接获取,不存在就新建
				}

				for (auto &word_pair : word_map)
				{
					InvertedElem item;
					item.doc_id = doc.doc_id;
					item.word = word_pair.first;
					// 标题当中出现,权重更高.假设在标题出现1次权重为10,在内容出现1次权重为1
					item.weight = 10 * word_pair.second.title_cnt + word_pair.second.content_cnt; // 相关性
					InvertedList& inverted_list = inverted_index[word_pair.first];
					inverted_list.push_back(std::move(item));
				}

				return true;
			}
	};
	Index* Index::instance = nullptr;
	std::mutex Index::mtx;
}

搜索引擎模块 Searcher

搜索关键字会被服务端分词,再进行索引查找:

基本结构

cpp 复制代码
#pragma once

#include "index.hpp"

namespace ns_searcher
{
	class Searcher
	{
		private:
			ns_index::Index* index; // 供系统进行查找的索引
		public:
			Searcher() {}
			~Searcher() {}
		public:
			void InitSearcher(const std::string& input)
			{
				// 1. 获取 index 单例对象
				// 2. 根据 index 对象建立索引
			}
		
		// query: 搜索关键字
		// json_string: 返回给用户浏览器的搜索结果
		void Search(const std::string& query, std::string* json_string)
		{
			// 1.[分词]:对 query 进行按照 searcher 的要求进行分词
			// 2.[触发]:根据分词的各个词,进行 index 查找
			// 3.[合并排序]:汇总查找结果,根据相关性(weight)降序排序
			// 4.[构建]:根据查找出来的结果,构建 json_string
		}
	};
}

初始化

cpp 复制代码
void InitSearcher(const std::string& input)
			{
				// 1. 获取 index 单例对象
				index = ns_index::Index::GetInstance();
				std::cout << "获取 index 单例成功" <<  std::endl; // for debug:测试时可以实时看到调用 InitSearcher 的进度
				// 2. 根据 index 对象建立索引
				index->BulidIndex(input);
				std::cout << "建立正排和倒排索引成功" << std::endl;
			}

搜索

安装 jsoncpp:

shell 复制代码
sudo yum install -y jsoncpp-devel

使用时需要引入头文件:

cpp 复制代码
#include<jsoncpp/json/json.h>

编译时,加上:-ljsoncpp

code:

cpp 复制代码
		// query: 搜索关键字
		// json_string: 返回给用户浏览器的搜索结果
		void Search(const std::string& query, std::string* json_string)
		{
			// 1.[分词]:对 query 进行按照 searcher 的要求进行分词
			std::vector<std::string> words;
			ns_util::JiebaUtil::CutString(query, &words);
			// 2.[触发]:根据分词的各个词,进行 index 查找; query 分词后的词也需要忽略大小写
			ns_index::InvertedList inverted_list_all; // 汇总所有的倒排索引节点
			for (std::string word : words)
			{
				boost::to_lower(word); // 大小写忽略

				ns_index::InvertedList* inverted_list = index->GetInveredList(word); // 根据 word 获取倒排拉链
				if (nullptr == inverted_list) // 没有倒排拉链
				{
					continue; // 没有就检测下一个 word
				}

				// 汇总所有倒排拉链的倒排索引节点
				// 当前这里有个不完美的地方:之后解决
				inverted_list_all.insert(inverted_list_all.end(), inverted_list->begin(), inverted_list->end());
			}
			// 3.[合并排序]:汇总查找结果,根据相关性(weight)降序排序 
			// 只控制了 weight,其他的 doc_id 等成员没有控制,所以只保证权值大的在前面,里面其他的顺序,例如 doc_id 的顺序没有特意规定,所以 doc_id 里面顺序是随机的,但是这不重要,只控制权值就够了
			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;
			});

			// 4.[构建]:根据查找出来的结果,构建 json_string -- jsoncpp -- 完成序列化,反序列化 -- 这里我们就只序列化了,之后还要用 jsoncpp 在其他地方完成反序列化
			Json::Value root;
			for (auto& item : inverted_list_all) // weight大 --> 小
			{
				ns_index::DocInfo* doc = index->GetForwardIndex(item.doc_id); // 根据倒排索引节点的 doc_id,去查正排索引
				if (doc == nullptr)
				{
					continue;
				}
				Json::Value elem;
				// json 子对象的键值对顺序是按照k的,例如 content 在最前面, ascii 码从小到大来的,记住这句话就行,其他别多想,复习的时候谨记这一点
				elem["title"] = doc->title;
				elem["content"] = doc->content; // content 是文档去标签的全部内容,但我们要的是 content 的一部分(摘要)之后解决
				elem["url"] = doc->url;

				// 排序过了,权值是从大到小来的,先遍历的是权重高的,按序追加即可,在 root 中 json 子对象的顺序,就是 sort 之后数组的顺序
				root.append(elem);
			}

			Json::StyledWriter writer; 
			*json_string = writer.write(root);
		}

有个不完美的地方:

例如 query 为雷军小米,分词后为 雷军、小米;之后查倒排,会返回两个相同的倒排拉链(文档 1,文档2),存放到 inverted_list_all 中就是 文档1、文档2、文档1、文档2 的形式,会有重复文档。

所以我们的代码只是简单地对倒排节点进行插入,排序,构建 json 串,而并没有进行去重,所以就有搜索结果重复的潜在问题。

证明(我自己不证明了,用的别人的):

拷贝一个较小的文档,用 test.html 进行测试:

重新编译,然后先运行.parser对所有的html数据源(包括 test.html)进行数据清洗,保存到 cln.txt 中;然后./http_server 进行搜索服务(内部会先构建索引 -- 包括 test.html,然后根据搜索关键字分词,查找)

id 都是相同的,权重应该要合并。

解决:

在searcher.hpp当中添加一个倒排索引打印结构:

cpp 复制代码
struct InvertedElemPrint
{ 
    uint64_t doc_id;//文档id
    int weight;//累加权重
    std::vector<std::string> words;//一个doc_id对应多个关键字
    InvertedElemPrint():doc_id(0), weight(0)
    {}
};
cpp 复制代码
// query: 搜索关键字
		// json_string: 返回给用户浏览器的搜索结果
		void Search(const std::string& query, std::string* json_string)
		{
			// 1.[分词]:对 query 进行按照 searcher 的要求进行分词
			std::vector<std::string> words;
			ns_util::JiebaUtil::CutString(query, &words);
		
			// 2.[触发]:根据分词的各个词,进行 index 查找; query 分词后的词也需要忽略大小写 -- 转为小写 tolower
			//ns_index::InvertedList inverted_list_all; // 汇总所有的倒排索引节点
			
			std::vector<InvertedElemPrint> inverted_list_all;//存放所有经过去重之后的倒排索引节点
			std::unordered_map<uint64_t, InvertedElemPrint> tokens_map; //文件id作为key,进行去重

			
			for (std::string word : words)
			{
				boost::to_lower(word); // 大小写忽略

				ns_index::InvertedList* inverted_list = index->GetInveredList(word); // 根据 word 获取倒排拉链
				if (nullptr == inverted_list) // 没有倒排拉链
				{
					continue; // 没有就检测下一个 word
				}

				// // 汇总所有倒排拉链的倒排索引节点
				// // 当前这里有个不完美的地方:之后解决
				// 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]; // 根据文档 id 获取关键字集合
					item.doc_id = elem.doc_id;
					item.weight += elem.weight; // 累加权值
					item.words.push_back(elem.word); // 把当前关键字添加到集合中
				}
			}

			for(const auto &item : tokens_map)//遍历去重之后的map
			{ 
				inverted_list_all.push_back(std::move(item.second)); //插入 倒排拉链打印节点 到inverted_list_all
			}



			// 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_string -- jsoncpp -- 完成序列化,反序列化 -- 这里我们就只序列化了,之后还要用 jsoncpp 在其他地方完成反序列化
			Json::Value root;
			for (auto& item : inverted_list_all) // weight大 --> 小
			{
				ns_index::DocInfo* doc = index->GetForwardIndex(item.doc_id); // 根据倒排索引节点的 doc_id,去查正排索引
				if (doc == nullptr)
				{
					continue;
				}
				Json::Value elem;
				elem["title"] = doc->title;
				// 原始版本,全部内容,不是摘要
				// elem["content"] = doc->content; // content 是文档去标签的全部内容,但我们要的是 content 的一部分(摘要)之后解决

				//item是经过去重之后的节点, 此时的words是个vector,直接使用words[0]这个关键字获取摘要
				elem["desc"] = GetDesc(doc->content, item.words[0]);
				elem["url"] = doc->url;

				// 排序过了,权值是从大到小来的,先遍历的是权重高的,按序追加即可,在 root 中 json 子对象的顺序,就是 sort 之后数组的顺序
				root.append(elem);
			}

			Json::FastWriter writer; 
			*json_string = writer.write(root); // 把序列化后的结果放到 json_string 中
			//std::cout << (*json_string) << std::endl;
		}

此时可以发现,搜索结果只有一条记录了,并且权值已经累加了!


cpp 复制代码
		// 文档内容 关键字(在摘要中要凸显,忽略大小写)	
std::string GetDesc(const std::string &html_content, const std::string& word)
		 {
			// 找到 word 在 html_content 中的首次出现,往前找 50 字节(如果前面没有 50 个,就从 begin 开始截取)
			// 往后找 100 字节(如果没有 100 字节,到 end 就行),截取出这部分内容

			const int prev_step = 50;
			const int next_step = 100;
			// 1. 找到 word 首次出现 

			// 在这里使用 find 是有问题的,因为 find 区分大小写,我们传过来的 word 是小写的
			// 建立倒排索引时,to_lower 过,倒排索引统一是用小写建立的
			// query 分词之后,去找倒排索引也是 to_lower 找的,拿着小写去找的,用小写来找小写,对应的倒排拉链才能被获取上来
			// 但是 html_content 是原网页内容,是不忽略大小写的
			// 所以 find 关键字(word) 的时候,word 是小写,就有可能 find 不到,所以要用 search 

			auto cmp = [](int x, int y){ return (std::tolower(x) == std::tolower(y)); };
			auto iter = std::search(html_content.begin(), html_content.end(), word.begin(), word.end(), cmp);

			if(iter == html_content.end()) // 这种情况是不可能存在的,但是防御性的写一下
			{ 
				return "None1";
			}
			int pos = std::distance(html_content.begin(), iter);

			// 2. 获取 start, end 位置
			int start = 0;
			int end = html_content.size() - 1;
			// 如果之前有 50 个以上的字符,就更新开始位置
			// 没写 = ,因为等于的话,更新时, start 和 end 的值是不变的,所以没必要更新了,就不写 =
			if (pos - prev_step > start)
			{
				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;
		 }

修改 elem["content"] = doc->content;elem["desc"] = GetDesc(doc->content, item.word);

模块代码

cpp 复制代码
#pragma once

#include "index.hpp"
#include "util.hpp"
#include <algorithm>
#include <jsoncpp/json/json.h>

namespace ns_searcher
{
	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;
				// 2. 根据 index 对象建立索引
				index->BulidIndex(input);
				std::cout << "建立正排和倒排索引成功" << std::endl;
			}
		
		// query: 搜索关键字
		// json_string: 返回给用户浏览器的搜索结果
		void Search(const std::string& query, std::string* json_string)
		{
			// 1.[分词]:对 query 进行按照 searcher 的要求进行分词
			std::vector<std::string> words;
			ns_util::JiebaUtil::CutString(query, &words);
			// 2.[触发]:根据分词的各个词,进行 index 查找; query 分词后的词也需要忽略大小写 -- 转为小写 tolower
			ns_index::InvertedList inverted_list_all; // 汇总所有的倒排索引节点
			for (std::string word : words)
			{
				boost::to_lower(word); // 大小写忽略

				ns_index::InvertedList* inverted_list = index->GetInveredList(word); // 根据 word 获取倒排拉链
				if (nullptr == inverted_list) // 没有倒排拉链
				{
					continue; // 没有就检测下一个 word
				}

				// 汇总所有倒排拉链的倒排索引节点
				// 当前这里有个不完美的地方:之后解决
				inverted_list_all.insert(inverted_list_all.end(), inverted_list->begin(), inverted_list->end());
			}
			// 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;
			});

			// 4.[构建]:根据查找出来的结果,构建 json_string -- jsoncpp -- 完成序列化,反序列化 -- 这里我们就只序列化了,之后还要用 jsoncpp 在其他地方完成反序列化
			Json::Value root;
			for (auto& item : inverted_list_all) // weight大 --> 小
			{
				ns_index::DocInfo* doc = index->GetForwardIndex(item.doc_id); // 根据倒排索引节点的 doc_id,去查正排索引
				if (doc == nullptr)
				{
					continue;
				}
				Json::Value elem;
				elem["title"] = doc->title;
				// 原始版本,全部内容,不是摘要
				// elem["content"] = doc->content; // content 是文档去标签的全部内容,但我们要的是 content 的一部分(摘要)之后解决
				elem["desc"] = GetDesc(doc->content, item.word);
				elem["url"] = doc->url;

				// 排序过了,权值是从大到小来的,先遍历的是权重高的,按序追加即可,在 root 中 json 子对象的顺序,就是 sort 之后数组的顺序
				root.append(elem);
			}

			Json::StyledWriter writer; 
			*json_string = writer.write(root); // 把序列化后的结果放到 json_string 中
			//std::cout << (*json_string) << std::endl;
		}

		// 在摘要中,也要有搜索关键字 word
		 std::string GetDesc(const std::string &html_content, const std::string& word)
		 {
			// 找到 word 在 html_content 中的首次出现,往前找 50 字节(如果前面没有 50 个,就从 begin 开始截取)
			// 往后找 100 字节(如果没有 100 字节,到 end 就行),截取出这部分内容

			const int prev_step = 50;
			const int next_step = 100;
			// 1. 找到 word 首次出现 

			// 在这里使用 find 是有问题的,因为 find 区分大小写,我们传过来的 word 是小写的
			// 建立倒排索引时,to_lower 过,倒排索引统一是用小写建立的
			// query 分词之后,去找倒排索引也是 to_lower 找的,拿着小写去找的,用小写来找小写,对应的倒排拉链才能被获取上来
			// 但是 html_content 是原网页内容,是不忽略大小写的
			// 所以 find word 的时候,word 是小写,就有可能 find 不到,所以要用 search 

			auto cmp = [](int x, int y){ return (std::tolower(x) == std::tolower(y)); };
			auto iter = std::search(html_content.begin(), html_content.end(), word.begin(), word.end(), cmp);

			if(iter == html_content.end()) // 这种情况是不可能存在的,但是防御性的写一下
			{ 
				return "None1";
			}
			int pos = std::distance(html_content.begin(), iter);

			// 2. 获取 start, end 位置
			int start = 0;
			int end = html_content.size() - 1;
			// 如果之前有 50 个以上的字符,就更新开始位置
			// 没写 = ,因为等于的话,更新时, start 和 end 的值是不变的,所以没必要更新了,就不写 =
			if (pos - prev_step > start)
			{
				start = pos - prev_step;
			}

			if (pos + next_step < end)
			{
				end = pos + next_step;
			}
			
			// 3. 截取子串并返回
			if (start >= end) return "None2";
			return html_content.substr(start, end - start);
		 }
	};
}

Http_Server 模块

引入 cpp-httplib

url 复制代码
cpp-httplib库: https://gitee.com/welldonexing/cpp-httplib

cpp-httplib 使用时需要较新版本的 gcc,centos 7 默认 gcc 为 4.8.5

用老的编译器,要么编译不通过,要么直接运行时报错。

升级 gcc

shell 复制代码
sudo yum -y install centos-release-scl  # 1. 安装 scl 源
sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++ # 2. 安装 devtoolset

# 安装后,devtoolset 的位置
[lx@VM-4-2-centos yum.repos.d]$ ls /opt/rh/
devtoolset-7

scl enable devtoolset-7 bash       # 3. 启动工具集:细节,命令行启动只能本会话有效,每次退出登录之后,都会回退到老版本;为了避免每次登录都要手动启动 ,我们把这条命令可以放在 ~/.bash_profile 下,这个是登录的时候默认执行的登录脚本

vim ~/.bash_profile

安装 cpp-httplib

最新的 cpp-httplib 在使用的时候,如果 gcc 不是特别新的话有可能会有运行时错误的问题,所以建议使用:cpp-httplib 0.7.15

shell 复制代码
git clone https://gitee.com/linzhipong/cpp-httplib.git
  • httplib 里面使用了线程库,所以编译的时候需要指明链接 -lpthread

简单使用:

cpp 复制代码
#include "cpp-httplib/httplib.h"
 
int main()
{
    httplib::Server svr;
    svr.Get("/hi",[](const httplib::Request &req, httplib::Response &rsp){
        rsp.set_content("你好,世界!", "text/plain; charset=utf-8");
    });
 
    svr.listen("0.0.0.0", 8888);
 
    return 0;
}

模块代码

创建 web 根目录:

shell 复制代码
mkdir wwwroot

code:

cpp 复制代码
#include "cpp-httplib/httplib.h"
#include "searcher.hpp"
 
const std::string root_path = "./wwwroot"; // web 根目录
const std::string input = "data/cln_html/cln.txt";

int main()
{
    ns_searcher::Searcher search;
    search.InitSearcher(input);
    httplib::Server svr;

    svr.set_base_dir(root_path.c_str()); //设置web根目录
    //以Get的方式给client发送信息
    svr.Get("/s",[&search](const httplib::Request &req, httplib::Response &rsp){
        if (!req.has_param("word"))
        {
            rsp.set_content("必须要有搜索关键字!", "text/plain; charset=utf-8");
            return;
        }
        std::string word = req.get_param_value("word");
        std::cout << "用户正在搜索: " << word << std::endl;
        std::string json_string;
        search.Search(word, &json_string);
        rsp.set_content(json_string, "application/json");
    });
 
	// 设置监听
    svr.listen("0.0.0.0", 7777);
 
    return 0;
}

测试:

前端模块

在 wwwroot 下的 index.html 中进行前端代码的编写。

  • html:网页的骨髓 -- 负责网页结构
  • css:网页的皮肉 -- 负责网页美观
  • javascript:网页的灵魂 --负责动态效果和前后端交互

html

index.html:

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>boost 搜索引擎</title>
</head>
<body>
    <div class="container">
        <div class="search">   
		<input type="text" placeholder="请输入搜索关键字">
            <button>搜索一下</button>
        </div>
        <div class="result">
            <div class="item">
                <a href="#">这是标题</a>
                <p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘
                    要这是摘要这是摘要这是摘要这是摘要</p>
                <i>https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib</i>
            </div>
            <div class="item">
                <a href="#">这是标题</a>
                <p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘
                    要这是摘要这是摘要这是摘要这是摘要</p>
                <i>https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib</i>
            </div>
            <div class="item">
                <a href="#">这是标题</a>
                <p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘
                    要这是摘要这是摘要这是摘要这是摘要</p>
                <i>https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib</i>
            </div>
            <div class="item">
                <a href="#">这是标题</a>
                <p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘
                    要这是摘要这是摘要这是摘要这是摘要</p>
                <i>https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib</i>
            </div>
            <div class="item">
                <a href="#">这是标题</a>
                <p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘
                    要这是摘要这是摘要这是摘要这是摘要</p>
                <i>https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib</i>
            </div>
        </div>
    </div>
</body>
</html>

css

css 设置样式的方式是:选择特定的标签,然后设置该标签的属性,

在 html 代码中内嵌:

html 复制代码
<style>
        /* 去掉网页中的所有的默认内外边距,html的盒子模型 */
        * {
            /* 设置外边距 */
            margin: 0;
            /* 设置内边距 */
            padding: 0;
        }
        /* 将我们的body内的内容100%和html的呈现吻合 */
        html,
        body {
            height: 100%;
        }
        /* 类选择器.container */
        .container {
            /* 设置div的宽度 */
            width: 800px;
            /* 通过设置外边距达到居中对齐的目的 */
            margin: 0px auto;
            /* 设置外边距的上边距,保持元素和网页的上部距离 */
            margin-top: 15px;
        }
        /* 复合选择器,选中container 下的 search */
        .container .search {
            /* 宽度与父标签保持一致 */
            width: 100%;
            /* 高度设置为52px */
            height: 52px;
        }
        /* 先选中input标签, 直接设置标签的属性,先要选中, input:标签选择器*/
        /* input在进行高度设置的时候,没有考虑边框的问题 */
        .container .search input {
            /* 设置left浮动 */
            float: left;
            width: 600px;
            height: 50px;
            /* 设置边框属性:边框的宽度,样式,颜色 */
            border: 1px solid black;
            /* 去掉input输入框的有边框 */
            border-right: none;
            /* 设置内边距,默认文字不要和左侧边框紧挨着 */
            padding-left: 10px;
            /* 设置input内部的字体的颜色和样式 */
            color: #CCC;
            font-size: 14px;
        }
        /* 先选中button标签, 直接设置标签的属性,先要选中, button:标签选择器*/
        .container .search button {
            /* 设置left浮动 */
            float: left;
            width: 150px;
            height: 52px;
            /* 设置button的背景颜色,#4e6ef2 */
            background-color: #4e6ef2;
            /* 设置button中的字体颜色 */
            color: #FFF;
            /* 设置字体的大小 */
            font-size: 19px;
            font-family:Georgia, 'Times New Roman', Times, serif;
        }
        .container .result {
            width: 100%;
        }
        .container .result .item {
            margin-top: 15px;
        }

        .container .result .item a {
            /* 设置为块级元素,单独站一行 */
            display: block;
            /* a标签的下划线去掉 */
            text-decoration: none;
            /* 设置a标签中的文字的字体大小 */
            font-size: 20px;
            /* 设置字体的颜色 */
            color: #4e6ef2;
        }
        .container .result .item a:hover {
            text-decoration: underline;
        }
        .container .result .item p {
            margin-top: 5px;
            font-size: 16px;
            font-family:'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
        }

        .container .result .item i{
            /* 设置为块级元素,单独站一行 */
            display: block;
            /* 取消斜体风格 */
            font-style: normal;
            color: green;
        }
    </style>

javascript

使用原生的JS要求较高,我们采用JQuery框架。JQuery中可以使用ajax来进行前后端交互比如发起http请求:

js 复制代码
<script>
        function Search(){
            // 是浏览器的一个弹出框
            // alert("hello js!");
            // 1. 提取数据, $可以理解成就是JQuery的别称
            let query = $(".container .search input").val();
            console.log("query = " + query); //console是浏览器的对话框,可以用来进行查看js数据

            //2. 使用 ajax 发起http请求,ajax: 属于一个和后端进行数据交互的函数,JQuery中的
            $.ajax({
                type: "GET",
                url: "/s?word=" + query,
                success: function(data){
                    console.log(data);
                    BuildHtml(data);
                }
            });
        }

        function BuildHtml(data){
            // 获取html中的result标签
            let result_lable = $(".container .result");
            // 清空历史搜索结果
            result_lable.empty();

            for( let elem of data){
                // console.log(elem.title);
                // console.log(elem.url);
                let a_lable = $("<a>", {
                    text: elem.title,
                    href: elem.url,
                    // 跳转到新的页面
                    target: "_blank"
                });
                let p_lable = $("<p>", {
                    text: elem.desc
                });
                let i_lable = $("<i>", {
                    text: elem.url
                });
                let div_lable = $("<div>", {
                    class: "item"
                });
                a_lable.appendTo(div_lable);
                p_lable.appendTo(div_lable);
                i_lable.appendTo(div_lable);
                div_lable.appendTo(result_lable);
            }
        }
    </script>

模块代码

cpp 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="http://code.jquery.com/jquery-2.1.1.min.js"></script> /*引入JQuery*/

    <title>boost 搜索引擎</title>
    <style>
        /* 去掉网页中的所有的默认内外边距,html的盒子模型 */
        * {
            /* 设置外边距 */
            margin: 0;
            /* 设置内边距 */
            padding: 0;
        }
        /* 将我们的body内的内容100%和html的呈现吻合 */
        html,
        body {
            height: 100%;
        }
        /* 类选择器.container */
        .container {
            /* 设置div的宽度 */
            width: 800px;
            /* 通过设置外边距达到居中对齐的目的 */
            margin: 0px auto;
            /* 设置外边距的上边距,保持元素和网页的上部距离 */
            margin-top: 15px;
        }
        /* 复合选择器,选中container 下的 search */
        .container .search {
            /* 宽度与父标签保持一致 */
            width: 100%;
            /* 高度设置为52px */
            height: 52px;
        }
        /* 先选中input标签, 直接设置标签的属性,先要选中, input:标签选择器*/
        /* input在进行高度设置的时候,没有考虑边框的问题 */
        .container .search input {
            /* 设置left浮动 */
            float: left;
            width: 600px;
            height: 50px;
            /* 设置边框属性:边框的宽度,样式,颜色 */
            border: 1px solid black;
            /* 去掉input输入框的有边框 */
            border-right: none;
            /* 设置内边距,默认文字不要和左侧边框紧挨着 */
            padding-left: 10px;
            /* 设置input内部的字体的颜色和样式 */
            color: #CCC;
            font-size: 14px;
        }
        /* 先选中button标签, 直接设置标签的属性,先要选中, button:标签选择器*/
        .container .search button {
            /* 设置left浮动 */
            float: left;
            width: 150px;
            height: 52px;
            /* 设置button的背景颜色,#4e6ef2 */
            background-color: #4e6ef2;
            /* 设置button中的字体颜色 */
            color: #FFF;
            /* 设置字体的大小 */
            font-size: 19px;
            font-family:Georgia, 'Times New Roman', Times, serif;
        }
        .container .result {
            width: 100%;
        }
        .container .result .item {
            margin-top: 15px;
        }

        .container .result .item a {
            /* 设置为块级元素,单独站一行 */
            display: block;
            /* a标签的下划线去掉 */
            text-decoration: none;
            /* 设置a标签中的文字的字体大小 */
            font-size: 20px;
            /* 设置字体的颜色 */
            color: #4e6ef2;
        }
        .container .result .item a:hover {
            text-decoration: underline;
        }
        .container .result .item p {
            margin-top: 5px;
            font-size: 16px;
            font-family:'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
        }

        .container .result .item i{
            /* 设置为块级元素,单独站一行 */
            display: block;
            /* 取消斜体风格 */
            font-style: normal;
            color: green;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="search">
         <input type="text" placeholder="请输入搜索关键字">
            <button onclick="Search()">搜索一下</button> <!-- 点击搜索一下就执行 Search 函数 -->
        </div>
        <div class="result">
            <!-- 动态生成网页内容 -->
        </div>
    </div>
    <script>
        function Search(){
            // 是浏览器的一个弹出框
            // alert("hello js!");
            // 1. 提取数据, $可以理解成就是JQuery的别称
            let query = $(".container .search input").val();
            console.log("query = " + query); //console是浏览器的对话框,可以用来进行查看js数据

            //2. 发起http请求,ajax: 属于一个和后端进行数据交互的函数,JQuery中的
            $.ajax({
                type: "GET",
                url: "/s?word=" + query,
                success: function(data){
                    console.log(data);
                    BuildHtml(data);
                }
            });
        }

        function BuildHtml(data){
            // 获取html中的result标签
            let result_lable = $(".container .result");
            // 清空历史搜索结果
            result_lable.empty();

            for( let elem of data){
                // console.log(elem.title);
                // console.log(elem.url);
                let a_lable = $("<a>", {
                    text: elem.title,
                    href: elem.url,
                    // 跳转到新的页面
                    target: "_blank"
                });
                let p_lable = $("<p>", {
                    text: elem.desc
                });
                let i_lable = $("<i>", {
                    text: elem.url
                });
                let div_lable = $("<div>", {
                    class: "item"
                });
                a_lable.appendTo(div_lable);
                p_lable.appendTo(div_lable);
                i_lable.appendTo(div_lable);
                div_lable.appendTo(result_lable);
            }
        }
    </script>
</body>
</html>

日志模块

模块代码:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <ctime>//时间

//日志等级
#define NORMAL  1
#define WARNING 2   
#define DEBUG   3 
#define FATAL   4   //致命错误

//在宏参当中带#,可以将宏名称转为字符串      __FILE__ 获取文件名      __LINE__ 获取行号
//想打日志直接使用LOG即可,传入日志等级和日志消息
#define LOG(LEVEL, MESSAGE) log(#LEVEL, MESSAGE, __FILE__, __LINE__)    

//一旦出问题了,我们最想知道在哪个文件内部,第几行,出现了什么错误
void log(std::string level, std::string message, std::string file, int line)
{
    //time(nullptr):输出的是时间戳
    std::cout << "[" << level << "]" << "[" << time(nullptr) << "]" 
        << "[" << message << "]" << "[" << file << " : " << line << "]" << std::endl;
}

例如,在 index.hpp 中使用日志功能:

std::cout <<"当前已经建立的索引文档: " << count <<std::endl; --> LOG(NORMAL, "当前的已经建立的索引文档: " + std ::to_string(count));

日志功能可加可不加,我就不加了。

结项

项目演示时:先 ./parser,再 ./http_server

项目拓展方向:

  1. 建立整站搜索 -- 我们只进行了 doc/html 下的 html 文件搜索
  2. 设计一个在线更新的方案,信号,爬虫,完成整个服务器的设计
  3. 不使用组件,自己设计一下对应的各种方案
  4. 在我们的搜索引擎中,添加竞价排名
  5. 热次统计,智能显示搜索关键词(字典树,优先级队列)
  6. 设置登陆注册,引入对mysql的使用
相关推荐
Lill_bin1 天前
ElasticSearch底层原理解析
大数据·分布式·elasticsearch·搜索引擎·zookeeper·云原生·jenkins
Chen_leilei2 天前
ES机制原理
大数据·elasticsearch·搜索引擎
西岭千秋雪_2 天前
谷粒商城のElasticsearch
java·大数据·服务器·spring boot·elasticsearch·搜索引擎
小魏冬琅3 天前
如何通过编程工具提升工作效率
大数据·elasticsearch·搜索引擎
檀越剑指大厂3 天前
【Elasticsearch系列七】索引 crud
大数据·elasticsearch·搜索引擎
苹果cms插件专题4 天前
苹果CMS海洋CMS那个更容易被百度收录?苹果CMS站群
搜索引擎·百度
进击的C语言4 天前
【PyCharm】和git安装教程
python·搜索引擎·pycharm·数据分析
白鹿贞松4 天前
COMP 6714-Info Retrieval and Web Search笔记week1
笔记·python·搜索引擎
檀越剑指大厂4 天前
【Elasticsearch系列二】安装 Kibana
大数据·elasticsearch·搜索引擎
Chen_leilei4 天前
Elasticsearch知识点整理
大数据·elasticsearch·搜索引擎