详细介绍:【项目设计】基于正倒排索引的Boost搜索引擎

文章目录


1. 项目背景

在大型搜索领域,例如某度、某狗、某 60、某条等公司,都拥有强大的搜索引擎系统。以我们目前的资源与规模,要实现类似的通用搜索引擎几乎是不可能的。

然而,对于站内搜索(Site Search)来说,场景更加垂直、数据范围相对有限,我们完全可以自行实现。

例如,Boost 官方网站本身并没有提供站内搜索功能,因此我们需要基于 Boost 文档内容,自行构建一个站内搜索引擎,以便用户能够快速检索相关的技术资料与示例。

我们平时搜索内容时,呈现给我们的主要是由三部分构成:title + 摘要 + url,所以我们要实现的 Boost 搜索引擎也是需要给呈现出这三个部分。

在这里插入图片描述

2. 搜索引擎的相关原理

在服务器的磁盘中,存在一个专门用于存放网页数据的目录 —— data/,其中保存了从全网(Boost 官网等)获取到的 HTML 文件。这些文件是搜索引擎的 “原始语料”。

整个搜索流程可以分为:离线阶段(数据处理与索引建立) 和 在线阶段(搜索请求与结果返回) 两部分。

2.1 离线阶段:数据准备与索引构建

1️⃣ 网页获取

  • 从 Boost 官网中自动获取相关的 HTML 页面,保存至 data/*.html 目录中。

2️⃣ 数据清洗与去标签:

  • 对获取到的网页进行结构化处理,去除 HTML 标签,仅保留关键信息:
    • 网页标题(title)
    • 网页正文内容(content)
    • 网页链接(url)

3️⃣ 建立索引:

  • 根据清洗后的文本内容构建搜索索引,用于加速后续的查询匹配过程。
  • 索引的作用相当于 “目录”,能让系统在大量网页中快速定位包含目标关键词的文件。

2.2 在线阶段:搜索与结果展示

1️⃣ 发起搜索请求

  • 用户通过浏览器访问搜索页面,并通过 HTTP GET 方式上传搜索关键词。

2️⃣ 服务端检索处理

  • 服务端的 Searcher 模块 接收到请求后:
  • 根据用户输入的关键词,在内存中的索引结构中进行匹配;
  • 查找与之相关的网页(即对应的 HTML 文件)。

3️⃣ 结果拼装与返回

  • 服务器将多个匹配网页的关键信息 title + 摘要 + url 拼接为一个新的 HTML 页面,并返回给用户浏览器。
  • 用户即可在结果页面中查看与搜索词最相关的内容。

2.3 系统结构简图

下图展示了整个系统的宏观工作流程,从数据抓取、索引构建到用户查询的完整路径:

在这里插入图片描述

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

  • 技术栈:C/C++、C++11、STL、标准库 Boost,Jsoncpp、cppjieba、cpp-httplib
  • 附加内容:html5、css、js、jQuery、Ajax
  • 项目环境:Centos 7 云服务器、vim / gcc(g++) / Makefile、vsCode

4. 搜索引擎的具体原理

为了让搜索引擎能够在成千上万篇网页中快速找到匹配的内容,必须先对网页内容建立索引。

在搜索引擎中,常用的两种索引结构分别是 正排索引(Forward Index) 和 倒排索引(Inverted Index)。

4.1 正排索引(Forward Index)

正排索引是以 文档 ID → 文档内容 的形式存储数据的。

换句话说,每个文档(网页)都有唯一的编号 doc_id,系统通过这个编号就能直接找到该文档的所有信息。

例如:

文档ID文档内容
1乔布斯买了五斤苹果
2乔布斯发布了苹果手机

这种结构方便根据文档 ID 找内容,但当我们想查 “苹果” 出现在哪些文档中时,必须遍历所有文档,效率很低。

4.2 对目标文档进行分词(为倒排索引做准备)

在构建倒排索引之前,必须先对每篇文档的内容进行分词处理。所谓 “分词”,就是将一整段文本拆解成一个个独立的词语。

示例:

文档ID原始内容分词结果
1乔布斯买了五斤苹果[乔布斯, 买, 了, 五斤, 苹果]
2乔布斯发布了苹果手机[乔布斯, 发布, 了, 苹果, 手机]

注意:有一些暂停词,如 了,的,吗,a,the 一般我们在分词的时候可以不考虑。

4.3 倒排索引(Inverted Index)

倒排索引是一种反向存储结构,以 词语 → 出现该词的文档列表 的形式组织数据。

它是搜索引擎能够 “秒级” 响应查询的关键数据结构。

示例:

关键词出现的文档ID列表
乔布斯[1, 2]
[1]
发布[2]
苹果[1, 2]
手机[2]

当用户搜索 “苹果手机” 时:

  • “苹果” → [1, 2]
  • “手机” → [2]

取交集即可快速定位结果文档 2。

4.4 模拟搜索过程

当用户在浏览器中输入关键词 “苹果手机” 时,整个搜索引擎的执行过程如下:

1️⃣ 解析请求

浏览器将关键词以 HTTP GET 方式发送给服务器:

/word?query=苹果手机

2️⃣ 倒排索引查找

搜索引擎在内存中的倒排索引结构中分别查找:

  • “苹果” → [1, 2]
  • “手机” → [2]

对结果取交集,得到最终候选文档:

命中文档 ID: [2]

3️⃣ 根据正排索引提取内容

搜索引擎再根据文档 ID,从正排索引中查出对应网页的完整信息:

title: 乔布斯发布了苹果手机
content: (原文或经过摘要提取的文本片段)
url: https://boost.example.com/phone.html

4️⃣ 生成摘要与响应结果

搜索引擎对命中文档进行内容摘要(截取含关键词的上下文),并将 【title + 摘要(desc) + url】 拼装成新的 HTML 页面返回给用户。

示例响应片段:

<div class="result">
<h3>乔布斯发布了苹果手机</h3>
<p>乔布斯在发布会上正式推出了苹果手机,引发全球关注...</p>
<a href="https://boost.example.com/iphone.html">查看详情</a>
</div>

整体流程总结

用户输入关键词(苹果手机)
          ↓
倒排索引查找 → 获取命中文档ID(1,2)
          ↓
取交集 → [2]
          ↓
根据正排索引读取文档内容
          ↓
生成摘要 + 拼接HTML结果
          ↓
返回给浏览器展示搜索结果

原理总结

阶段主要任务核心作用
正排索引按文档存储原始内容便于读取与展示
分词将句子拆解为词语生成可索引的词项
倒排索引建立词 → 文档映射提升搜索速度与精度

5. 编写数据去标签与数据清洗的模块

5.1 数据准备与网页清洗(去标签)

1️⃣ 下载 Boost 官方文档

首先,我们需要从 boost 官网 下载最新版本的文档包。

在这里插入图片描述
以 boost_1_89_0 为例:

# 解压
[edison@vm-centos:~/myCode/boost_search]$ tar xzf boost_1_89_0.tar.gz
# 删除压缩包
[edison@vm-centos:~/myCode/boost_search]$ rm -f boost_1_89_0.tar.gz
# 创建索引数据目录
[edison@vm-centos:~/myCode/boost_search]$ mkdir -p data/input
# 拷贝 html 文档
[edison@vm-centos:~/myCode/boost_search]$ cp -rf boost_1_89_0/doc/html/* data/input/
# 查看结果
[edison@vm-centos:~/myCode/boost_search]$ ll
total 8
drwxr-xr-x 8 edison edison 4096 Aug  7 03:25 boost_1_89_0
drwxrwxr-x 3 edison edison 4096 Oct 30 16:00 data

此时,data/input/ 目录中存放的就是 Boost 文档的所有网页文件,这些文件将作为后续建立索引的数据源。

2️⃣ 什么是 HTML 标签?为什么要去掉?

在搜索引擎中,我们只关心网页的 核心内容(标题、正文、链接等)。而 HTML 文件中包含大量的结构化标签(<...>),这些标签用于网页排版、超链接、样式控制等,对搜索功能没有实际意义,反而会干扰文本分析。

例如,下面是一段典型的 HTML 片段:

<!DOCTYPE html>
  <html>
    <head>
    <title>Chapter 30. Boost.Proto</title>
        <link rel="stylesheet" href="../../doc/src/boostbook.css" type="text/css">
      </head>
      <body>
      <h1>Boost.Proto 教程</h1>
      <p>Boost.Proto 是一个强大的表达式模板库……</p>
      </body>
    </html>

其中的 <html>, <head>, <title>, <body>, <p> 等内容都是标签。它们对搜索结果展示没有帮助,因此我们需要 去标签(Tag Stripping),只保留纯文本信息。

3️⃣ 创建 “原始 HTML” 与 “去标签” 目录结构

在数据目录下新建一个用于存放去标签后纯净文本的目录:

[edison@vm-centos:~/myCode/boost_search]$ cd data
[edison@vm-centos:~/myCode/boost_search/data]$ mkdir raw_html
[edison@vm-centos:~/myCode/boost_search/data]$ ll
total 16
drwxrwxr-x 56 edison edison 12288 Oct 30 16:00 input
drwxrwxr-x  2 edison edison  4096 Oct 30 16:22 raw_html

其中:

  • input/:存放原始 HTML 文档
  • raw_html/:存放去掉标签后、可直接建立索引的纯净文本

4️⃣ 文件写入格式设计

我们要对每个文档执行以下步骤:

  • 去除所有 HTML 标签,只保留纯文本;
  • 提取出网页的 titlecontent(正文)和 url
  • 按固定格式写入到统一的文本文件 data/raw_html/raw.txt 中。

写入格式如下:

title\3content\3url\n
title\3content\3url\n
title\3content\3url\n

5️⃣ 为什么使用 \3 作为分隔符?

\3 的 ASCII 码为 3,对应控制字符 ETX(End of Text),它是一种 “不可打印字符”,几乎不会出现在自然语言文本中。在搜索引擎、分布式存储等系统中,使用 \3 有以下优势:

  • 避免冲突:不会与正常的中文、英文、标点或换行符混淆;
  • 分割高效:读取文件时能精准识别文档边界;
  • 兼容性好:符合早期通信协议的分隔约定,可跨平台解析。

因此,使用 \3 作为字段分隔符是一种成熟的工程实践。

6️⃣ 数据读取的便利性

在后续加载索引数据时,可以直接通过:

std::ifstream ifs("data/raw_html/raw.txt");
std::string line;
while (std::getline(ifs, line)) {
// 每行即为一个完整文档: title\3content\3url
}

即可方便地逐行读取每个文档的三元组结构,为索引构建和检索模块提供输入。

✅ 总结:

  • 去标签是搜索引擎的数据清洗阶段;
  • input/ 存放原始网页,raw_html/ 存放清洗后的纯文本;
  • 每行文档使用 title\3content\3url 格式存储;
  • \3 是一种安全高效的分隔符选择。

5.2 Parser 模块设计与实现

Parser.cpp 是整个 Boost 搜索引擎项目的数据预处理模块,负责从原始 HTML 网页中提取可用于索引的纯文本内容,并生成标准化的文档数据文件。

Parser 模块负责以下工作:

  • 枚举文件:递归扫描 data/input/ 目录,获取所有 .html 文件路径;
  • 解析内容:从每个 HTML 文件中提取 title、content(去标签)、url 三项信息;
  • 保存结果:将所有文档的清洗结果统一写入 data/raw_html/raw.txt,每行代表一个网页,字段之间使用 \3(ASCII 3)分隔。

输出格式如下:

title\3content\3url\n

该输出文件是后续 分词(Tokenizer) 和 倒排索引(Inverted Index) 模块的输入源。

5.3 模块结构概览

函数名功能描述
EnumFile()遍历目录,收集所有 .html 文件路径
ParseHtml()主控制逻辑,依次读取与解析 HTML 文件
ParseTitle()提取 <title>...</title> 标签内容
ParseContent()去除所有 HTML 标签,仅保留正文
ParseUrl()根据文件路径构造对应官网 URL
SaveHtml()将解析结果写入输出文件
FileUtil::ReadFile()工具函数,用于读取文件内容

执行流程

┌──────────────────────┐
│  EnumFile()          │
│  递归扫描HTML文件     │
└──────────┬───────────┘
           │ files_list
┌──────────▼───────────┐
│  ParseHtml()          │
│  调用子函数解析每个文件 │
└──────────┬───────────┘
           │ results
┌──────────▼───────────┐
│  SaveHtml()           │
│  写入raw_html/raw.txt │
└──────────┬───────────┘
           │
           ▼
     data/raw_html/raw.txt

整个过程完成后,raw.txt 就成为搜索引擎的原始索引源文件,供后续的分词与倒排索引阶段使用。

5.3.1 文件枚举(EnumFile)

EnumFile() 是整个解析流程的起点,用于递归遍历输入目录,收集所有符合条件的 .html 文件路径,并保存到一个 vector<string> 数组中。

其中的每个元素都是完整的 HTML 文件路径,例如:

data/input/libs/regex/doc/html/regex.html
data/input/libs/asio/doc/html/index.html

该函数借助 Boost 提供的文件系统库 boost::filesystem

  • 递归遍历整个输入目录;
  • 判断文件类型是否为普通文件;
  • 检查扩展名是否为 .html
  • 将符合条件的路径加入结果数组。

核心代码如下:

bool EnumFile(const string &src_path, vector<string> *files_list)
  {
  namespace fs = boost::filesystem;
  fs::path root_path(src_path);
  // 1.判断路径是否存在, 不存在, 就没有必要再往后走了
  if (!fs::exists(root_path))
  {
  cerr << src_path << " not exists!" << endl;
  return false;
  }
  // 2. 文件存在, 对文件进行递归遍历
  fs::recursive_directory_iterator end; //定义一个空的迭代器,用来进行判断递归结束
  for (fs::recursive_directory_iterator iter(root_path); iter != end; iter++)
  {
  // 判断文件是否是普通文件,html都是普通文件, 诸如: .jpg / .png 等等这种就不行
  if (!fs::is_regular_file(*iter))
  {
  continue; // 如果不是普通文件, 那么就跳过, 继续遍历
  }
  // 如果是普通文件, 那么需要判断该文件后缀是否以【.html】结尾的
  if (iter->path().extension() != ".html")
  {
  continue;
  }
  // 走到这里, 当前的路径一定是一个合法的, 以【.html】结束的普通网页文件
  //logMsg(DEBUG, "%s", iter->path().string().c_str()); // 测试
  files_list->push_back(iter->path().string()); //将所有带路径的html保存在files_list,方便后续进行文本分析
  }
  return true;
  }

执行后输出结果:

files_list = [
    "data/input/index.html",
    "data/input/proto/users_guide.html",
    "data/input/asio/overview.html"
]
5.3.2 HTML 解析(ParseHtml)

该步骤对每个 HTML 文件进行解析,提取三个核心字段:

字段含义提取方式
title网页标题通过查找 <title>...</title> 标签
content网页正文利用简易状态机去除所有 <...> 标签,仅保留纯文字内容
url官方文档地址通过文件路径拼接生成:https://www.boost.org/doc/libs/latest/doc/html/...

核心代码如下:

// 对【files_list】数组中的每个文件进行解析
bool ParseHtml(const vector<string> &files_list, vector<DocInfo_t> *results)
  {
  for (const string &file : files_list)
  {
  // 1.读取文件, Read();
  string result; // 存放读取到的文件
  if (!FileUtil::ReadFile(file, &result))
  {
  continue; // 读取失败, 则继续处理下一个
  }
  // 2.解析指定的文件, 提取title
  DocInfo_t doc;
  if (!ParseTitle(result, &doc.title))
  {
  continue;
  }
  // 3.解析指定的文件, 提取content(就是去标签)
  if (!ParseContent(result, &doc.content))
  {
  continue;
  }
  // 4.解析指定的文件路径, 构建url
  if(!ParseUrl(file, &doc.url))
  {
  continue;
  }
  // 走到这里, 一定是完成了解析任务, 当前文档的相关结果都保存在了【doc】里面
  // 然后把解析完之后的【doc】放入到数组【results】中
  results->push_back(std::move(doc)); // 移动语义
  }
  return true;
  }

1️⃣ ParseTitle() —— 提取网页标题

从 HTML 文本中提取出 <title>...</title> 标签内的内容,作为文档标题。

在这里插入图片描述

核心原理:

  • 先使用 find("<title>") 定位标题起始位置;
  • 再使用 find("</title>") 定位标题结束位置;
  • 然后截取两者之间的字符串,即网页标题。

核心代码如下:

// <title>HelloWorld</title>
static bool ParseTitle(const string &file, string *title)
{
// <title>HelloWorld</title>
// 此时 begin 指向 <title> 中的 <
  size_t begin = file.find("<title>");
    if (begin == string::npos)
    {
    return false;
    }
  // <title>HelloWorld</title>
  // 此时 end 指向 </title> 中的 <
size_t end = file.find("</title>");
if (end == string::npos)
{
return false;
}
// <title>HelloWorld</title>
// 此时 begin 指向 H
begin += string("<title>").size();
  if (begin > end)
  {
  return false;
  }
  // 此时就提取出了HelloWorld
  *title = file.substr(begin, end - begin);
  return true;
  }

示例:

<title>Chapter 30. Boost.Proto</title>

解析结果:

title = "Chapter 30. Boost.Proto"

2️⃣ ParseContent() —— 提取网页正文(去标签)

通过状态机算法遍历整个 HTML 内容,删除所有 HTML 标签,仅保留纯文字正文。

在这里插入图片描述

网页内容由两类字符组成:

  • 标签区(<...>
  • 文本区(可见文字)

使用两个状态来切换:

enum status { LABLE, CONTENT };

状态机逻辑:

当前状态遇到字符动作下一个状态
LABLE'>'标签结束,进入文本区CONTENT
CONTENT'<'新标签开始LABLE
CONTENT普通字符写入到 contentCONTENT

核心代码如下:

static bool ParseContent(const string &file, 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;
}

示例输入:

<h1>Boost.Proto 教程</h1>
<p>Boost.Proto 是一个表达式模板库。</p>

输出结果:

content = "Boost.Proto 教程 Boost.Proto 是一个表达式模板库。"

3️⃣ ParseUrl() —— 构造文档访问链接

根据文件在本地 data/input 目录下的路径,构造出对应的 Boost 官网访问 URL。

实现原理:

  • 每个文件路径都以 data/input/... 开头;
  • 去掉前缀后,将路径拼接到固定的 Boost 官网地址:
https://www.boost.org/doc/libs/latest/doc/html
  • 从而生成该 HTML 文件在官网的完整访问链接。

核心代码如下:

static bool ParseUrl(const string &file_path, string *url)
{
// string url_head = "https://www.boost.org/doc/libs/1_89_0/doc/html";
string url_head = "https://www.boost.org/doc/libs/latest/doc/html";
string url_tail = file_path.substr(src_path.size());
*url = url_head + url_tail;
return true;
}

示例:

file_path = "data/input/users_guide.html"

输出:

url = "https://www.boost.org/doc/libs/latest/doc/html/users_guide.html"

4️⃣ util.hpp —— 文件读取工具类

util.hpp 中定义了一个简单的工具类 FileUtil,主要用于将指定路径的 HTML 文件读取为字符串,供 Parser.cpp 中的 ParseHtml() 调用。

核心代码如下:

// 对文件进行解析的工具类
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;
}
};
5.3.3 文件输出(SaveHtml)

SaveHtml() 是整个 Parser 模块的最后一步,用于将已经解析好的网页信息(标题、正文、URL)统一写入输出文件data/raw_html/raw.txt 中,形成后续索引模块的输入数据。

1️⃣ 数据结构来源

ParseHtml() 执行完毕后,我们已经得到一个 vector<DocInfo_t>,其中每个元素代表一个完整网页的结构化信息:

typedef struct DocInfo
{
string title;   // 网页标题
string content; // 网页正文(去标签后)
string url;     // 对应的官网访问路径
} DocInfo_t;

2️⃣ 写入格式设计

每一行代表一个网页文档,字段之间使用控制字符 \3 (ASCII 3, ETX) 分隔,行末以 \n 表示一篇文档的结束。格式如下:

title\3content\3url\n

3️⃣ 函数核心逻辑

bool SaveHtml(const vector<DocInfo_t> &results, const string &output)
  {
  #define SEP '\3'
  // 按照二进制方式进行写入
  std::ofstream out(output, std::ios::out | std::ios::binary);
  if (!out.is_open())
  {
  cerr << "open " << output << " failed!" << endl;
  return false;
  }
  // 开始进行文件内容的写入了
  // 写入到txt中的第一行为: title\3content\3url
  for (auto &item : results)
  {
  string out_string;
  // title
  out_string = item.title;
  out_string += SEP;
  // content
  out_string += item.content;
  out_string += SEP;
  // url
  out_string += item.url;
  out_string += '\n';
  // 把字符串的内容写入到文件中
  out.write(out_string.c_str(), out_string.size());
  }
  // 关闭
  out.close();
  return true;
  }

5️⃣ 写入结果示例

假设 results 中包含以下两条解析结果:

titlecontenturl
Boost.ProtoBoost.Proto 是一个表达式模板库https://www.boost.org/doc/libs/latest/doc/html/proto.html
Boost.AsioBoost.Asio 是一个跨平台网络编程库https://www.boost.org/doc/libs/latest/doc/html/boost_asio.html

则写入的文件内容如下(不可见的 \3 用 ␃ 表示):

Boost.Proto␃Boost.Proto 是一个表达式模板库␃https://www.boost.org/doc/libs/latest/doc/html/proto.html
Boost.Asio␃Boost.Asio 是一个跨平台网络编程库␃https://www.boost.org/doc/libs/latest/doc/html/boost_asio.html

5.4 模块作用总结

功能阶段主要任务输出内容
文件枚举查找所有 HTML 文件文件路径列表
内容提取解析标题、正文、链接结构化文档数据
文件输出统一格式写入文本文件raw.txt(供索引使用)

总结:

  • Parser 模块完成了从原始 HTML 到结构化纯文本的转换,
  • 它是整个搜索引擎的 “数据入口”,
  • 后续分词与倒排索引都基于它生成的 raw.txt 文件进行构建。

6. 建立索引的模块

6.1 模块概述

1️⃣ 模块定位

Index.hpp 是 Boost 搜索引擎项目 的核心模块之一,负责将 Parser 模块生成的原始清洗文件 data/raw_html/raw.txt 转换为两个结构化索引:

索引类型作用存储方式
正排索引 (Forward Index)根据文档 ID 直接访问文档详细信息(title、content、url)顺序存储在 vector<DocInfo>
倒排索引 (Inverted Index)根据关键词快速定位包含该词的文档列表存储在 unordered_map<string, InvertedList>

目的:

  • 通过正排索引实现 “由文档 ID 获取内容”。
  • 通过倒排索引实现 “由关键词快速定位文档”。

2️⃣ 数据来源与流程

Parser 模块已生成 raw.txt 文件,其中每一行格式如下:

title\3content\3url

Index 模块负责读取该文件,解析并构建内存中的索引结构。
总体数据流如下:

raw.txt
   ↓
逐行读取 (BuildIndex)
   ↓
正排索引 (BuildForwardIndex)
   ↓
倒排索引 (BuildInvertedIndex)
   ↓
forward_index + inverted_index

6.2 数据结构设计

1️⃣ 文档信息结构体 DocInfo

struct DocInfo
{
string title;      // 文档标题
string content;    // 文档正文内容(去标签后)
string url;        // 官网文档链接
uint64_t doc_id;   // 文档ID(即vector下标)
};

设计要点:

  • doc_id 与在 forward_index(正排索引) 中的存储位置是对应的;
  • 使用 uint64_t 便于支持大规模数据;
  • content 已是清洗后的纯文本,不含任何 HTML 标签。

2️⃣ 倒排元素结构体 InvertedElem

struct InvertedElem
{
uint64_t doc_id;  // 当前词所在文档ID
string word;      // 关键字
int weight;       // 权重(相关性得分)
InvertedElem() : weight(0) {}
};

一个关键词可能出现在多个文档中,因此每个关键词对应一个 倒排链表 (InvertedList):

typedef vector<InvertedElem> InvertedList;

3️⃣ 索引存储结构

vector<DocInfo> forward_index;
  unordered_map<string, InvertedList> inverted_index;
名称类型含义
forward_index顺序表通过文档 ID 快速访问文档内容
inverted_index哈希表通过关键词快速定位文档集合

forward_index 结构如下图所示:

在这里插入图片描述

inverted_index 结构如下图所示:

在这里插入图片描述

6.3 单例设计模式

1️⃣ 为什么要单例?

索引是整个搜索系统的全局共享资源,只允许存在一份内存实例,以保证数据一致性与节省内存。

class Index
{
private:
static Index* instance;
static mutex mtx;
Index() {}
Index(const Index&) = delete;
Index& operator=(const Index&) = delete;
};

2️⃣ 线程安全的双重检查锁定(DCL)

static Index *GetInstance()
{
if (nullptr == instance)
{
mtx.lock();
if (nullptr == instance)
{
instance = new Index();
}
mtx.unlock();
}
return instance;
}

为什么要进行两次判断?

  • 第一次检查保证性能(绝大多数情况下不加锁);
  • 第二次检查保证安全(并发初始化时只有一个线程成功)。

6.4 BuildIndex() — 索引构建主流程

1️⃣ 函数功能

BuildIndex() 负责从 raw.txt 文件中读取文档数据,并依次构建正排与倒排索引。

bool BuildIndex(const string &input)
{
ifstream in(input, ios::in | ios::binary);
if (!in.is_open()) return false;
string line;
int count = 0;
while (getline(in, line))
{
// Step 1: 构建正排索引
DocInfo* doc = BuildForwardIndex(line);
if (!doc) continue;
// Step 2: 构建倒排索引
BuildInvertedIndex(*doc);
if (++count % 50 == 0)
logMsg(NORMAL, "当前已建立索引文档: %d", count);
}
in.close();
return true;
}

2️⃣ 核心逻辑流程

阶段函数功能
BuildForwardIndex()将一行 raw 数据转化为 DocInfo
BuildInvertedIndex()统计词频并更新倒排结构
logMsg()每 50 个文档打印进度日志

6.5 BuildForwardIndex() — 正排索引建立

6.5.1 什么是正排索引(Forward Index)

回顾一下:正排索引是以 文档 ID 作为主键的索引结构,用于存储每个文档的原始信息(标题、正文、URL)。

文档ID标题正文URL
0Boost.AsioBoost.Asio 是一个异步网络库https://…
1Boost.RegexBoost.Regex 提供正则表达式支持https://…

在搜索阶段:

  • 倒排索引用于 “找出有哪些文档匹配了关键词”;
  • 正排索引用于 “从文档 ID 获取具体内容”。

因此,正排索引是一个顺序数组(vector),其下标天然即为文档 ID,查找时间复杂度为 O(1)。

6.5.2 正排索引的存储结构

在 Index 类中:

vector<DocInfo> forward_index;

DocInfo 的定义:

struct DocInfo {
string title;    // 标题
string content;  // 正文内容
string url;      // 访问链接
uint64_t doc_id; // 文档ID
};

关键点:

  • 每次插入时 doc_id = forward_index.size()
  • 因为 vector 连续存储、自动扩容,插入后 ID 与索引位置严格一致;

这意味着:

forward_index[doc_id] == 当前文档
6.5.3 正排索引的构建流程(BuildForwardIndex)

1️⃣ 原始输入

来自 Parser 模块生成的每行数据:

title␃content␃url

例如:

Boost.Regex␃Boost.Regex 提供正则表达式支持。␃https://www.boost.org/doc/libs/latest/doc/html/boost_regex.html

2️⃣ 分割字符串

vector<string> results;
  StringUtil::Split(line, &results, "\3");

此时:

results[0] = "Boost.Regex"
results[1] = "Boost.Regex 提供正则表达式支持。"
results[2] = "https://www.boost.org/..."

3️⃣ 填充结构体并分配 doc_id

DocInfo doc;
doc.title = results[0];
doc.content = results[1];
doc.url = results[2];
doc.doc_id = forward_index.size();

在插入前,doc_id 始终等于当前 vector 长度,例如:

  • 第一个文档 → doc_id = 0
  • 第二个文档 → doc_id = 1
  • 第 N 个文档 → doc_id = N - 1

4️⃣ 插入到正排索引中

forward_index.push_back(move(doc));
return &forward_index.back();

使用 move() 避免不必要的深拷贝;返回刚插入的 DocInfo 引用地址,以便后续用于倒排索引。

5️⃣ 结构示意图

┌────────────────────────────┐
│     forward_index (vector) │
├─────────────┬──────────────┤
│ [0]         │ DocInfo {doc_id=0, title="Boost.Asio", ... } │
│ [1]         │ DocInfo {doc_id=1, title="Boost.Regex", ...} │
│ [2]         │ DocInfo {doc_id=2, title="Boost.Thread", ...}│
└─────────────┴──────────────────────────────────────────────┘

6.6 BuildInvertedIndex() — 倒排索引建立

6.6.1 什么是倒排索引(Inverted Index)

还是先回顾一下:倒排索引是搜索引擎的核心数据结构之一。

它解决了这样的问题:“我想查找包含某个关键词的所有文档。”

1️⃣ 概念对比

类型功能
正排索引文档 ID文档内容根据 ID 找内容
倒排索引关键词文档 ID 列表根据词找文档

2️⃣ 示例

假设有两篇文档:

文档ID内容
0乔布斯买了五斤苹果
1乔布斯发布了苹果手机

对它们分词后:

出现在哪些文档
乔布斯[0,1]
苹果[0,1]
[0]
发布[1]
手机[1]

于是倒排索引结构如下:

inverted_index = {
    "乔布斯": [ {doc_id:0, weight:11}, {doc_id:1, weight:10} ],
    "苹果": [ {doc_id:0, weight:11}, {doc_id:1, weight:11} ],
    "买": [ {doc_id:0, weight:5} ],
    "发布": [ {doc_id:1, weight:7} ],
    "手机": [ {doc_id:1, weight:9} ]
}
6.6.2 倒排索引的内存结构
unordered_map<string, InvertedList> inverted_index;
  • key: 关键词(string
  • value: 倒排拉链(vector<InvertedElem>

每个 InvertedElem 存储:

struct InvertedElem {
uint64_t doc_id;   // 文档ID
string word;       // 当前关键词
int weight;        // 权重
};
6.6.3 建立倒排索引的完整过程

1️⃣ 输入

当前文档结构体 DocInfo { title, content, url, doc_id }

2️⃣ 局部词频统计表

使用哈希表统计当前文档中每个词的出现次数:

struct word_cnt {
int title_cnt;
int content_cnt;
word_cnt() : title_cnt(0), content_cnt(0) {}
};
unordered_map<string, word_cnt> word_map;

3️⃣ 对标题分词

vector<string> title_words;
  JiebaUtil::CutString(doc.title, &title_words);

例如:

"乔布斯发布了苹果手机" → ["乔布斯", "发布", "苹果", "手机"]

逐词统计:

for (string s : title_words)
{
boost::to_lower(s);         // 统一小写
word_map[s].title_cnt++;    // 标题词频 +1
}

4️⃣ 对正文分词

vector<string> content_words;
  JiebaUtil::CutString(doc.content, &content_words);
  for (string s : content_words)
  {
  boost::to_lower(s);
  word_map[s].content_cnt++;
  }

5️⃣ 计算权重并更新倒排表

搜索引擎需要衡量每个词对文档的重要程度(相关性)。在本系统中,采用 线性权重模型:

weight = X × title_cnt + Y × content_cnt

其中:

X = 10
Y = 1

代码逻辑如下:

for (auto &pair : word_map)
{
InvertedElem elem;
elem.doc_id = doc.doc_id;
elem.word = pair.first;
elem.weight = 10 * pair.second.title_cnt + 1 * pair.second.content_cnt;
inverted_index[pair.first].push_back(move(elem));
}

6️⃣ 图示:倒排索引的构建逻辑

输入文档 DocInfo
   │
   ├─ 分词 → ["乔布斯","发布","苹果","手机"]
   │
   ├─ 统计词频
   │     "乔布斯": {title_cnt=1, content_cnt=0}
   │     "苹果":   {title_cnt=1, content_cnt=1}
   │
   ├─ 计算权重
   │     weight("苹果") = 10*1 + 1*1 = 11
   │
   └─ 插入倒排表 inverted_index["苹果"].push_back({...})
6.6.4 数据合并与内存布局

多个文档构建完毕后,内存中的倒排索引结构如下:

unordered_map<string, vector<InvertedElem>>

结构展开后:

"乔布斯"  →  [ (doc=0, weight=10), (doc=1, weight=9) ]
"苹果"    →  [ (doc=0, weight=11), (doc=1, weight=11) ]
"手机"    →  [ (doc=1, weight=8) ]

特点:

  • 哈希查找 O(1);
  • 每个关键词对应的倒排链为连续存储;
  • 新文档追加时,倒排链 push_back() 即可。
6.6.5 倒排索引的示意图
          ┌──────────────────────────────────────────┐
          │            inverted_index                │
          ├──────────────────────┬───────────────────┤
          │ key="苹果"           │ value (InvertedList)
          ├──────────────────────┼───────────────────┤
          │                      │[{doc=1,weight=11},│
          │                      │ {doc=3,weight=9}, │
          │                      │ {doc=5,weight=10}]│
          └──────────────────────┴───────────────────┘

6.7 索引查询接口

1️⃣ 按文档 ID 获取正排信息

根据倒排查询到的 doc_id,再通过正排索引提取标题、内容、URL。

DocInfo *GetForwardIndex(uint64_t doc_id)
{
if (doc_id >= forward_index.size())
return nullptr;
return &forward_index[doc_id];
}

2️⃣ 按关键词获取倒排列表

在搜索阶段输入一个关键词,直接得到所有包含该词的文档集合。

InvertedList *GetInvertedList(const string &word)
{
auto iter = inverted_index.find(word);
if (iter == inverted_index.end())
return nullptr;
return &(iter->second);
}

6.8 工具类扩展(util.hpp)

1️⃣ StringUtil — 字符串分割工具类

解析 raw.txt 的每一行文本为 title/content/url 三部分。

class StringUtil
{
public:
static void Split(const std::string &target, std::vector<std::string> *out, const std::string &sep)
  {
  boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
  }
  };

2️⃣ JiebaUtil — 分词工具类

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

说明:

  • 使用 cppjieba 中文分词库;
  • 采用 CutForSearch 模式,适用于搜索引擎高召回场景;
  • 停用词表过滤常见词(如 “的”、“了”、“是” 等)。

6.9 执行流程总结

┌──────────────────────────────┐
│  raw.txt (Parser输出结果)     │
└─────────────┬────────────────┘
              │ 每行(title␃content␃url)
┌─────────────▼────────────────┐
│ BuildForwardIndex()          │
│ 构建正排索引 vector   │
└─────────────┬────────────────┘
              │
┌─────────────▼────────────────┐
│ BuildInvertedIndex()         │
│ 构建倒排索引 word→DocList      │
└─────────────┬────────────────┘
              │
              ▼
 forward_index + inverted_index

6.10 运行结果示例

假设原始 raw.txt 片段为:

Boost.Asio␃Boost.Asio 是一个跨平台的异步网络库。␃https://www.boost.org/asio.html
Boost.Regex␃Boost.Regex 提供正则表达式的实现。␃https://www.boost.org/regex.html

构建索引后:

  • 正排索引(forward_index[0]):
title = Boost.Asio
url   = https://www.boost.org/asio.html
  • 倒排索引(inverted_index["boost"]):
[{doc_id:0, weight:11}, {doc_id:1, weight:11}]

6.11 总结

Index.hpp 模块是整个搜索引擎系统的“数据核心”,它将解析后的网页内容转化为结构化索引,使得上层搜索模块能够在毫秒级时间内从数百篇文档中查找到最相关的结果。

7. 搜索引擎模块

7.1 模块概述

Searcher.hpp 是 Boost 搜索引擎系统的核心在线检索模块。

它的主要职责是:

阶段功能
1️⃣ 初始化阶段加载并建立索引(正排 + 倒排)
2️⃣ 查询阶段对用户输入的搜索词进行分词、匹配、聚合与排序
3️⃣ 输出阶段将最终的检索结果构建为 JSON 格式返回给前端

7.2 模块总体结构

核心代码如下:

class Searcher
{
private:
Index *index;  // 索引指针(全局单例)
public:
void InitSearcher(const string &input);
void Search(string &query, string *json_string);
string GetDesc(const string &html_content, const string &word);
};

执行过程如下:

用户输入关键词
│
▼
[Search()] ——> [分词] ——> [倒排查找] ——> [结果合并+排序] ——> [JSON构建]
│
▼
返回序列化 JSON 响应

7.3 初始化阶段(InitSearcher)

核心代码如下:

void InitSearcher(const string &input)
{
index = Index::GetInstance();
logMsg(NORMAL, "获取index单例成功...");
index->BuildIndex(input);
logMsg(NORMAL, "建立正排和倒排索引成功...");
}

步骤解析:

步骤说明
① 获取索引单例调用 Index::GetInstance(),创建或获取唯一索引实例
② 建立索引调用 BuildIndex()data/raw_html/raw.txt 构建正排与倒排索引
③ 打印日志记录加载状态,方便调试

初始化阶段只需执行一次:

  • 搜索引擎服务启动时,Searcher::InitSearcher() 会加载所有数据进内存。
  • 后续的每次搜索都直接访问内存中的索引结构,速度极快。

7.4 查询阶段(Search)

核心代码如下:

void Search(string &query, string *json_string)
{
// 1. 对 query 做分词(内部调用结巴)
// 2. 根据每个词去倒排索引中“触发”匹配文档
// 3. 对命中的文档进行去重、权重累加并排序
// 4. 根据排序结果查正排索引,构造 JSON 返回
}

Search 的整套流程可以概括为四步:

  • 词元拆分(简单分词)
  • 倒排触发与去重合并
  • 按相关性排序
  • 构造 JSON 结果并返回
7.4.1 对 query 做 “词元拆分”

用户输入的搜索词可能是中文、英文或混合文本。

首先通过结巴分词工具 JiebaUtil::CutString() 将 query 拆分为语义单元。

vector<string> words;
  JiebaUtil::CutString(query, &words);

示例:

输入:  "苹果手机发布"
输出:  ["苹果", "手机", "发布"]
7.4.2 根据词元触发倒排、合并并去重

代码核心部分如下:

vector<InvertedElemPrint> inverted_list_all;          // 存放去重后的结果
  unordered_map<uint64_t, InvertedElemPrint> tokens_map; // 以 doc_id 为 key 去重 & 累加权重
    for (string word : words)
    {
    boost::to_lower(word); // 查询关键字统一转小写
    InvertedList *inverted_list = index->GetInvertedList(word); // 取出该词的倒排链
    if (nullptr == inverted_list)
    {
    continue;
    }
    // 遍历这个词对应的整个倒排拉链
    for (const auto &elem : *inverted_list)
    {
    // 以 doc_id 作为 key 对结果去重并聚合
    auto &item = tokens_map[elem.doc_id]; // 若不存在则新建
    item.doc_id = elem.doc_id;
    item.weight += elem.weight;           // 多个关键词命中同一文档时,权重累加
    item.words.push_back(elem.word);      // 记录在该文档中命中的词
    }
    }
    // 把 map 中的聚合结果转为 vector,方便排序
    for (const auto &item : tokens_map)
    {
    inverted_list_all.push_back(move(item.second));
    }

这段逻辑做了几件事:

1️⃣ 大小写归一化

  • 因为在构建索引时已经把所有词统一转成了小写
  • 所以查询时也要对 word 调用 boost::to_lower()
  • 这样的话,例如 “Regex” 和 “regex” 会匹配到同一条倒排链。

2️⃣ 获取倒排链

对当前词 word:

InvertedList *inverted_list = index->GetInvertedList(word);
  • 如果该词从未在任何文档中出现,返回 nullptr,直接跳过;
  • 如果存在,inverted_list 中包含了所有 doc_id 与对应 weight。

3️⃣ 以 doc_id 为 key 做 “文档级别的去重和权重汇总”

通过:

unordered_map<uint64_t, InvertedElemPrint> tokens_map;

来实现:

  • key:doc_id
  • value:该文档的综合匹配信息(累计权重 + 命中词列表)

对每个 elem:

auto &item = tokens_map[elem.doc_id];
item.doc_id = elem.doc_id;
item.weight += elem.weight;
item.words.push_back(elem.word);

这样会产生以下效果:

  • 如果不同的查询词命中了同一个 doc_id,那么它们会汇总到同一个 InvertedElemPrint 中;
  • 该文档的总权重是所有命中词的权重之和;
  • words 记录这个文档 “因哪些词被命中”,方便后续摘要生成或者高亮。

4️⃣ 从 map 转 vector

最后:

for (const auto &item : tokens_map)
{
inverted_list_all.push_back(move(item.second));
}

将 unordered_map(去重+汇总结果)转为 vector,以便下一步排序。

7.4.3 按相关性排序

代码如下:

sort(inverted_list_all.begin(), inverted_list_all.end(),
[](const InvertedElemPrint &e1, const InvertedElemPrint &e2){
return e1.weight > e2.weight;
});
  • 排序依据:weight,即文档的综合相关性得分;
  • 分值越高的文档排在越前面;

权重来源:索引构建阶段(Index 模块)的 BuildInvertedIndex() 中计算的:

weight = 10 * title_cnt + 1 * content_cnt;
7.4.4 根据 doc_id 查正排 + 生成 JSON

处理排序后的每一个命中文档:

Json::Value root;
for (auto &item : inverted_list_all)
{
DocInfo *doc = index->GetForwardIndex(item.doc_id);
if (nullptr == doc)
continue;
Json::Value elem;
elem["title"]  = doc->title;
elem["desc"]   = GetDesc(doc->content, item.words[0]); // 用命中的第一个词生成摘要
elem["url"]    = doc->url;
elem["id"]     = (int)item.doc_id;
elem["weight"] = item.weight;
root.append(elem);
}
// 序列化成字符串
Json::FastWriter writer;
*json_string = writer.write(root);
  • 通过 GetForwardIndex(doc_id) 从正排索引里拿到完整文档信息;
  • 调用 GetDesc() 方法,从正文中截取一小段包含命中词的上下文作为摘要;
  • title / desc / url / id / weight 填入 Json::Value 对象中;
  • 最终用 Json::FastWriter 序列化为 JSON 字符串返回给调用者(main 函数),再打印出来。

7.5 摘要生成模块(GetDesc)

搜索结果中的 “desc” 是正文的简短片段(摘要),用于在前端展示文档的关键上下文。

实现思路:

  • 找到搜索词在正文中的第一次出现;
  • 取其前 50 个字符与后 100 个字符;
  • 拼接成简短摘要并在结尾加上省略号 …。

代码实现:

string GetDesc(const string &html_content, const string &word)
{
const int prev_step = 50;
const int next_step = 100;
auto iter = search(html_content.begin(), html_content.end(),
word.begin(), word.end(),
[](int x, int y){ return tolower(x) == tolower(y); });
if (iter == html_content.end())
return "None1";
int pos = distance(html_content.begin(), iter);
int start = 0, end = html_content.size() - 1;
if (pos > start + prev_step) start = pos - prev_step;
if (pos < end - next_step) end = pos + next_step;
if (start >= end) return "None2";
string desc = html_content.substr(start, end - start);
desc += "....";
return desc;
}

示例如下,假设原文:

Boost.Regex 是 Boost 库中用于处理正则表达式的组件。它支持 PCRE 风格的语法,并提供高性能匹配。

搜索词:正则

输出摘要:

"……Boost.Regex 是 Boost 库中用于处理正则表达式的组件。它支持 PCRE 风格……"

7.6 数据流与核心算法流程图

如下所示:

┌──────────────────────────┐
│ 用户输入 query="苹果手机" │
└──────────────┬───────────┘
               ▼
┌──────────────┐
│ 分词 ["苹果","手机"] │
└───────┬──────────┘
        ▼
┌────────────────────────┐
│ 倒排查找               │
│ "苹果"→[doc2,doc5]      │
│ "手机"→[doc2,doc7]      │
└────────┬────────────────┘
         ▼
┌────────────────────────┐
│ 去重 + 权重累加        │
│ doc2: 11+9=20          │
│ doc5: 8                │
│ doc7: 6                │
└────────┬────────────────┘
         ▼
┌────────────────────────┐
│ 排序                   │
│ [doc2, doc5, doc7]     │
└────────┬────────────────┘
         ▼
┌────────────────────────┐
│ 构建JSON结果 + 摘要提取  │
└────────────────────────┘

总结:Searcher 模块完成了搜索引擎的 “在线查询” 阶段。

它通过:

  • 分词 → 倒排索引查询 → 结果融合 → 排序 → 摘要生成 → JSON输出

这一套完整的流程,实现了站内搜索的全闭环。

8. 服务端测试模块(debug.cc)

8.1 模块定位与作用

在真正编写 Web 服务器(HTTP 服务)之前,我们先实现了一个简易的命令行服务端测试程序 debug.cc,用于验证:

  • Searcher 模块是否初始化成功(索引是否构建成功);
  • 搜索流程是否正确(输入 query → 输出 JSON);
  • 获取结果是否正常排序、摘要是否生成正常;
  • 方便在命令行下快速调试分词、倒排索引匹配情况。

可以把它理解为:没有网络的简化版搜索服务端 —— 用命令行替代 HTTP 请求,用 std::cout `替代 HTTP 响应。

8.2 源码总览

代码如下:

#include "searcher.hpp"
#include <iostream>
  #include <cstdio>
    #include <cstring>
      #include <memory>
        const string input = "data/raw_html/raw.txt";
        int main()
        {
        // 测试
        unique_ptr<Searcher> search(new Searcher());
          search->InitSearcher(input);
          string query;
          string json_string;
          char buffer[1024];
          while (true)
          {
          cout << "Please Enter Your Search Query# ";
          fgets(buffer, sizeof(buffer)-1, stdin);
          buffer[strlen(buffer) - 1] = 0; // 把 '\n' 设置为 '0'
          query = buffer;
          search->Search(query, &json_string);
          cout << json_string << endl;
          }
          return 0;
          }

8.3 输入数据文件路径

路径如下:

const string input = "data/raw_html/raw.txt";
  • 这个常量 input 指定了索引构建所需的输入文件路径;
  • 文件 raw.txt 正是前面 Parser 模块 生成的结果,每一行对应一个文档,格式为:
title\3content\3url\n

Searcher::InitSearcher(input) 内部会使用这个路径,调用 Index::BuildIndex(input),完成正排 & 倒排索引的建立。

注意:

debug.cc 使用 data/raw_html/raw.txt 作为搜索引擎的语料输入文件。

该文件必须先由 Parser 模块生成,否则初始化会失败。

8.4 Searcher 对象的创建与初始化

代码如下:

unique_ptr<Searcher> search(new Searcher());
  // Searcher *search = new Searcher();
  search->InitSearcher(input);

使用 std::unique_ptr 管理 Searcher:

  • 这里使用了 C++11 的智能指针 std::unique_ptr 来管理 Searcher 对象的生命周期;
  • 优点:自动释放内存,避免手动 delete;防止内存泄漏,更符合现代 C++ 风格。

初始化搜索引擎(索引构建):

search->InitSearcher(input);

这一步调用 Searcher::InitSearcher(),其内部逻辑为:

  • 通过 Index::GetInstance() 获取索引单例对象;
  • 调用 Index::BuildIndex(input)
    • 打开 data/raw_html/raw.txt
    • 逐行读取;
    • 调用 BuildForwardIndex() 构建正排索引;
    • 调用 BuildInvertedIndex() 构建倒排索引;
  • 打印日志,例如:
    • “获取 index 单例成功…”
    • “建立正排和倒排索引成功…”

到此位置,整个搜索引擎的【离线索引 + 在线查询引擎】就已经加载完毕,准备好接受查询了。

8.5 交互式查询循环

核心查询逻辑在 while (true) 循环中:

string query;
string json_string;
char buffer[1024];
while (true)
{
cout << "Please Enter Your Search Query# ";
fgets(buffer, sizeof(buffer)-1, stdin);
buffer[strlen(buffer) - 1] = 0; // 把 '\n' 设置为 '0'
query = buffer;
search->Search(query, &json_string);
cout << json_string << endl;
}

1️⃣ 从标准输入读取一整行查询

fgets(buffer, sizeof(buffer)-1, stdin);
buffer[strlen(buffer) - 1] = 0; // 把 '\n' 替换为 '\0'
query = buffer;

fgets(buffer, sizeof(buffer)-1, stdin)

  • 从标准输入(键盘)读取一行字符串;
  • 最多读取 sizeof(buffer) - 1 个字符;
  • 会把末尾的换行符 \n 一并读进来。

buffer[strlen(buffer) - 1] = 0

  • 将最后的 \n 改成字符串结束符 '\0'
  • 这样 buffer 就变成一个“标准 C 字符串”。

query = buffer

  • 将 C 字符串赋值给 C++ 的 std::string query
  • 此时 query 就是用户输入的完整查询内容,例如 “正则表达式”。

注意:main 函数本身不做分词、不做大小写转换,也不做任何预处理。所有与搜索相关的处理,都留给 Searcher::Search() 来完成。

2️⃣ 调用 Searcher 进行搜索

search->Search(query, &json_string);

这里会进入 Searcher::Search() 函数,执行对 query 分词:

vector<string> words;
  JiebaUtil::CutString(query, &words);

对每个词,去倒排索引 GetInvertedList(word) 中查找文档;

然后对命中的文档 doc_id 做:

  • 去重;
  • 权重累加;
  • 相关性排序;

再然后,通过正排索引 GetForwardIndex(doc_id) 拿到标题、正文、URL;

接着,调用 GetDesc() 生成摘要片段;

最后,使用 JsonCpp 构造 JSON 数组,序列化为字符串写入 json_string。

注意:

Search 函数完成了从【关键词字符串】到【排序好的 JSON 搜索结果】的全部处理逻辑

8.6 典型测试流程示例

运行以后,可以看到,目前已经成功建立索引了:

在这里插入图片描述

接着依次输入:

Please Enter Your Search Query# regex

查询结果如下:

在这里插入图片描述

9. HTTP 搜索服务端模块

http_server 模块的作用是:

  • 在本地启动一个 HTTP Server(基于 cpp-httplib 库);
  • 对外暴露一个搜索接口:GET /s?word=xxx
  • 将用户在浏览器中输入的搜索关键字传给 Searcher;
  • 将 Searcher 返回的 JSON 搜索结果,作为 HTTP 响应返回给浏览器;
  • 同时支持静态资源目录 wwwroot,用于前端页面文件(HTML / JS / CSS)的访问。

可以简单理解为:http_server = 【把 Searcher 模块挂到一个 HTTP 端口上,对浏览器开放访问】

源码总览:

#include "searcher.hpp"
#include "httplib.h"
#include "log.hpp"
const string input = "data/raw_html/raw.txt";
const std::string root_path = "./wwwroot";
int main()
{
// 获取单例, 建立索引
Searcher search;
search.InitSearcher(input);
httplib::Server svr;
svr.set_base_dir(root_path.c_str()); // 引入wwwroot目录
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;
logMsg(NORMAL, "用户搜索的: %s", word.c_str());
std::string json_string;
search.Search(word, &json_string);
rsp.set_content(json_string.c_str(), "application/json"); // 给用户返回的结果
});
logMsg(NORMAL, "服务器启动成功...");
svr.listen("0.0.0.0", 8081);
return 0;
}

其中:

  • input:索引构建的输入文件路径(Parser 模块生成的 raw.txt);
  • root_path:静态资源根目录,存放前端页面;
const string input = "data/raw_html/raw.txt";
const std::string root_path = "./wwwroot";

9.1 搜索引擎核心初始化

代码如下:

Searcher search;
search.InitSearcher(input);

这里直接在 main 函数中创建了一个 Searcher 对象 search(非单例,但内部用的 Index 是单例);

接着调用 InitSearcher(input):

  • 获取 Index::GetInstance()
  • 调用 Index::BuildIndex(input)
    • data/raw_html/raw.txt 中读取数据;
    • 解析每一行,构建正排 & 倒排索引;
  • 将索引加载入内存,供后续的 Search() 使用。

这一步和 debug.cc 中初始化逻辑是一致的,只是这里是在 HTTP 服务启动前完成。

9.2 HTTP 服务器对象创建

代码如下:

httplib::Server svr;

httplib::Server 是 cpp-httplib 提供的 HTTP 服务端类;

svr 对象负责:

  • 注册路由(Get / Post 等);
  • 监听端口;
  • 接收 HTTP 请求;
  • 调用对应的处理函数;
  • 发送 HTTP 响应。

9.3 静态资源目录挂载

代码如下:

svr.set_base_dir(root_path.c_str()); // 引入wwwroot目录

作用:

  • 指定静态资源根目录,例如 ./wwwroot

如果浏览器访问:

GET /index.html
GET /js/main.js
GET /css/style.css

那么 cpp-httplib 会自动从 wwwroot 目录下面寻找相应文件:

./wwwroot/index.html
./wwwroot/js/main.js
./wwwroot/css/style.css

也就是说,这个 HTTP Server 既能当作【API 后端】,又能直接作为【静态文件服务器】,一并提供前端页面文件。

9.4 注册搜索接口路由:GET /s

代码如下:

svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rsp){
...
});

1️⃣ 路由含义

  • "/s":表示 URL 路径为 /s 的 HTTP GET 请求;
  • 由一个 lambda 回调函数 来处理请求;
  • 回调函数签名:
[&](const httplib::Request &req, httplib::Response &rsp) { ... }

2️⃣ 捕获 search 对象

[&search](const httplib::Request &req, httplib::Response &rsp)
  • 捕获方式:&search(引用捕获);
  • 目的:在回调函数内部,可以直接调用 search.Search(...),无需全局变量或单例;

9.5 请求参数检查与获取

完整回调逻辑如下:

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");
logMsg(NORMAL, "用户搜索的: %s", word.c_str());
std::string json_string;
search.Search(word, &json_string);
rsp.set_content(json_string.c_str(), "application/json");
});

1️⃣ 检查是否携带 word 参数

if (!req.has_param("word"))
{
rsp.set_content("必须要有搜索关键字!", "text/plain; charset=utf-8");
return;
}

req.has_param("word")

  • 检查 URL 查询参数中,是否包含 word;
  • 比如:/s?wd=regex(参数名错了)

如果缺少 word 参数:

  • 返回一段纯文本提示 “必须要有搜索关键字!”;
  • Content-Type:text/plain; charset=utf-8
  • 直接 return,请求处理到此结束。

2️⃣ 获取搜索关键字

std::string word = req.get_param_value("word");
logMsg(NORMAL, "用户搜索的: %s", word.c_str());

req.get_param_value("word") 从 query string 中取出参数值:

  • 请求:/s?word=boost → word = “boost”;
  • 请求:/s?word=正则表达式 → word = “正则表达式”;

使用 logMsg 打一行日志,方便在后台看到用户的搜索行为。

9.6 调用 Searcher 执行搜索

代码如下:

std::string json_string;
search.Search(word, &json_string);

Searcher::Search(word, &json_string)

  • 对 word 分词(对于单个词,分出来就一个);
  • 根据分词结果去倒排索引中查找相关文档;
  • 做去重、权重累加、排序;
  • 根据 doc_id 查正排索引;
  • 生成 JSON 数组并序列化为字符串写入 json_string。

所以,从 HTTP Server 的视角来看:Search 是一个 “黑盒函数”,输入关键字字符串,输出一串 JSON。

9.7 构造 HTTP 响应

代码如下:

rsp.set_content(json_string.c_str(), "application/json"); // 给用户返回的结果

rsp.set_content(body, content_type)

  • 设置响应体内容;
  • 设置 HTTP 响应头中的 Content-Type 字段;

这里的类型是:application/json,表示返回的是 JSON 数据。

浏览器拿到这个响应后,可以在前端 JS 中对 JSON 进行解析和渲染,例如:

fetch('/s?word=regex')
.then(res => res.json())
.then(data => {
// data 是一个数组,里面是搜索结果对象
});

9.8 服务器启动与监听

代码如下:

logMsg(NORMAL, "服务器启动成功...");
svr.listen("0.0.0.0", 8081);
return 0;

svr.listen("0.0.0.0", 8081)

  • 启动 HTTP 服务;
  • 监听在本机 8081 端口;
  • "0.0.0.0" 表示接收来自任何网卡 IP 的请求(即可以用 127.0.0.1 / 本机 IP 访问)。

启动后,可以在浏览器中访问:http://127.0.0.1:8081/s?word=regex

在这里插入图片描述

9.9 总结

http_server 模块完成了从 “命令行测试版 debug.cc” 到 “真正 HTTP 搜索服务” 的升级:

  • 使用 cpp-httplib 快速搭建 HTTP 服务端;
  • 将 Searcher 模块对接到 /s 路由上;
  • 支持 GET /s?word=xxx 的搜索接口;
  • 将 JSON 搜索结果以 application/json 的形式返回给前端;
  • 同时挂载了静态资源目录 wwwroot,方便前后端一体部署。

10. 前端搜索页面

index.html 是 Boost 搜索引擎的 Web 前端界面,负责:

  • 为用户提供一个简洁的搜索输入框与 “搜索一下” 按钮;
  • 通过 Ajax 调用后端 /s?word=xxx 接口获取搜索结果(JSON);
  • 把 JSON 中的每条结果渲染为一条“搜索结果卡片”,包含:
    • 标题(可点击跳转到 Boost 官网原文)
    • 摘要(正文中的一小段内容)
    • URL(以绿色字体展示)

我是把整个页面将 HTML、CSS、JavaScript 写在同一个文件中的。

但编写之前,我们需要先了解一下 html、css、js,其中:

  • html:是网页的骨骼,主要负责网页结构;
  • css:网页的皮肉,主要负责网页美观的;
  • js:网页的灵魂,主要负责动态效果,和前后端交互;

10.1 编写 html

页面的 HTML 结构主要分三块:

  • <head>:引入 jQuery、设置页面标题和嵌入 CSS;
  • <body> 顶部:多彩的 “BOOST” Logo;
  • <body> 主要内容区域:搜索框 + 搜索结果列表。

代码如下:

<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>
      <title>Boost 搜索引擎</title>
        <style>
          ...CSS 省略...
        </style>
      </head>

meta charset="UTF-8":保证中文显示正常;

引入 jQuery 2.1.1 是用于简化 DOM 操作和 Ajax 请求;

<style> 中写的是整套页面样式(布局 + 颜色 + 字体等)。

10.2 编写 css

这块儿没啥好说的,代码如下:

/* 去掉网页中的所有的默认内外边距 */
* {
margin: 0;
/* 设置外边距 */
padding: 0;
/* 设置内边距 */
}
/* 将我们的body内的内容100%和html的呈现吻合 */
html,
body {
height: 100%;
}
/* 页面主容器 */
.container {
width: 800px;           /* 固定宽度 */
margin: 0 auto;         /* 水平居中 */
margin-top: 15px;       /* 距离顶部 15px */
}
/* 搜索栏外层容器 */
.container .search {
width: 100%;
height: 52px;
display: flex;           /* 使用 Flex 布局让输入框和按钮在同一行对齐 */
align-items: stretch;    /* 垂直方向等高对齐 */
}
/* 输入框样式 */
.container .search input {
flex: 1;                             /* 自动占满剩余宽度 */
height: 52px;                        /* 高度与按钮一致 */
border: 1px solid #c9c9c9;           /* 边框浅灰色 */
border-right: none;                  /* 去掉右边框,与按钮拼接 */
padding: 0 12px;                     /* 左右内边距 */
color: #666;                         /* 输入文字颜色 */
font-size: 14px;                     /* 字体大小 */
box-sizing: border-box;              /* 内边距计入总宽度 */
border-radius: 26px 0 0 26px;        /* 左半圆角 */
outline: none;                       /* 去掉聚焦时的默认蓝色外框 */
}
/* 按钮样式 */
.container .search button {
width: 140px;                        /* 固定宽度 */
height: 52px;                        /* 与输入框同高 */
border: 1px solid #4e6ef2;         /* 与背景色一致的边框 */
border-left: none;                   /* 去掉左边框,与输入框无缝连接 */
background-color: #4e6ef2;         /* 蓝色背景 */
color: #fff;                       /* 白色文字 */
font-size: 19px;                     /* 字体稍大 */
font-family: Georgia, 'Times New Roman', Times, serif;
border-radius: 0 26px 26px 0;        /* 右半圆角 */
cursor: pointer;                     /* 鼠标悬停变成手型 */
}
/* 悬停时稍微变暗,点击时更暗,提供交互反馈 */
/* 悬停时稍微变暗,点击时更暗,提供交互反馈 */
.container .search button:hover { filter: brightness(0.95); }
.container .search button:active { filter: brightness(0.9); }
/* 搜索结果区域 */
.container .result {
width: 100%;
}
/* 单条搜索结果外框 */
.container .result .item {
margin-top: 15px;
}
/* 搜索结果标题链接 */
.container .result .item a {
display: block;                      /* 块级元素独占一行 */
text-decoration: none;               /* 去掉下划线 */
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;
}
/* 搜索结果的 URL */
.container .result .item i {
font-style: normal;                  /* 取消斜体 */
color: green;                      /* 绿色文字 */
}
/* 五色 BOOST Logo */
.logo {
font-size: 92px;                     /* 大字号 */
font-weight: 700;                    /* 粗体 */
letter-spacing: -3px;                /* 字距收紧 */
user-select: none;                   /* 禁止选择文字 */
/* margin-bottom: 40px;                 Logo 与搜索框间距 */
}
/* 每个字母独立上色:蓝、红、黄、蓝、绿 */
.logo span:nth-child(1) { color: #4285F4; }
.logo span:nth-child(2) { color: #EA4335; }
.logo span:nth-child(3) { color: #FBBC05; }
.logo span:nth-child(4) { color: #4285F4; }
.logo span:nth-child(5) { color: #34A853; }
/* 页面整体居中布局 */
body {
display: flex;
flex-direction: column;              /* 垂直排列 Logo 和搜索框 */
align-items: center;                 /* 水平居中 */
}

10.3 编写 js

如果直接使用原生的 js 成本会比较高(xmlhttprequest),我们推荐使用 JQuery

JS 在页面底部:

<script>
  function Search() { ... }
  function BuildHtml(data) { ... }
</script>

主要分为两个函数:

  • Search():从输入框取出 query,调用后端 /s 接口;
  • BuildHtml(data):根据后端返回的 JSON 数据构建 HTML 结果列表。

代码如下:

<script>
  function Search() {
  // 是浏览器的一个弹出框
  // alert("hello js!");
  // 1. 提取数据, '$'可以理解成就是JQuery的别称(JQuery类似于STL)
  let query = $(".container .search input").val();
  if (query == '' || query == null) {
  return;
  }
  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) {
  if (data == '' || data == null) {
  document.write("搜索的内容没有");
  return;
  }
  // 获取html中的result标签
  let result_lable = $(".container .result");
  // 清空历史搜索结果
  result_lable.empty();
  // 此时data是一个很长的Json格式的数组, 所以要挨个遍历
  for (let elem of data) {
  // 调试打印
  // console.log(elem.title);
  // console.log(elem.url);
  // 构建a标签
  let a_lable = $("<a>", {
  text: elem.title,
  href: elem.url,
  // 跳转到新的页面, 不在原网页直接显示
  target: "_blank"
  });
  // 构建p标签
  let p_lable = $("<p>", {
  text: elem.desc
  });
  // 构建i标签
  let i_lable = $("<i>", {
  text: elem.url
  });
  // 合并a/p/i标签
  let div_lable = $("<div>", {
    class: "item"
    });
    // 把a/p/i添加进item标签中
    a_lable.appendTo(div_lable);
    p_lable.appendTo(div_lable);
    i_lable.appendTo(div_lable);
    // 把item添加进result标签中
    div_lable.appendTo(result_lable);
    }
    }
    </script>

10.4 前端与后端接口对接关系

1️⃣ 请求方向

前端:

$.ajax({
type: "GET",
url: "/s?word=" + query,
...
})

后端 (http_server):

svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rsp){
if (!req.has_param("word")) { ... }
std::string word = req.get_param_value("word");
std::string json_string;
search.Search(word, &json_string);
rsp.set_content(json_string.c_str(), "application/json");
});
  • 参数名一致:都是 word;
  • 方法一致:都是 GET 请求;
  • 返回内容类型:application/json,便于前端被 jQuery 直接解析。

2️⃣ 数据流整体示意

用户在输入框输入关键字,点击 “搜索一下”
   │
   ▼
前端 JS:Search()
   - 取 input 中的内容
   - 发起 GET /s?word=xxx 的 Ajax 请求
   │
   ▼
后端 http_server
   - 提取 word 参数
   - 调用 search.Search(word, &json_string)
   - 返回 JSON 字符串
   │
   ▼
前端 success 回调
   - 收到 data(已解析成 JS 对象数组)
   - 调用 BuildHtml(data)
   - 动态构建并展示搜索结果列表

11. 项目测试

在服务器上运行 ./http_server

在这里插入图片描述

接着在浏览器输入:http://127.0.0.1:8081/,此时就可以看到整个页面了:

在这里插入图片描述

然后我们搜索关键字:regex,最终就能罗列出所有的搜索结果:

在这里插入图片描述

同时,按 F12 调出 console 控制台,可以看到详细的信息:

在这里插入图片描述

然后随便点击一个,就能跳转到官方页面:

在这里插入图片描述

在对应的服务器上,也会打印相应的日志信息:

在这里插入图片描述

如果你想让该服务上线的话,只需要在 Linux 服务器上运行下面这条命令即可:

nohup ./http_server > log/log.txt 2>&1 &

12. 项目扩展

本项目目前实现的是一个基于 Boost 文档的站内搜索引擎原型,后续可以在以下几个方向上继续扩展和演进:

  • 整站搜索:从 Boost 文档扩展为全站或多站点搜索。
  • 在线更新:设计抓取与信号机制,实现索引的实时增量更新。
  • 自研方案:不依赖第三方库,自行实现 HTTP、JSON、分词等基础模块。
  • 竞价排名:在搜索结果中加入广告出价机制,实现商业化排序。
  • 热词统计:通过字典树与优先队列实现搜索热词统计与智能联想。
  • 用户系统:引入登录注册功能,结合 MySQL 保存用户信息与搜索记录。

这样就可以把本项目变成一个 有用户体系、有数据持久化的完整 Web 系统,而不只是一个纯技术 demo。

13. 项目源码

Github:基于正倒排索引的 Boost 搜索引擎

posted @ 2025-12-06 10:32  clnchanpin  阅读(16)  评论(0)    收藏  举报