目录

项目背景

搜索引擎的宏观原理

要用到的技术栈和项目环境

正排索引、倒排索引、分词、停止词

准备工作

去标签与数据清洗

编写数据去标签与清洗模块 - parser 函数

编写 parser 代码框架

实现 Enum_file 函数

下载使用 boost 的文件系统

boost 的文件系统的简单介绍

测试 Enum_file 函数

实现 Parse_html 函数

Parse_html 函数框架

实现 Read_file 函数

实现 Parse_title 函数

实现 Parse_content 函数

实现 Parse_url 函数

测试 Parse_html 函数

实现 Save_html 函数

编写建立数据索引的模块 - index.hpp

index.hpp 的基本框架

实现 Build_index 函数

Build_index 函数的基本框架

编写 Build_forward_index 函数

编写 Build_inverted_index 函数

编写搜索引擎模块 - searcher.hpp

searcher.hpp 的基本代码结构

将 Index 类设置为单例模式

实现 search 函数

测试 searcher 

编写提取摘要的代码 - Get_desc 函数

阶段性测试

编写网络模块 - http_server.cc

准备工作(升级 gcc,下载 cpp-httplib)

正式编写 http_server.cc

编写前端代码

后续更新

解决搜索结果出现重复文档的问题

添加日志系统

项目扩展方向


项目源码:boost_searcher · 氢麟/Linux_code - 码云 - 开源中国

项目背景

  • 像百度这样的全网搜索的引擎个人是做不出来的,但是我们可以做一个站内的搜索引擎,通过站内的搜索引擎,可以管中窥豹式地推测出全网搜索引擎的大致实现。站内搜索的数据更加垂直,数据量更少,实现更加容易。
  • 全网搜索的引擎的搜索结果的陈列大致都是;网页的title,网页的摘要和即将跳转的url。
  • boost 的官网是有站内搜索的,该项目就是模拟实现这个站内搜索引擎

搜索引擎的宏观原理

搜索引擎的宏观原理可以概括为四个核心环节:抓取、处理、索引、排序。其本质是在海量信息与用户需求之间建立高效的匹配桥梁。

下面分步骤说明:

  1. 抓取(Crawling)
    搜索引擎用自动化程序(爬虫,Spider)沿着网页链接,像蜘蛛织网一样遍历互联网,将访问到的页面源代码下载到本地服务器。爬虫遵循“链接指向哪里,就爬去哪里”的规则。

  2. 处理(Processing)
    对抓取到的原始页面进行解析:去除HTML标签、提取正文、识别标题/关键词/图片/链接等元素;同时记录页面元信息(发布时间、域名等),并检测重复内容或作弊行为。

  3. 索引(Indexing)
    将处理后的内容转换成便于快速检索的“倒排索引”。倒排索引类似书籍的附录关键词表:每个关键词对应一个包含该词的文档ID列表及位置信息。这样,当用户搜索“猫”时,无需遍历所有网页,直接查索引就能定位相关文档。

  4. 排序(Ranking)
    当用户输入查询词后,搜索引擎根据数百种信号(如关键词匹配度、页面权威性、用户点击数据、时效性等)通过算法(如谷歌的PageRank、BERT等)给每个相关页面打分,最终按得分由高到低展示结果。

要用到的技术栈和项目环境

  • 技术栈:C/C++ C++11, STL,准标准库 Boost, Jsoncpp, cppjieba, cpp-httplib,html5, css,js. jQuery, Ajax
  • 项目环境: Centos 7云服务器,vim/gcc(g++)/Makefile, vs2019 or vs code

正排索引、倒排索引、分词、停止词

正排索引和倒排索引是信息检索中两种截然不同的数据结构。搜索引擎的核心之所以能快速查找,关键就在于使用了倒排索引

可以用一本书来类比,帮你直观理解两者的区别:

1. 正排索引(前向索引)

  • 结构:从“文档”指向“文档里的词”。

  • 类比书的目录。目录按页码顺序列出每一页里有哪些词。

  • 举例

    • 文档1:......雷军买了四⽄⼩⽶......

    • 文档2:......雷军发布了⼩⽶⼿机......

  • 查询方式:给你一个文档ID,能快速知道它里面有什么词。

  • 缺点:当用户搜索“手机”时,必须遍历所有文档(扫描整本书每一页),才知道文档2包含“手机”。效率极低

分词:将提取文档的所有关键字,目的是方便建立倒排索引和查找

文档1:......雷军买了四⽄⼩⽶...... -> 雷军、买、四斤、小米、四斤小米

文档2:......雷军发布了⼩⽶⼿机...... -> 雷军、发布、小米、手机、小米手机

2. 倒排索引(反向索引)

  • 结构:从“词典里的词”指向“包含该词的文档列表”。

  • 类比书的附录索引(书最后几页的关键词列表)。它告诉你每个关键词出现在哪些页码。

  • 举例

    • “雷军” → {文档1}、{文档2}

    • “买” → {文档1}

    • “四斤” → {文档1}

    • “小米" → {文档1}、{文档2}

    • "四斤小米" → {文档1}

    • "发布"  → {文档2}

    • "手机" → {文档2}

    • "小米手机" → {文档2}

  • 查询方式:用户搜索“手机”,直接查这个词的索引,立刻得到文档2。无需遍历所有文档

  • 附加信息:倒排列表里通常还记录词频、位置,用于计算相关性。排序:通过记录的附加信息,对所有文档排序,比如文档1“雷军”的词频比文档2要高(更加匹配),或者文档1是小米官方文档(权威性),或者文档1的竞价排名比文档2高,这些情况都会导致用户的搜索结果中,文档1比文档2要先出现。

对比总结

维度 正排索引 倒排索引
映射方向 文档 → 词 词 → 文档
核心作用 用于数据更新原始内容恢复 用于快速检索
能否直接回答“哪些文档包含某词”? 不能(需要全扫) (一次定位)
存储开销 较小 较大(需维护词典和倒排表)
典型应用 数据库的聚类索引(如MySQL的InnoDB) 搜索引擎(Elasticsearch、Lucene)

实际搜索引擎同时使用两者,各司其职:

  1. 倒排索引负责“查”:用户搜“北京天气” → 立刻从倒排索引中找到包含“北京”和“天气”的文档ID列表。

  2. 正排索引负责“取”:得到文档ID(比如文档3、文档8)后,用正排索引去获取这些文档的完整内容(标题、摘要、正文、URL),以便展示在搜索结果页。

模拟一次搜索过程:

⽤⼾输⼊:⼩⽶ -> 倒排索引中查找 -> 提取出⽂档ID(1,2) -> 根据正排索引 -> 找到⽂档的内容 -> title+conent(desc)+url ⽂档结果进⾏摘要->构建响应结果

总结:倒排索引用于快速定位“哪些文档匹配”,正排索引用于高效获取“匹配文档的内容”。

停止词:是指在信息检索中,为了节省存储空间和提高查询效率,在处理查询或建立索引之前,自动过滤掉的一组高频、低信息量词汇(“的”、“了”、“是”、“the”、“a”、“an”这类词)。原因:这类词几乎出现在每一个文档中。如果为这些词建立倒排索引,索引会变得极其庞大,浪费大量空间,而且搜索时也几乎无法缩小范围。搜索“苹果的电脑”和“苹果电脑”,用户意图几乎一样。保留“的”不会提升结果准确性

停止词的处理方式

有两种常见策略:

策略一:完全忽略
在建立索引时,直接跳过停止词,不为它们创建倒排记录。在用户查询时,也把查询中的停止词去掉,只用剩余的词去搜索。

  • 例子:搜索“猫和狗”,系统会自动处理为“猫” AND “狗”,忽略“和”。

策略二:降级处理(现代搜索引擎更常用)
不直接忽略,而是给停止词非常低的权重。同时利用位置信息保留短语查询能力。

  • 例子:搜索“to be or not to be”,如果完全忽略,就只剩“not”,用户无法找到莎士比亚的这句名言。现代引擎会保留这些词的位置,当用户用引号搜索短语时,依然能精确匹配。但在普通关键词搜索中,这些词的权重极低,几乎不影响排序

准备工作

  • 在 Linux 中建立新目录 boost_searcher
  • 在 boost 官网下载最新的 boost 库的压缩包
  • 在 Xshell (不能vscode)使用 rz 指令将压缩包上传到 Linux,上传过程中可能会出现乱码
  • 使用 tar xzf 指令将压缩包解压。
  • 解压之后,将压缩包删除,解压出来的目录就是 boost 库的所有内容
  • 在目录 boost_searcher 建立子目录 data/input,将 boost_1_90_0/doc/html 目录的所有内容,拷贝到 data/input,boost_1_90_0/doc/html 就是 boost_1_90_0 的所有网页文件,也就是我们制作的搜索引擎的数据源。
  • 拷贝完成后,可以把 boost_1_90_0 库删除了(不影响)

去标签与数据清洗

什么是标签?

HTML(超文本标记语言)标签是构建网页的基础元素。它们通常成对出现(如 <p> 和 </p>),但也有少数自闭合标签(如 <img />)。

常用标签

功能分类 标签示例 作用
标题 <h1>标题</h1> 文章的标题,h1最大,h6最小
段落 <p>文字</p> 展示一段文字
链接 <a href="网址">文字</a> 点击后跳转
图片 <img src="图片地址"> 显示一张图片
容器 <div> 和 <span> 用来给内容分组、布局(像透明的盒子)

(下面是 data/input/function.html 文件的一部分)

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Chapter 13. Boost.Function</title>
<link rel="stylesheet" href="../../doc/src/boostbook.css" type="text/css">
<meta name="generator" content="DocBook XSL Stylesheets V1.79.1">
<link rel="home" href="index.html" title="The Boost C++ Libraries BoostBook Documentation Subset">
<link rel="up" href="libraries.html" title="Part I. The Boost C++ Libraries (BoostBook Subset)">        
<link rel="prev" href="foreach/history_and_acknowledgements.html" title="History and Acknowledgements">
<link rel="next" href="function/history.html" title="History &amp; Compatibility Notes">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body bgcolor="white" text="black" link="#0000FF" vlink="#840084" alink="#0000FF">
<table cellpadding="2" width="100%"><tr>
<td valign="top"><img alt="Boost C++ Libraries" width="277" height="86" src="../../boost.png"></td>
<td align="center"><a href="../../index.html">Home</a></td>
<td align="center"><a href="../../libs/libraries.htm">Libraries</a></td>
<td align="center"><a href="http://www.boost.org/users/people.html">People</a></td>
<td align="center"><a href="http://www.boost.org/users/faq.html">FAQ</a></td>
<td align="center"><a href="../../more/index.htm">More</a></td>

为什么要去标签?

  • 搜索引擎在处理网页时,并不是完全“去掉”标签,而是忽略标签的视觉和布局含义,同时提取标签中蕴含的结构和语义信息。简单来说,搜索引擎关心的是“这段文字说了什么?”,而不是“这段文字是红色还是蓝色?”。
  • 网页中充斥着大量用于布局、样式和交互的代码,比如 <div><span>、CSS 类名等,这些对用户搜索结果的匹配没有帮助
  • 某些标签对搜索引擎非常重要,它们指明了内容的优先级和意义。搜索引擎不是去掉它们,而是读取并利用它们,比如 <h1> / <h2>:搜索引擎认为被这些标签包裹的文字是“标题”,权重比普通文字高很多。
  • 搜索引擎最终建立的索引,是一个巨大的关键词到网页的映射表。它需要干净、连续的文本。
  • 早期搜索引擎会直接统计页面文字。于是有人把关键词用白色字体写在白色背景上,或者塞进 <font> 标签里,用户看不见但搜索引擎能读到。搜索引擎改进后,会在解析标签时,分析样式(CSS)。如果发现文字被隐藏(display: none 或 visibility: hidden),就会降低该网页的排名或直接处罚。

去标签例子:

<div style="color:red; font-size:20px">
  <h1>苹果发布会</h1>
  <p>iPhone 15 <strong>重磅</strong> 发布</p>
</div>

搜索引擎的处理过程大致是:

  1. 解析标签:读到 <h1>,标记“苹果发布会”为高权重标题

  2. 提取文本:提取出“苹果发布会 iPhone 15 重磅 发布”。

  3. 忽略样式:丢弃 color:red 和 font-size:20px(这是给浏览器渲染用的)。

  4. 记录语义:记住 <strong> 包裹的“重磅”有强调含义。

  5. 建立索引:将处理后的文本和权重信息存入数据库。

如何去标签?

在了解了去标签之后,接下来我们在 data 目录下再创建 raw_html 目录,在该目录下创建 raw_html.txt 文件,该文件用来存放去标签处理后的 html 文件。我们现在的目标是:提取出 data/input 目录下的所有 html 文件的标题、除标签以外的内容,以及拼接出该 html 文件在 boost 官网的 url,将这些信息写入raw_html.txt 文件中,在这个文件中,用 \n 区别不同的 html 文件,同一个html 文件中,不同的信息用 \3 作为分隔符,类似:

为什么要这样处理呢?因为我们要写入文件中,写入文件的东西都要考虑下一次读取时是否方便,我们的处理方式让下一次读取很方便:直接用 string.h 的 getline 函数 get(ifstream,line),一次 getline 就可以读取一整个 html 文件的信息,而同一个 html 文件的不同信息用 \3 作为分隔符又很方便提取出不同的信息。这些功能都由 parser 函数搞定:

编写数据去标签与清洗模块 - parser 函数

编写 parser 代码框架

在 boost_searcher 目录下建立 parser.cc 文件:

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

typedef struct DocInfo
{
    std::string title;   // 网页的标题
    std::string content; // 网页的内容
    std::string url;     // 网页在官网的url
}DocInfo_t;

// boost库所有的 html 文件存放的位置
static const std::string file_path = "data/input";             

// 存放解析后的纯文本文件的位置    
static const std::string output = "data/raw_html/raw_html.txt";    

bool Enum_file(const std::string& file_path,std::vector<std::string>* files_list);
bool Parse_html(const std::vector<std::string>& flies_list,std::vector<DocInfo_t>* results);
bool Save_html(const std::vector<DocInfo_t>& results,const std::string& output);

int main() 
{
    // 第一步:枚举./data/input目录下的所有 html 文件的文件名,将结果保存到 files_list
    std::vector<std::string> files_list;
    if(!Enum_file(file_path, &files_list))
    {
        std::cerr << "Enum_File error" << std::endl;
        return -1;
    }

    // 第二步:对 files_list 中的文件进行解析,将结果保存到 results
    std::vector<DocInfo_t> results;
    if(!Parse_html(files_list,&results))
    {
        std::cerr << "Parse_html error" << std::endl;
        return -2;
    }

    // 第三步:将 results 保存到 ./data/raw_html/raw_html.txt 中
    if(!Save_html(results, output))
    {
        std::cout << "Save_html error" << std::endl;
        return -3;
    }


    return 0;
}

bool Enum_file(const std::string& file_path,std::vector<std::string>* files_list)
{
    return true;
}

bool Parse_html(const std::vector<std::string>& flies_list,std::vector<DocInfo_t>* results)
{
    return true;
}

bool Save_html(const std::vector<DocInfo_t>& results,const std::string& output)
{
    return true;
}

实现 Enum_file 函数

下载使用 boost 的文件系统

我们需要枚举 ./data/input 目录下的所有 html 文件的文件名,C/C++ 和 STL 对文件系统的支持不是很完善,所以我们需要使用 boost 库,要先在 Linux 系统安装 boost 库:

sudo yum instell -y boost-devel

安装完成后,如果要使用 boost 库中有关文件系统的库函数,需要包含头文件和展开命名空间:

#include <boost/filesystem.hpp>

using namespace boost::filesystem;

最好不要在全局作用域展开整个 boost::filesystem 命名空间,只在 Enum_file 函数内部给 boost::filesystem 起别名:

bool Enum_file(const std::string& file_path,std::vector<std::string>* files_list)
{
    namespace fs = boost::filesystem;
    // ...
    
    return true;
}

当然也可以像平时使用 std 标准库那样,比如 std::cout。但那样没有上面的方式方便,主要是 “boost::filesystem” 太长了。

我们在 Enum_file 函数中主要使用 boost::filesystem 的判断文件是否存在、判断文件是普通文件还是目录,判断文件的后缀名,遍历目录等等功能。下面做一个简单介绍:

boost 的文件系统的简单介绍

判断文件/目录是否存在

使用 fs::exists() 函数。它会返回 bool 值,如果路径存在则返回 true,否则返回 false

fs::path myPath = "C:\\example\\myfile.txt"; // Windows示例
// fs::path myPath = "/home/user/myfile.txt"; // Linux示例

if (fs::exists(myPath)) {
    std::cout << "路径存在" << std::endl;
} else {
    std::cout << "路径不存在" << std::endl;
}

判断是普通文件还是目录

在确认路径存在后,可以进一步使用 fs::is_regular_file() 和 fs::is_directory() 来判断其类型

if (fs::exists(myPath)) {
    if (fs::is_regular_file(myPath)) {
        std::cout << myPath << " 是一个普通文件。" << std::endl;
    } 
    else if (fs::is_directory(myPath)) {
        std::cout << myPath << " 是一个目录。" << std::endl;
    } 
    else {
        // 其他类型,如符号链接、设备文件等
        std::cout << myPath << " 是其他类型的文件。" << std::endl;
    }
}

判断文件后缀名

path 类提供了 extension() 方法,可以非常方便地提取文件的后缀名(包含点号 .

fs::path filePath = "data.html";

// 获取扩展名,返回的也是一个 path 对象
fs::path ext = filePath.extension();

// 转换为字符串进行比较或使用
if (ext == ".html") {
    std::cout << "这是一个HTML文件。" << std::endl;
}

// 如果只需要后缀名字符串
std::string extStr = ext.string();
std::cout << "文件后缀名是: " << extStr << std::endl; // 输出 ".html"

遍历文件夹

使用 Boost.Filesystem 遍历文件夹主要有两种方式:简单的单层遍历递归遍历所有子目录。我们需要递归遍历 ./data/input 所有子目录。

单层遍历(不进入子目录)

使用 fs::directory_iterator,它只会遍历当前目录下的直接内容,不会进入子文件夹。

 for (const auto& entry : fs::directory_iterator(dirPath)) 
 {    
      // ...

递归遍历(进入所有子目录)

for (const auto& entry : fs::recursive_directory_iterator(dirPath)) 
{
    // ...

Enum_file 函数:

bool Enum_file(const std::string& src_path,std::vector<std::string>* files_list)
{
    namespace fs = boost::filesystem; // 方便使用
    fs::path src(src_path); // 创建一个 boost::filesystem::path 对象
    
    if(!fs::exists(src)) // 判断 src_path 是否存在
    {
        std::cerr << "Enum_file: src path not exists" << std::endl;
        return false;
    }

    // 枚举 src_path 下所有的 html 文件
    for(fs::recursive_directory_iterator iter(src); iter != fs::recursive_directory_iterator(); ++iter)
    {
        if(!fs::is_regular_file(*iter)) continue; // 判断是否是普通文件
        if(fs::extension(*iter) != ".html") continue; // 筛选出 html 文件

        std::cout << "Debug: " << iter->path().string() << std::endl;
        files_list->push_back(iter->path().string());
    }

    return true;
}

测试 Enum_file 函数

接下来先测试 Enum_file 函数(把文件名写入 files_list 之前,打印出文件名,肉眼观察是否都是 .html 文件),由于我们使用了第三方库(boost 库),在 makefile 编译选项中不要忘记链接第三方库:

makefile:

parser:parser.cc
	g++ -o parser parser.cc -lboost_system -lboost_filesystem -std=c++11

.PHONY: clean
clean:
	rm -f parser

测试发现 Enum_file 函数运行正常,打印出的文件名都是 .html 文件。

实现 Parse_html 函数

Parse_html 函数的功能:解析出 files_list 的所有 html 文件的标题(title)、内容(content)和官网 url。

Parse_html 函数框架

util.hpp: 该头文件中实现一些工具类的函数

#pragma once
#include <string>
#include <fstream>
#include <iostream>

namespace ns_util
{
    class File_util
    {
    public:
        static bool Read_file(const std::string& path, std::string* content)
        {}
    };
};

parser.cc::

bool Parse_title(const std::string& html_content,std::string* title);
bool Parse_content(const std::string& html_content,std::string* content);
bool Parse_url();

bool Parse_html(const std::vector<std::string>& flies_list,std::vector<DocInfo_t>* results)
{
    // 解析出 files_list 的所有 html 文件的标题(title)、内容(content)和官网 url。
    // 把结果放在 results 中

    for(const auto& file_name : flies_list)
    {
        std::string file_content; // 先把整个html文件内容读取到 file_content 中
        if(!ns_util::FileUtil::Read_file(file_name, &file_content))
        {
            std::cerr << "Read_file error" << std::endl;
            continue;
        } 

        DocInfo_t DocInfo;

        // 解析标题
        if(!Parse_title(file_content,&DocInfo.title))
        {
            std::cerr << "Parse_title error" << std::endl;
            continue;
        }

        // 解析内容
        if(!Parse_content(file_content,&DocInfo.content))
        {
            std::cerr << "Parse_content error" << std::endl;
            continue;
        }

        // 解析官网 url
        if(!Parse_url()) // 参数目前未知
        {
            std::cerr << "Parse_url error" << std::endl;
            continue;
        }

        results->push_back(std::move(DocInfo)); // 移动语义减少拷贝
    }
    return true;
}

注意:最后将 DocInfo 写入 results 时,要使用移动语义减少拷贝,提升效率

接下来有四个函数需要实现:Read_file、Parse_title、Parse_content、Parse_url。

实现 Read_file 函数

static bool Read_file(const std::string& path, std::string* content)
{
    std::ifstream in(path,std::ios::in);
    if(!in.is_open())
    {
        std::cerr << "open file failed" << std::endl;
        return false;
    }

    std::string line;
    while(std::getline(in,line))
    {
        *content += line;
    }

    in.close();
    return true;
}

实现 Parse_title 函数

需要找到 html 文件的 <title></title> 标签,把 <title></title> 标签之间的内容提取出来

bool Parse_title(const std::string& html_content,std::string* title)
{
    std::size_t begin = html_content.find("<title>");
    if(begin == std::string::npos) return false;

    std::size_t end = html_content.find("</title>");
    if(end == std::string::npos) return false;

    begin += std::string("<title>").size();
    if(begin > end) return false;

    *title = html_content.substr(begin, end - begin);
    return true;
}

实现 Parse_content 函数

这一步的本质就是数据清洗即去标签的过程,我们把所有单标签、双标签都去掉,只保留双标签之间的内容,并且去掉源 html 文件的 \n,因为我们想用 \n 作为不同的 html 文件之间的分隔符。我们使用一个简易的状态机来指示现在只在读取的是标签还是有效内容。共有两种状态:标签状态(正在读取标签),内容状态(正在读取有效内容)。如果在标签状态读取到了 ‘>’ ,表示标签结束了,状态立刻转换为内容状态,接下来的内容是有效内容或 '<' (有效内容为空),如果在内容状态读取到了 ‘<’ ,表明有效内容结束,一个新标签开始了,状态立刻转换为标签状态。不用担心有效内容中也有 ‘<’,'>',因为在 html 文件中,为了区分标签 ‘<’,'>'和有效内容的 ‘<’,'>',有效内容的 ‘<’,'>'为 "&lt;",会被被浏览器解析为 ‘<’,'>'

bool Parse_content(const std::string& html_content,std::string* content)
{
    // 简易状态机,指示现在处于读取标签还是有效内容的状态
    enum state
    {
        LABLE,
        CONTENT
    };

    enum state cur_state = LABLE; // html 文件开始的开头一般是标签
    for(char c : html_content)
    {
        switch(cur_state)
        {
            case LABLE:
                if(c == '>') cur_state = CONTENT; 
                break;
            case CONTENT:
                if(c == '<') cur_state = LABLE;
                else
                {
                    if(c == '\n') c = ' ';
                    *content += c;
                }
                break;
            default:
                break;
        }
    }

    return true;
}

实现 Parse_url 函数

项目中 html 文件的路径与该 html 文件在官网的 url 的对应关系

以 accumulators.html 文件为例,它在官网的 url 是 https://www.boost.org/doc/libs/1_90_0/doc/html/accumulators.html,它下载后的文件的路径是:boost_1_90_0/doc/html/accumulators.html,我们在做准备工作时,将boost_1_90_0/doc/html/ 路径下所有的 html 文件拷贝到了 data/input 目录,然后将 boost_1_90_0 删除了。所以说:https://www.boost.org/doc/libs/1_90_0/doc/html/ + 项目的文件路径(去掉 data/input )== 它在官网的 url。

bool Parse_url(const std::string& file_name,std::string* url)
{
    const std::string url_head = "https://www.boost.org/doc/libs/1_90_0/doc/html";
    const std::string url_tail = file_name.substr(file_path.size());
    *url = url_head + url_tail;
    return true;
}

测试 Parse_html 函数

目前,我们已经将 Parse_html 函数的所有功能都具体实现了,现在先测试一下 Parse_html 函数是否可以正常运行,结果是否正确。我们将 Parse_html 函数解析出来的 Doc 结构体打印出来看看,由于一个 html 的有效内容可能有很多,而 html 文件的数量又有很多,为了方便我们测试,我们让Parse_html 函数只解析一个 html 文件后 break,只打印一个 Doc 结构体,如果结果正确,那么在解析其他 html 网页也应该没有问题。

void Show_Doc(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;
    std::cout << "------------------------------------------------" << std::endl;
}
bool Parse_html(const std::vector<std::string>& flies_list,std::vector<DocInfo_t>* results)
{
    for()
        // 解析标题
       
        // 解析内容

        // 构建官网 url

        results->push_back(std::move(DocInfo));
        Show_Doc(DocInfo);
        break;
    }
    return true;
}

结果示例:

title: Examples
content: ExamplesHomeLibrariesPeopleFAQMoreExamples          C++11 Examples:          Illustrates the use of Boost.Asio using only C++11 language and library          features. Where necessary, the examples make use of selected Boost C++          libraries.                  C++14 Examples:          Contains a limited set of the C++03 Boost.Asio examples, updated to use          only C++14 library and language facilities. These examples do not make          direct use of Boost C++ libraries.                  C++17 Examples:          Selected examples illustrating C++17 usage in conjunction with Technical          Specifications.                  C++20 Examples:          Selected examples using C++20 language features.        Copyright © 2003-2025 Christopher M.      Kohlhoff        Distributed under the Boost Software License, Version 1.0. (See accompanying        file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)      
url: https://www.boost.org/doc/libs/1_90_0/doc/html/boost_asio/examples.html

网页的标题确实是 Examples

提取的有效内容也没有看到有标签,复制 url 到浏览器搜索,可以正常的访问,说明 Parse_html 函数应该运行正常,结果是正确的。

实现 Save_html 函数

接下来就要将解析好的 results 结构体的内容按照我们上面规定好的“用 \n 区别不同的 html 文件,同一个html 文件中,不同的信息用 \3 作为分隔符” 的形式,写入到 raw_html.hpp 文件中:

bool Save_html(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 << "Save_html: open " << output << " error" << std::endl;
        return false;
    }

    for(const auto& doc : results)
    {
        std::string out_string;
        out_string += doc.title;
        out_string += SEP;
        out_string += doc.content;
        out_string += SEP;
        out_string += doc.url;
        out_string += "\n";

        out.write(out_string.c_str(),out_string.size());
    }

    out.close();
    return true;
}

编写建立数据索引的模块 - index.hpp

上面的 parser 函数已经做好数据去标签与清洗的工作,将结果保存到了  data /raw_html/ raw_html.txt,接下来就要根据 raw_html.txt 的内容给所有文档建立正排索引和倒排索引。

index.hpp 的基本框架

在 boost_searcher 目录下建立 index.hpp 文件:

#pragma once
#include <iostream>
#include <vector>
#include <unordered_map>

namespace ns_index
{
    struct Doc_info
    {
        std::string title;    // 文档的标题
        std::string content;  // 文档的内容
        std::string url;      // 文档在官网的url
        uint64_t Doc_id;      // 建立正排索引时,文档的ID
    };

    struct Inverted_elem
    {
        uint64_t Doc_id;       // 关键字对应的文档 ID
        std::string word;      // 倒排索引关键字
        int weight;            // 这个关键字下 Doc_id 的权重
    };

    typedef std::vector<Inverted_elem> Inverted_list;  // 倒排拉链

    class index
    {
    private:
        // 建立正排索引时,数组下标正好可以作为文档的 ID
        std::vector<Doc_info> forward_index; // 正排索引

        // 倒排索引
        std::unordered_map<std::string, Inverted_list> inverted_index;

    public:
        index(){}
        ~index(){}

    private:
        bool Build_index(const std::string& input) // input 就是 parser 解析后保存的 raw_html.txt
        {
            return true;
        }

        // 正排索引:通过文档的 ID 获取文档内容
        Doc_info* Get_forward_index(uint64_t Doc_id)
        {
            return nullptr;
        }

        // 倒排索引:通过关键字获取文档 ID
        Inverted_list* Get_inverted_index(const std::string& word)
        {
            return nullptr;
        }
    }
}

接下来有三个函数需要实现:Get_forward_index、Get_inverted_index、Build_index,其中 Build_index 的实现比较复杂,而 Get_forward_index、Get_inverted_index 的实现比较简单,我们先实现这两个函数:

// 正排索引:通过文档的 ID 获取文档内容
Doc_info* Get_forward_index(uint64_t Doc_id)
{
    if (Doc_id >= forward_index.size())
    {
        std::cerr << "Get_forward_index : Doc_id is out of range" << std::endl;
        return nullptr;
    }

    return &forward_index[Doc_id];
}

// 倒排索引:通过关键字获取文档 ID
Inverted_list* Get_inverted_index(const std::string& word)
{
    if(inverted_index.find(word) == inverted_index.end())
    {
        std::cerr << "Get_inverted_index : word haven't inverted_list" << std::endl;
        return nullptr;
    }
    return &inverted_index[word];
}

实现 Build_index 函数

Build_index 函数的基本框架

// input 就是 parser 解析后保存的 raw_html.txt
bool Build_index(const std::string& input)
{
    std::ifstream in(input,std::ios::in | std::ios::binary);
    if(!in.is_open())
    {
        std::cerr << "Build_index : open file error" << std::endl;
        return false;
    }

    std::string line;
    while(std::getline(in,line))
    {
        Doc_info* doc = Build_forward_index(line);
        if(doc == nullptr)
        {
            std::cerr << "Build_index : " << line << 
                " Build_forward_index error" << std::endl; // for debug
            continue;
        }

        Build_inverted_index(doc);
    }
    
    return true;
}

按行读取 raw_html.txt 文件的内容,每读取一行就是一个 html 文件的内容。先建立该文档的正排索引,产生 Doc_info 结构,再根据 Doc_info 结构建立倒排索引。接下来有两个函数需要实现:Build_forward_index、Build_inverted_index,即真正的建立正倒排索引的过程,这两个函数不向外暴露。

编写 Build_forward_index 函数

Doc_info* Build_forward_index(const std::string& line)
{
    // 对 line 进行拆分 -> title、content、url
    std::vector<std::string> results; // 临时存储拆分后的结果

    const std::string sep = "\3"; // 分割符
    ns_util::String_util::Split(line,&results,sep);
    if(results.size() != 3)
    {
        std::cerr << "Build_forward_index : cut error" << std::endl;
        return nullptr;
    }

    // 构建 Doc_info 结构体,push 到 forward_index 中
    Doc_info doc;
    doc.title = results[0];
    doc.content = results[1];
    doc.url = results[2];

    doc.Doc_id = forward_index.size();
    forward_index.push_back(std::move(doc)); // 移动语义减少拷贝

    return &forward_index.back();
}

我们把拆分字符串的函数作为一个工具实现在 ns_util::String_util 中。注意我们建立正排索引时文档 ID 与数组下标的对应关系:我们先让 doc.Doc_id = forward_index.size();,再 push_back 到正排索引表中,这样文档 ID 与数组下标就一一对应了。

接下来就要实现 Split 函数了:

我们要实现切分字符串的功能,可以使用 C++ 的 string 的 find 和 substr,也可以使用 C 语言的 strtok 函数,但是 C++ 的太麻烦, C 语言的 strtok 函数又会改变原字符串,所以我们使用 boost 库的 split 函数来分割字符串,下面对 boost::split 函数做简单的介绍

#include <boost/algorithm/string.hpp>

template<typename SequenceSequenceT, typename RangeT, typename PredicateT>
SequenceSequenceT& split(
    SequenceSequenceT& Result,
    RangeT&& Input,
    PredicateT Pred,
    token_compress_mode_type eCompress = token_compress_off
);
参数 说明
Result 存储分割结果的容器,如 std::vector<std::string>
Input 待分割的输入字符串
Pred 识别分隔符的谓词,常用 boost::is_any_of("分隔符") 指定
eCompress 控制是否压缩连续分隔符,可选 token_compress_on 或 token_compress_off(默认)
#include <iostream>
#include <vector>
#include <string>
#include <boost/algorithm/string.hpp>

int main() {
    std::string str = "hello,world,boost";
    std::vector<std::string> tokens;
    
    // 使用逗号作为分隔符进行分割
    boost::split(tokens, str, boost::is_any_of(","));
    
    // 输出分割结果
    for (const auto& token : tokens) {
        std::cout << token << std::endl;
    }

    // 分割结果:
    // hello
    // world
    // boost
    
    return 0;
}

压缩连续分隔符

std::string str = "hello\3\3\3world\3boost";  // 多个\3
std::vector<std::string> tokens;

// 不压缩连续分隔符
boost::split(tokens, str, boost::is_any_of("\3"), boost::token_compress_off);
// 结果:["hello", "", "","world", "boost"]

tokens.clear();

// 压缩连续分隔符,将多个\3视为一个\3
boost::split(tokens, str, boost::is_any_of("\3"), boost::token_compress_on);
// 结果:["hello", "world", "boost"]

Split 函数:

static void Split(const std::string& line, std::vector<std::string>* results, const std::string& sep)
{
    boost::split(*results, line, boost::is_any_of(sep),boost::token_compress_on);
}

编写 Build_inverted_index 函数

在编写 Build_inverted_index 函数之前,我们先清楚的知道我们要建立的倒排索引表的具体样子是什么,以下是示意图:

首先倒排索引表是一个 unordered_map,key 是关键字,value 是该关键字对应的 inverted_list,inverted_list 是一个数组,每个元素都包含:该关键字出现的文档 ID(uint64_t Doc_id),关键字是什么(std::string word),该关键字在该文档的权重(int weight),现在我们的目标就是建立这个倒排索引表。

cppjieba 的使用

分词工具我们采用 cppjieba,在 git 下载 cppjieba 后,我们在家目录创建 thirdpart 目录,将本项目所有的第三方库放入这个目录,建立软连接到我们的工作目录(boost_searcher 目录)我们只使用 Jieba 类的 CutForSearch 方法,以下是使用示例:

#include "inc/cppjieba/Jieba.hpp"
#include <iostream>
#include <vector>
#include <string>
using namespace std;

// jieba 分词所用的词库,作为分词的标准
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) 
{
  // 构建 jieba 类对象
  cppjieba::Jieba jieba(DICT_PATH,
        HMM_PATH,
        USER_DICT_PATH,
        IDF_PATH,
        STOP_WORD_PATH);
  
  string s;                // 待分词的字符串
  vector<string> words;    // 存储分词后的结果

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

  s = "中国科学院中国科学院中国科学院";
  cout << s << endl;
  cout << "[demo] CutForSearch" << endl;
  jieba.CutForSearch(s, words);
  cout << limonp::Join(words.begin(), words.end(), "/") << endl;

  return EXIT_SUCCESS;
}

输出:

小明硕士毕业于中国科学院计算所,后在日本京都大学深造
[demo] CutForSearch
小明/硕士/毕业/于/中国/科学/学院/科学院/中国科学院/计算/计算所/,/后/在/日本/京都/大学/日本京都大学/深造

中国科学院中国科学院中国科学院
[demo] CutForSearch
中国/科学/学院/科学院/中国科学院/中国/科学/学院/科学院/中国科学院/中国/科学/学院/科学院/中国科学院

如果 s = "中国科学院中国科学院中国科学院"; 我们可以统计出 “中国” 出现了 3 次,“科学”出现了 3 次,“科学院” 出现了 3 次等等

引入 jieba 到项目中

由于在建立倒排索引时要用到分词,将来用户输入的搜索关键字我们也要进行分词,所以我们将分词作为一个工具实现在 util.hpp 的 Jieba_util 类中。如果分词功能是Jieba_util 类的成员函数(假设函数名叫 Cut_string),那么我们每次分词时都要先创建一个Jieba_util 类对象,不太方便,我们想直接调用 Cut_string,那么我们就要在 Jieba_util 类中声明一个内部 cppjieba::Jieba 类对象,封装cppjieba::Jieba 类对象的 CutForSearch 函数。注意将 cppjieba 库的 dict 软连接到 boost_searcher 目录,软连接名就叫 dict。

util.hpp:

    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 Jieba_util
    {
    private:
        static cppjieba::Jieba jieba;
    public:
        // 我们想不定义 Jieba_util 类对象,直接使用 Cut_string,即 Jieba_util::Cut_string(...)
        // 所以 Cut_string 必须是一个静态成员函数
        // 又由于 Cut_string 是静态成员函数,那么它只能访问静态成员变量
        // 所以,jieba 必须是一个静态成员变量
        static void Cut_string(const std::string& src, std::vector<std::string>& out)
        {
            jieba.CutForSearch(src, out);
        }
    };

    // 静态成员变量必须在类外初始化
    cppjieba::Jieba Jieba_util::jieba(DICT_PATH,HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH);

正式编写 Build_inverted_index 函数

在正式编写 Build_inverted_index 函数之前,我们先理解建立倒排索引的过程和一些细节,建立倒排索引的过程分为 4 步:

  1. 使用 jieba 分词。
  2. 统计词频,遍历分词的结果,我们要分别统计同一词在 title 和 content 的出现的次数,好计算相关性。
  3. 自定义相关性,上面介绍搜索引擎的宏观原理时说过,关键字与文档的相关性可能与关键词匹配度、页面权威性、用户点击数据、时效性等有关,这里大大简化,我们让关键字在 title 出现时的权重是 10,在内容出现的权重是 1。
  4. 添加到倒排索引表

  • 细节:搜索引擎的关键字不区分大小写

如何做到让搜索引擎不区分大小写,都能搜索到同一个文档呢?解决方法是统一转化为小写:在建立倒排索引时,在统计词拼之前,转化为小写统计,倒排索引表的 key 也都是小写;用户输入的关键字,也都转化为小写,这样在查找倒排索引表时,不管用户输入是小写还是大写甚至是大小写混用,都能找到与之有关的文档。

bool Build_inverted_index(Doc_info* doc)
{
    // 对一个分词的统计
    struct word_cnt
    {
        int title_cnt;    // 该词在标题出现的次数
        int content_cnt;  // 该词在内容出现的次数
        // 出现的次数一般不会很多,所以使用 int
        word_cnt():title_cnt(0),content_cnt(0){}
    };

    // 统计一个文档中,所有分词出现的次数,存储在 unordered_map 中
    std::unordered_map<std::string, word_cnt> words_map;

    // 对 title 进行分词,并统计词频
    // 分词
    std::vector<std::string> words; // 存储分词结果
    ns_util::Jieba_util::Cut_string(doc->title,words);
    // 统计词频
    for(auto& word : words)
    {
        boost::to_lower(word); // 让搜索引擎支持不区分大小写
        words_map[word].title_cnt++;
    }

    // 对 content 进行分词,并统计词频
    // 分词
    std::vector<std::string> words; // 存储分词结果
    ns_util::Jieba_util::Cut_string(doc->content,words);
    // 统计词频
    for(auto& word : words)
    {
        boost::to_lower(word); // 让搜索引擎支持不区分大小写
        words_map[word].content_cnt++;
    }

// 倒排索引的权重
#define X 10
#define Y 1
    // 现在 words_map 中,存储了该文档中所有分词出现的次数
    for(auto& word_pair : words_map)
    {
        Inverted_elem elem;
        elem.Doc_id = doc->Doc_id;
        elem.word = word_pair.first;
        elem.weight = word_pair.second.title_cnt*X + word_pair.second.content_cnt*Y;

        inverted_index[word_pair.first].push_back(std::move(elem));
    }
}

编写搜索引擎模块 - searcher.hpp

我们已经做好数据清洗和去标签处理,并且建立了正倒排索引,接下来就是编写搜索引擎模块

searcher.hpp 的基本代码结构

#pragma once
#include <string>
#include "index.hpp"

namespace ns_searcher
{
    class Searcher
    {
    private:
        ns_index::index* Index;
    public:
        Searcher(){}
        ~Searcher(){}
    public:
        void Init_searcher()
        {
            // 1、创建或者获取 Index 对象(单例模式)
            // 2、使用 Index 对象建立索引
        }

        // query: 用户输入的搜索内容
        // 给用户浏览器返回的 json 字符串
        void search(const std::string& query,std::string* json_string)
        {
            // 第一步:【分词】:对 query 安装 searcher 的要求进行分词
            // 第二步:【触发】:按照分词后的个个词,进行 index 查找
            // 第三步:【合并】:汇总查找的结果
            // 第四步:【排序】:将查找的结果,按照 weight 降序排序
            // 第五步:【构建】:根据合并排序的结果,构建 json 字符串返回
        }
    }
};

将 Index 类设置为单例模式

由于我们在 Index 类建立的正排索引表(std::vector<Doc_info> forward_index)和倒排索引表(std::unordered_map<std::string, Inverted_list> inverted_index)在内存中是十分庞大的,所以我们不希望 Index 类的对象之间发生拷贝,即在搜索引擎运行时,内存中始终只有一个 Index 类的对象,即我们要将Index 类设置为单例模式。

index.hpp 的 index 类:

class index
    {
    private:
        std::vector<Doc_info> forward_index;
        std::unordered_map<std::string, Inverted_list> inverted_index;

    private:
        index(){}
        index(const index&) = delete;
        index& operator=(const index&) = delete;

        static index* instence;

        static std::mutex mtx;

    public:
        ~index(){}

    public:
        static index* Get_instence()
        {
            if(instence == nullptr)
            {
                mtx.lock();
                if(instence == nullptr)
                {
                    instence = new index();
                }
                mtx.unlock();
            }
            
            return instence;
        }
        
        // ...
    }

// 静态成员变量在类内声明,在类外初始化
std::mutex index::mtx;
index* index::instence = nullptr;

实现 search 函数

接下来有两个函数需要实现:seacher 的两个成员函数:Init_searcher 和 searcher。其中 Init_searcher 现在其实可以轻松实现了:

void Init_searcher(const std::string& input)
{
    // 1、创建或者获取 Index 对象(单例模式)
    Index = ns_index::index::Get_instence();
    
    // 2、使用 Index 对象建立索引
    Index->Build_index(input);
}

接下来实现 searcher 函数:

void search(const std::string& query,std::string* json_string)
{
    // 第一步:【分词】:对 query 安装 searcher 的要求进行分词
    std::vector<std::string> words; // 存储分词后的结果
    ns_util::Jieba_util::Cut_string(query,words);

    // 第二步:【触发】:按照分词后的个个词,进行 index 查找
    ns_index::Inverted_list Inverted_list_all; // 注意它的类型:它是数组,元素是 Inverted_elem
    for(const std::string& word : words)
    {
        ns_index::Inverted_list* inverted_list = Index->Get_inverted_index(word);
        if(inverted_list == nullptr) continue;
        Inverted_list_all.insert(Inverted_list_all.end(),inverted_list->begin(),inverted_list->end());
        // BUG : 不同的关键字可能映射到同一个文档,即Inverted_list_all可能出现重复文档
        // 导致搜索结果出现重复文档,之后解决
    }

    // 第三步:【合并】:汇总查找的结果,这一步已经在上一步完成了 -> Inverted_list_all

    // 第四步:【排序】:将查找的结果,按照 weight 降序排序
    std::sort(Inverted_list_all.begin(),Inverted_list_all.end(),\
    [](const ns_index::Inverted_elem& e1,const ns_index::Inverted_elem& e2){
        return e1.weight > e2.weight;
    });
    
    // 第五步:【构建】:根据合并排序的结果,构建 json 字符串返回
    Json::Value root;
    for(auto& item : Inverted_list_all)
    {
        // 查找正排索引
        ns_index::Doc_info* doc = Index->Get_forward_index(item.Doc_id);
        if(doc == nullptr) continue;

        Json::Value root_elem;
        root_elem["title"] = doc->title;
        root_elem["content"] = doc->content; // BUG : 展示在网页的搜索结果的 content 是部分的,之后解决
        root_elem["url"] = doc->url;

        root.append(root_elem);
    }

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

上面的 search 函数还有两个没有解决的问题:

  1. 在第二步:【触发】的时候,不同的关键字可能映射到同一个文档,query 经过分词之后也可能有相同的分词(比如用户输入“中国科学院中国科学院中国科学院”),即Inverted_list_all可能出现重复文档,导致搜索结果出现重复文档
  2. 在第五步:【构建】的时候,展示在网页的搜索结果的 content 是应该是正排索引存储的 content 的摘要,并且是与搜索关键字有关的摘要,而正排索引存储的 content 是 html 文件去标签之后的所有内容。 

测试 searcher 

现在我们已经建立正倒排索引,还有了搜索的功能,是时候测试一下这些功能了。

在 boost_searcher 目录下建立 search_test.cc 文件:

#include "searcher.hpp"
#include <string>

const std::string input = "data/raw_html/raw_html.txt";
int main()
{
    ns_searcher::Searcher* searcher = new ns_searcher::Searcher();
    searcher->Init_searcher(input);

    std::string query;
    std::string json_string;
    while(true)
    {
       
        std::cout << "请输入查询:";
        std::cin >> query;

        searcher->search(query,&json_string);
        std::cout << json_string << std::endl;
    }
    return 0;
}

makefile:

.PHONY: all
all:parser server

parser:parser.cc
	g++ -o parser parser.cc -lboost_system -lboost_filesystem -std=c++11

server:server.cc
	g++ -o server server.cc -ljsoncpp -std=c++11

.PHONY: clean
clean:
	rm -f parser server

编写提取摘要的代码 - Get_desc 函数

上面的 searcher 代码不是有一个问题没有解决吗?在第五步:【构建】的时候,展示在网页的搜索结果的 content 是应该是正排索引存储的 content 的摘要,并且是与搜索关键字有关的摘要,而正排索引存储的 content 是 html 文件去标签之后的所有内容。 

以在百度搜索“NBA经纪人角色”为例:

展示在网页的搜索结果的 content 是与搜索关键字有关的摘要,也就是说,要对构建的 json_string 的 content 做提取摘要的处理:

Json::Value root_elem;
root_elem["title"] = doc->title;
root_elem["content"] = Get_desc(doc->content,item.word);
root_elem["url"] = doc->url;

Get_desc 函数:

std::string Get_desc(const std::string& html_content,const std::string& word)
{
    size_t pos = html_content.find(word);
    if(pos == std::string::npos) return "None_1"; // 几乎不可能出现,for debug

    size_t prev_step = 50;   // 向前截取 50 个字节(如果没有就从开始截取)
    size_t next_step = 100;  // 向后截取 100 个字节(如果没有就截取到末尾)

    size_t begin = pos - prev_step >= 0 ? pos - prev_step : 0;
    size_t end = pos + next_step < html_content.size() ? pos + next_step : html_content.size();

    if(begin >= end) return "None_2"; // 几乎不可能出现,for debug
    return html_content.substr(begin,end - begin);
}

阶段性测试

现在我们对上面所有的代码进行测试。

解决搜索结果出现 “None_2” 的问题

这个问题应该是提取摘要的代码 - Get_desc 函数出现了问题,我们发现 pos、prev_step、next_step、begin、end 都是 size_t 类型,而计算 begin 时出现了 “pos - prev_step”,如果 pos - prev_step < 0,赋值给一个无符号整数将是一个很大的值,导致 begin >= end 为真,返回 “None_2”,解决办法就是将Get_desc 函数的变量全部改为 int 类型。

解决搜索结果出现 “None_1” 的问题

这个问题应该还是提取摘要的代码 - Get_desc 函数出现了问题。我们为了支持用户搜索时忽略大小写,将倒排索引的关键字和用户搜索的关键字都统一转化为小写,但是在提取摘要的时候,我们可是拿着转化为小写的关键字去寻找 content 中关键字出现的位置,问题就出现在这里,我们应该拿着原关键字而不是转化为小写的关键字去寻找。解决方法是使用 std::search 来搜索,我们自己定义搜索规则:

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

改进后的 Get_desc 函数:

std::string Get_desc(const std::string& html_content,const std::string& word)
{
    auto iter = std::search(html_content.begin(),html_content.end(),word.begin(),word.end(),[](char x,char y){
        return std::tolower(x) == std::tolower(y);
    });
    if(iter == html_content.end()) return "None_1"; // 几乎不可能出现,for debug

    int prev_step = 50;   // 向前截取 50 个字节(如果没有就从开始截取)
    int next_step = 100;  // 向后截取 100 个字节(如果没有就截取到末尾)

    int pos = std::distance(html_content.begin(),iter);
    int begin = pos - prev_step >= 0 ? pos - prev_step : 0;
    int end = pos + next_step < html_content.size() ? pos + next_step : html_content.size();

    if(begin >= end) return "None_2"; // 几乎不可能出现,for debug
    return html_content.substr(begin,end - begin);
}

测试搜索结果的 weight 是否正确并且是否按照降序排序

在 searcher 类的 search 方法中,构建的 json_string 的键值对再添加上 doc_id 和 weight:

Json::Value root_elem;
root_elem["title"] = doc->title;
// 提取doc->content中,与item.word有关的摘要
root_elem["content"] = Get_desc(doc->content,item.word);
root_elem["url"] = doc->url;

// for debug
root_elem["doc_id"] = (int)doc->Doc_id;
root_elem["weight"] = item.weight;

root.append(root_elem);

编译之后运行 search_test,搜索 “split”:

// ...   
   {
      "content" : "mingFind algorithmsReplace AlgorithmsFind IteratorSplitFirst Example            Using the algorithms is straightforward. Let us have a look at the fir",
      "doc_id" : 5161,
      "title" : "Usage",
      "url" : "https://www.boost.org/doc/libs/1_90_0/doc/html/string_algo/usage.html",
      "weight" : 15
   },
// ...

在浏览器访问 https://www.boost.org/doc/libs/1_90_0/doc/html/string_algo/usage.html 使用 ctrl + f 查找 “split” ,发现该文档有 21 个 split,而我们的 weight 只是 15,这里有 bug。但这其实没有 bug,原因是浏览器的 ctrl + f 查找 “split”,可能会包含类似“splits”、“SplitVec” 等结果,jieba 分词从 “splits”、“SplitVec” 可能分不出 “split”,我们复制 raw_html.txt 的 5162 行(即 doc_id == 5161 , raw_html.txt 的行数从 1 开始,doc_id 从 0 开始)到一个支持查找的文本编辑器,手动的数出哪些独立的、可能会被 jieba 分词的,比如 "split()"、“split.hpp” 的 “split”,发现确实是 15 个。我们可以在建立 doc_id == 5161 的文档的倒排索引时,打印出所有的分词的结果看看,看看 jieba 分词的具体结果,以验证我们的猜测

index.hpp:

// ...
            
            // 对 title 进行分词,并统计词频
            // 分词
            std::vector<std::string> title_words; // 存储分词结果
            ns_util::Jieba_util::Cut_string(doc->title,title_words);
            // 统计词频
            for(auto& word : title_words)
            {
                boost::to_lower(word); // 让搜索引擎支持不区分大小写
                words_map[word].title_cnt++;
            }

            // for debug
            if(doc->Doc_id == 5161)
            {
                std::cout << "title 的分词结果 =====================" << std::endl;
                for(auto& w : title_words) std::cout << "word:" << w << std::endl;
            }

            // 对 content 进行分词,并统计词频
            // 分词
            std::vector<std::string> content_words; // 存储分词结果
            ns_util::Jieba_util::Cut_string(doc->content,content_words);
            // 统计词频
            for(auto& word : content_words)
            {
                boost::to_lower(word); // 让搜索引擎支持不区分大小写
                words_map[word].content_cnt++;
            }

            // for debug
            if(doc->Doc_id == 5161)
            {
                std::cout << "content 的分词结果 =====================" << std::endl;
                for(auto& w : content_words) std::cout << "word:" << w << std::endl;
            }

// ...

通过验证发现我们的猜测是正确的,我们的代码是没有 bug 的。

编写网络模块 - http_server.cc

准备工作(升级 gcc,下载 cpp-httplib)

我们可以自己手写一个 http 服务器,但是没有必要,我们可以直接使用别人已经写好的库,比如 cpp-httplib 库,更方便的编写 http 服务器,不过在使用 cpp-httplib 库之前,我们要对 gcc 编译器进行升级,因为如果用较旧的编译器编译 cpp-httplib 库,要么编译不通过,要么运行时报错。yum 为了维持软件的稳定性,默认的软件都是较旧的版本,使用 gcc -v 指令查看 gcc 的版本,发现是 4.8.5 版本的,我们要将 gcc 升级为较新版本,以下是升级的过程,如果有错误的话,可以借助于 AI 工具:

// 安装scl
[hxh@VM-16-12-centos ~]$ sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++
// 安装过程。。。
// 因为 CentOS 7 的官方仓库已经停止维护(EOL),导致 mirrorlist.centos.org 无法解析。需要将仓库
// 源迁移到 Vault 归档镜像。

// 将所有 CentOS 仓库的 mirrorlist 替换为 baseurl,指向 Vault 镜像:
# 备份现有仓库配置
sudo mkdir -p /etc/yum.repos.d/backup
sudo mv /etc/yum.repos.d/*.repo /etc/yum.repos.d/backup/

# 创建新的 repo 配置(使用阿里云镜像)
sudo cat > /etc/yum.repos.d/CentOS-Vault.repo << 'EOF'
[C7.9.2009-base]
name=CentOS-7.9.2009 - Base
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/os/$basearch/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[C7.9.2009-updates]
name=CentOS-7.9.2009 - Updates
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/updates/$basearch/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[C7.9.2009-extras]
name=CentOS-7.9.2009 - Extras
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/extras/$basearch/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[centos-sclo-rh]
name=CentOS-7 - SCLo rh
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/sclo/$basearch/rh/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[centos-sclo-sclo]
name=CentOS-7 - SCLo sclo
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/sclo/$basearch/sclo/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1
EOF

# 清理并重建缓存
sudo yum clean all
sudo yum makecache

// bash: /etc/yum.repos.d/CentOS-Vault.repo: Permission denied
// 权限被拒绝了,因为 sudo cat > 的重定向操作是在当前 shell 中执行的,而不是在 sudo 权限下。需
// 要用其他方式写入文件。

// 使用 sudo tee(推荐)
sudo tee /etc/yum.repos.d/CentOS-Vault.repo << 'EOF'
[C7.9.2009-base]
name=CentOS-7.9.2009 - Base
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/os/$basearch/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[C7.9.2009-updates]
name=CentOS-7.9.2009 - Updates
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/updates/$basearch/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[C7.9.2009-extras]
name=CentOS-7.9.2009 - Extras
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/extras/$basearch/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[centos-sclo-rh]
name=CentOS-7 - SCLo rh
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/sclo/$basearch/rh/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1

[centos-sclo-sclo]
name=CentOS-7 - SCLo sclo
baseurl=http://mirrors.aliyun.com/centos-vault/7.9.2009/sclo/$basearch/sclo/
gpgcheck=1
gpgkey=http://mirrors.aliyun.com/centos-vault/RPM-GPG-KEY-CentOS-7
enabled=1
EOF

// 安装新版本gcc
[whb@VM-0-3-centos boost_searcher]$ sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++
// 安装过程 。。。
// GPG 密钥验证失败了。这是因为 SCLo 仓库的包需要特定的 GPG 密钥,而不是 CentOS 主仓库的密钥。
// 我们需要禁用 GPG 检查或者添加正确的 GPG 密钥。

// 临时禁用 GPG 检查(最快)
sudo yum install -y --nogpgcheck devtoolset-7-gcc devtoolset-7-gcc-c++

[hxh@VM-16-12-centos ~]$ ls /opt/rh/
devtoolset-7
[hxh@VM-16-12-centos ~]$ scl enable devtoolset-7 bash
bash: __vsc_prompt_cmd_original: command not found
[hxh@VM-16-12-centos ~]$ scl enable devtoolset-7 bash
bash: __vsc_prompt_cmd_original: command not found

// 太好了!安装成功了。bash: __vsc_prompt_cmd_original: command not found 这个错误只是 
// VSCode 终端的一个小提示,不影响 devtoolset-7 的使用。

// 验证 gcc 版本
[hxh@VM-16-12-centos ~]$ gcc -v
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/opt/rh/devtoolset-7/root/usr/libexec/gcc/x86_64-redhat-linux/7/lto-wrapper
Target: x86_64-redhat-linux
Configured with: ../configure --enable-bootstrap --enable-languages=c,c++,fortran,lto --prefix=/opt/rh/devtoolset-7/root/usr --mandir=/opt/rh/devtoolset-7/root/usr/share/man --infodir=/opt/rh/devtoolset-7/root/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-shared --enable-threads=posix --enable-checking=release --enable-multilib --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-gcc-major-version-only --enable-plugin --with-linker-hash-style=gnu --enable-initfini-array --with-default-libstdcxx-abi=gcc4-compatible --with-isl=/builddir/build/BUILD/gcc-7.3.1-20180303/obj-x86_64-redhat-linux/isl-install --enable-libmpx --enable-gnu-indirect-function --with-tune=generic --with-arch_32=i686 --build=x86_64-redhat-linux
Thread model: posix
gcc version 7.3.1 20180303 (Red Hat 7.3.1-5) (GCC) 
bash: __vsc_prompt_cmd_original: command not found

注意:上面的 gcc 升级只是临时的,关闭当前终端,重新打开一个终端后,发现 gcc 版本又变回老版本了。如果要永久使用较新版本的 gcc,可以在 ~/.bashrc 配置文件添加:

export PATH=/opt/rh/devtoolset-7/root/usr/bin:$PATH
export LD_LIBRARY_PATH=/opt/rh/devtoolset-7/root/usr/lib64:$LD_LIBRARY_PATH
export MANPATH=/opt/rh/devtoolset-7/root/usr/share/man:$MANPATH

接下来把 cpp-httplib 下载并解压到之前在家目录建立好的 thirdpart 目录,通过软连接的方式连接到 boost_searcher 目录。

cpp-httplib 的使用方法

cpp-httplib 是一个极其轻量且易于上手的 C++ HTTP 库。它的核心优势是“单文件、头文件”,你只需将 httplib.h 文件包含到项目中,就能立刻拥有 HTTP 客户端和服务端的能力,无需复杂的配置和链接,下面是 cpp-httplib 最经典的“Hello World”示例。它创建了一个监听在 8080 端口的服务器,并对路径 /hi 的 GET 请求返回 "Hello World!" 文本。

#include "cpp-httplib/httplib.h"

const std::string root_path = "./wwwroot";

int main() {
    // 1. 实例化一个Server对象
    httplib::Server svr;

    // 2. 设置 web 根目录
    svr.set_base_dir(root_path.c_str());

    // 3. 注册路由和对应的处理函数
    // 当收到 GET /hi 请求时,执行lambda表达式
    svr.Get("/hi", [](const httplib::Request& req, httplib::Response& res) {
        res.set_content("Hello World!", "text/plain");
    });

    // 4. 启动服务,监听所有IP的8080端口
    svr.listen("0.0.0.0", 8080);
    return 0;
}

makefile:(注意 http_test 要链接线程库)

.PHONY: all
all:parser search_test http_test

parser:parser.cc
	g++ -o parser parser.cc -lboost_system -lboost_filesystem -std=c++11

search_test:search_test.cc
	g++ -o search_test search_test.cc -ljsoncpp -std=c++11

http_test:http_test.cc
	g++ -o http_test http_test.cc -ljsoncpp -lpthread -std=c++11

.PHONY: clean
clean:
	rm -f parser search_test http_test

在 boost_searcher 目录创建 wwwroot 目录,在该目录下创建 index.html 文件:

<!DOCTYPE html>

<html>
<head>
<meta charset = "UTF-8">
<title> for test </title>
</head>
<body>
<h1> 你好, 世界 </h1>
<p> 这是⼀个httplib的测试⽹⻚ </p>
</body>
</html>

在浏览器 url 搜索栏输入 你的服务器的公网 IP:8080 

正式编写 http_server.cc

现在,我们将搜索引擎模块和网络模块合在一起,初步形成一个提供搜索服务的 boost http 网页搜索引擎:

http_server.cc:

#include <iostream>
#include "cpp-httplib/httplib.h"
#include "searcher.hpp"

const std::string root_path = "./wwwroot";
const std::string input = "data/raw_html/raw_html.txt";
int main() {
    // 实例化一个Server对象
    httplib::Server svr;

    // 设置 web 根目录
    svr.set_base_dir(root_path.c_str());

    // 实例化一个搜索对象
    ns_searcher::Searcher searcher;
    searcher.Init_searcher(input);

    // 注册路由和对应的处理函数
    // 当收到 GET /s 请求时,表明用户要执行搜索服务
    svr.Get("/s", [&searcher](const httplib::Request& req, httplib::Response& res) {
        if(!req.has_param("word"))
        {
            res.set_content("请输入搜索关键字", "text/plain");
            return;
        }
        std::string query = req.get_param_value("word");
        std::cout << "用户在搜索: " << query << std::endl;
        std::string json_string;
        searcher.search(query, &json_string);
        res.set_content(json_string, "application/json");
    });

    // 启动服务,监听所有IP的8080端口
    svr.listen("0.0.0.0", 8080);
    return 0;
}

在浏览器的 url 输入框输入:你的服务器的公网ip:8080/s?word=filesystem ,浏览器输出的结果:

编写前端代码

我们几乎已经将后端代码写好了,接下来要编写前端代码,让我们的搜索界面更加美观一点。

了解 html、css、JavaScript:

html: 是网页的骨骼 -- 负责网页结构

css:网页的皮肉 -- 负责网页美观的

js(javascript):网页的灵魂---负责动态效果,和前后端交互

教程: https://www.w3school.com.cn/

编写 index.html :

直接 deepseek 神力,让它把 html\css\js 都帮我们都搞定:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>boost 搜索引擎</title>
  <!-- 字体 -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..600;1,14..32,400..600&display=swap" rel="stylesheet">
  <!-- Font Awesome 用于搜索按钮图标 -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
  <!-- 引入 jQuery -->
  <script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
  <style>
    /* 全局重置 — 干净基底 */
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      min-height: 100vh;
      background: linear-gradient(145deg, #f7f9fc 0%, #eef2f7 100%);
      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
      display: flex;
      align-items: flex-start;
      justify-content: center;
      padding: 2rem 1rem;
    }

    /* 主容器 — 无外框,纯内容 */
    .container {
      width: 100%;
      max-width: 820px;
      margin: 0 auto;
      background: transparent;
      box-shadow: none;
      backdrop-filter: none;
      border: none;
      padding: 0;
    }

    /* 搜索栏 */
    .container .search {
      width: 100%;
      height: auto;
      display: flex;
      align-items: stretch;
      gap: 12px;
      margin-bottom: 28px;
      flex-wrap: wrap;
    }

    .container .search input {
      flex: 1 1 300px;
      min-width: 200px;
      height: 60px;
      padding: 0 24px;
      border: 1.5px solid rgba(78, 110, 242, 0.25);
      border-radius: 40px;
      background: rgba(255, 255, 255, 0.7);
      backdrop-filter: blur(4px);
      font-size: 16px;
      font-weight: 450;
      color: #1a2639;
      outline: none;
      transition: all 0.25s cubic-bezier(0.2, 0, 0, 1);
      box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.02);
    }

    .container .search input::placeholder {
      color: #a0b3cf;
      font-weight: 400;
    }

    .container .search input:focus {
      border-color: #4e6ef2;
      background: #ffffff;
      box-shadow: 0 4px 16px rgba(78, 110, 242, 0.12), inset 0 2px 4px rgba(0,0,0,0.01);
    }

    .container .search input:hover {
      border-color: rgba(78, 110, 242, 0.5);
    }

    .container .search button {
      flex: 0 0 auto;
      height: 60px;
      padding: 0 40px;
      border: none;
      border-radius: 40px;
      background: linear-gradient(135deg, #4e6ef2 0%, #3b5ad9 100%);
      color: #fff;
      font-size: 18px;
      font-weight: 600;
      font-family: 'Inter', sans-serif;
      letter-spacing: 0.3px;
      cursor: pointer;
      transition: all 0.25s cubic-bezier(0.2, 0, 0, 1);
      box-shadow: 0 8px 24px rgba(78, 110, 242, 0.25), 0 2px 6px rgba(78, 110, 242, 0.2);
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 10px;
      white-space: nowrap;
      border: 1px solid rgba(255, 255, 255, 0.15);
    }

    .container .search button i {
      font-size: 18px;
      opacity: 0.8;
    }

    .container .search button:hover {
      background: linear-gradient(135deg, #5a7af5 0%, #4363e0 100%);
      box-shadow: 0 12px 32px rgba(78, 110, 242, 0.35), 0 4px 12px rgba(78, 110, 242, 0.2);
      transform: translateY(-2px) scale(1.02);
    }

    .container .search button:active {
      transform: scale(0.97);
      box-shadow: 0 4px 12px rgba(78, 110, 242, 0.2);
    }

    /* 结果区域 */
    .container .result {
      width: 100%;
      margin-top: 0;
      padding-top: 0;
      min-height: 60px;
    }

    /* 每个结果条目 — 干净卡片 */
    .container .result .item {
      margin-top: 12px;
      padding: 18px 20px 16px 24px;
      background: rgba(255, 255, 255, 0.5);
      backdrop-filter: blur(4px);
      border-radius: 28px;
      transition: all 0.25s ease;
      border: 1px solid rgba(255, 255, 255, 0.6);
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
      cursor: default;
      overflow: hidden; /* 防止内容溢出 */
    }

    .container .result .item:hover {
      background: rgba(255, 255, 255, 0.85);
      box-shadow: 0 8px 28px rgba(78, 110, 242, 0.06), 0 2px 8px rgba(0, 0, 0, 0.02);
      border-color: rgba(78, 110, 242, 0.15);
      transform: translateY(-2px);
    }

    /* 标题链接 — 点击跳转 */
    .container .result .item a.title-link {
      display: block;
      text-decoration: none;
      font-size: 20px;
      font-weight: 550;
      color: #1f2b4a;
      letter-spacing: -0.02em;
      transition: color 0.15s ease;
      line-height: 1.4;
      margin-bottom: 2px;
      cursor: pointer;
      word-break: break-word; /* 长单词换行 */
    }

    .container .result .item a.title-link:hover {
      color: #4e6ef2;
      text-decoration: underline;
    }

    /* 摘要描述文字 */
    .container .result .item .desc {
      margin-top: 6px;
      font-size: 15px;
      font-weight: 400;
      color: #4b5a73;
      line-height: 1.6;
      letter-spacing: -0.01em;
      padding-right: 8px;
      font-family: 'Inter', sans-serif;
      word-break: break-word;
    }

    /* 网址标签 — 可点击跳转,带截断功能 */
    .container .result .item .url-tag {
      display: inline-block;
      max-width: 100%;
      font-style: normal;
      color: #1d8b5e;
      background: rgba(29, 139, 94, 0.08);
      padding: 4px 14px 4px 14px;
      border-radius: 40px;
      font-size: 13px;
      font-weight: 500;
      letter-spacing: 0.2px;
      margin-top: 8px;
      backdrop-filter: blur(2px);
      border: 1px solid rgba(29, 139, 94, 0.12);
      transition: all 0.2s ease;
      cursor: pointer;
      text-decoration: none;
      /* 关键:截断长URL */
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
      max-width: 100%;
      vertical-align: middle;
    }

    .container .result .item .url-tag:hover {
      background: rgba(29, 139, 94, 0.2);
      border-color: rgba(29, 139, 94, 0.3);
      transform: scale(1.02);
      box-shadow: 0 2px 8px rgba(29, 139, 94, 0.15);
    }

    .container .result .item .url-tag i {
      margin-right: 6px;
      font-size: 11px;
      opacity: 0.7;
      flex-shrink: 0; /* 图标不压缩 */
    }

    .container .result .item .url-tag .url-text {
      display: inline;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .container .result .item .url-tag::before {
      content: none;
      display: none;
    }

    /* 空状态提示 */
    .container .result .empty-tip {
      text-align: center;
      color: #a0b3cf;
      font-size: 16px;
      padding: 40px 20px;
      letter-spacing: 0.3px;
      user-select: none;
    }

    /* 调试信息 */
    .container .result .debug-info {
      background: rgba(0, 0, 0, 0.05);
      padding: 15px 20px;
      border-radius: 12px;
      font-size: 13px;
      color: #555;
      font-family: 'Courier New', monospace;
      margin-top: 10px;
      white-space: pre-wrap;
      word-break: break-all;
    }

    /* 响应式 */
    @media (max-width: 640px) {
      body {
        padding: 1rem 0.8rem;
      }
      .container {
        padding: 0;
      }
      .container .search input {
        height: 54px;
        flex: 1 1 100%;
        border-radius: 40px;
        padding: 0 20px;
      }
      .container .search button {
        height: 54px;
        flex: 1 1 100%;
        justify-content: center;
        padding: 0 20px;
        border-radius: 40px;
      }
      .container .result .item {
        padding: 16px 16px 14px 18px;
      }
      .container .result .item a.title-link {
        font-size: 18px;
      }
      .container .result .item .url-tag {
        font-size: 12px;
        padding: 3px 12px;
        max-width: 100%;
      }
    }

    /* 小屏幕进一步优化 */
    @media (max-width: 480px) {
      .container .result .item .url-tag {
        font-size: 11px;
        padding: 3px 10px;
        max-width: 100%;
      }
    }
  </style>
</head>
<body>
  <div class="container">
    <!-- 搜索区域 -->
    <div class="search">
      <input type="text" placeholder="输入关键词,搜索海量文档…" id="searchInput">
      <button onclick="Search()">
        <i class="fas fa-search"></i> 搜索
      </button>
    </div>

    <!-- 结果区域 -->
    <div class="result">
      <div class="empty-tip">输入关键词,点击搜索</div>
    </div>
  </div>

  <script>
    /**
     * 搜索函数 — 与后端 C++ 服务 (/s?word=) 交互
     */
    function Search() {
        // 1. 提取输入框数据
        let query = $(".container .search input").val();
        console.log("搜索关键词: " + query);

        // 输入为空时显示提示
        if (!query || query.trim() === '') {
            $(".container .result").html('<div class="empty-tip">请输入搜索关键词</div>');
            return;
        }

        // 显示加载状态
        $(".container .result").html('<div class="empty-tip">搜索中...</div>');

        // 2. 发起 HTTP GET 请求到后端 /s 接口
        $.ajax({
            type: "GET",
            url: "/s?word=" + encodeURIComponent(query),
            dataType: "json",
            success: function(data) {
                console.log("=== 后端返回原始数据 ===");
                console.log(JSON.stringify(data, null, 2));
                BuildHtml(data);
            },
            error: function(xhr, status, error) {
                console.error("请求失败: ", status, error);
                let errorMsg = '请求失败,请稍后重试';
                if (xhr.status === 404) {
                    errorMsg = '后端服务未找到,请检查服务是否启动';
                } else if (xhr.status === 0) {
                    errorMsg = '无法连接到后端服务,请确认服务是否运行在8080端口';
                }
                $(".container .result").html('<div class="empty-tip" style="color: #e74c3c;">' + errorMsg + '</div>');
            }
        });
    }

    /**
     * 截断URL函数,保留域名和路径,显示省略号
     * @param {string} url - 原始URL
     * @param {number} maxLength - 最大显示长度
     * @returns {string} 截断后的URL
     */
    function truncateUrl(url, maxLength = 60) {
        if (!url || url.length <= maxLength) {
            return url;
        }
        
        // 尝试提取域名和路径
        try {
            // 移除协议前缀
            let displayUrl = url.replace(/^https?:\/\//, '');
            
            // 如果还是太长,截断并添加省略号
            if (displayUrl.length > maxLength) {
                // 尝试在路径部分截断
                let parts = displayUrl.split('/');
                if (parts.length > 1) {
                    // 保留域名和前两级路径
                    let domain = parts[0];
                    let path = parts.slice(1, 3).join('/');
                    if (domain.length + path.length + 3 <= maxLength) {
                        return domain + '/' + path + '/…';
                    }
                }
                // 简单截断
                return displayUrl.substring(0, maxLength - 1) + '…';
            }
            return displayUrl;
        } catch (e) {
            // 如果解析失败,直接截断
            return url.substring(0, maxLength - 1) + '…';
        }
    }

    /**
     * 构建搜索结果 HTML
     * @param {Array} data — 后端返回的 JSON 数组
     */
    function BuildHtml(data) {
        let result_label = $(".container .result");
        result_label.empty();

        // 如果数据为空或没有结果
        if (!data || data.length === 0) {
            result_label.html('<div class="empty-tip">没有找到相关结果</div>');
            return;
        }

        // 检查数据格式
        if (!Array.isArray(data)) {
            result_label.html(`
                <div class="empty-tip" style="color: #e67e22;">
                    数据格式异常,期望数组,实际类型: ${typeof data}
                </div>
                <div class="debug-info">${JSON.stringify(data, null, 2)}</div>
            `);
            return;
        }

        // 遍历数据,构建 DOM
        for (let i = 0; i < data.length; i++) {
            let elem = data[i];
            
            // 尝试多种可能的字段名
            let title = elem.title || elem.Title || elem.name || elem.Name || "无标题";
            let url = elem.url || elem.Url || elem.link || elem.Link || "#";
            let desc = elem.desc || elem.Desc || elem.description || elem.Description || elem.content || elem.Content || elem.summary || elem.Summary || "";
            
            // 如果desc为空,尝试从content或body获取
            if (!desc || desc.trim() === '') {
                desc = elem.content || elem.Content || elem.body || elem.Body || "暂无摘要描述";
            }
            
            // 如果desc还是空,显示提示
            if (!desc || desc.trim() === '') {
                desc = "暂无摘要描述";
            }

            // 创建外层 <div class="item">
            let div_label = $("<div>", {
                class: "item"
            });

            // 创建 <a> 标签 (标题) — 点击跳转
            let a_label = $("<a>", {
                class: "title-link",
                text: title,
                href: url,
                target: "_blank",
                title: "点击跳转到 " + url  // 鼠标悬停提示完整URL
            });

            // 创建 <div class="desc"> 标签 (摘要)
            let desc_label = $("<div>", {
                class: "desc",
                text: desc
            });

            // 截断URL用于显示,保留完整URL用于跳转
            let displayUrl = truncateUrl(url, 50);
            
            // 创建 <span class="url-tag"> 标签 (网址) — 点击跳转,带截断
            let url_label = $("<a>", {
                class: "url-tag",
                href: url,
                target: "_blank",
                title: "点击跳转到 " + url,  // 鼠标悬停显示完整URL
                html: '<i class="fas fa-external-link-alt"></i> ' + displayUrl
            });

            // 组装
            a_label.appendTo(div_label);
            desc_label.appendTo(div_label);
            url_label.appendTo(div_label);
            div_label.appendTo(result_label);
        }
    }

    // 页面加载完成后,显示空状态
    $(document).ready(function() {
        let result = $(".container .result");
        if (result.children().length === 0) {
            result.html('<div class="empty-tip">输入关键词,点击搜索</div>');
        }
        console.log("=== boost 搜索引擎已加载 ===");
        console.log("后端接口: /s?word=关键词");
        console.log("💡 点击标题或URL链接即可跳转到对应网页");
        console.log("💡 过长的URL会自动截断并显示省略号,鼠标悬停可查看完整地址");
    });

    // 支持回车键搜索
    $(document).ready(function() {
        $("#searchInput").keypress(function(event) {
            if (event.which === 13) {
                Search();
            }
        });
    });
  </script>
</body>
</html>

现在的搜索结果界面:

后续更新

解决搜索结果出现重复文档的问题

我们将 /boost_searcher/data/input/bbv2/installation.html 这个文件的内容改为:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
  <head>
  <!-- Copyright (C) 2002 Douglas Gregor <doug.gregor -at- gmail.com>

      Distributed under the Boost Software License, Version 1.0.
      (See accompanying file LICENSE_1_0.txt or copy at
      http://www.boost.org/LICENSE_1_0.txt) -->
    <title>用来测试</title>
    <meta http-equiv="refresh" content="0; URL=../bbv2.html#bbv2.installation">
  </head>
  <body>
    你是一个好人
    <a href="../bbv2.html#bbv2.installation">../bbv2.html#bbv2.installation</a>
  </body>
</html>

这个 html 文件的 content 被我们改为了 “你是一个好人”,“你是一个好人” 被 jieba 分词可能分为 “你” “是” “一个” “好人”,所以如果我们在搜索 “你是一个好人” 的话,“你是一个好人” 被 jieba 分词分为 “你” “是” “一个” “好人”,而 “你” “是” “一个” “好人” 的倒排索引都指向 installation.html,而我们在构建 json_string 时,把倒排拉链不管三七二十一都 push_back 到 Inverted_list_all 所以我们在搜索时就会出现重复文档。

解决方法:创建在 namespace ns_searcher 创建 struct uni_inverted_elem,这个 uni_inverted_elem 就是所有重复的 doc_id 累加的结果

// 在第二步:【触发】时,将重复文档都累加到一个 elem
    struct uni_inverted_elem
    {
        uint64_t Doc_id;
        int weight;                                // 累加后的 weight
        std::vector<std::string> words;            // 累加后的 words
        uni_inverted_elem():Doc_id(0),weight(0){}
    };

比如搜索 “你是一个好人”,累加后的 weight 应该是 4,累加后的 words 应该是 {“你” “是” “一个” “好人”}。

再创建一个 std::unordered_map<uint64_t,uni_inverted_elem> uni_Inverted_list_all; 即每个 doc_id 都唯一对应一个 uni_inverted_elem,用 word 找到倒排拉链后,遍历倒排拉链,现在不一股脑的 push_back 到 Inverted_list_all,而是累加到 uni_Inverted_list_all[inverted_item.Doc_id]。

for(auto& inverted_item : *inverted_list)
{
    uni_inverted_elem& elem = uni_Inverted_list_all[inverted_item.Doc_id]; // Doc_id 第一次出现
    elem.Doc_id = inverted_item.Doc_id;
    elem.weight += inverted_item.weight;
    elem.words.push_back(word);
}

最后再遍历 uni_Inverted_list_all,pash_back 到 Inverted_list_all,对 Inverted_list_all 进行排序:

std::vector<uni_inverted_elem> Inverted_list_all;
for(const auto& item : uni_Inverted_list_all) Inverted_list_all.push_back(item.second);
std::sort(Inverted_list_all.begin(),Inverted_list_all.end(),\
[](const uni_inverted_elem& e1,const uni_inverted_elem& e2){
    return e1.weight > e2.weight;
});

现在的搜索结果:

添加日志系统

log.hpp:

#pragma once
#include <string>
#include <ctime>
#include <cstdio>
#include <cstdarg>
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define Info 0
#define Debug 1
#define Waring 2
#define Error 3
#define Fatal 4

#define Screen 1
#define OneFile 2
#define ClassFile 3

#define LogFileName "log.txt"

#define SIZE 1024
class Log
{
public:
    Log()
    {
        PrintMethod = Screen;
        path = "./log/";
    }

    void Enable(int Method)
    {
        PrintMethod = Method;
    }

    std::string LevelToString(int level)
    {
        switch(level)
        {
            case Info:
                return "Info";
            case Debug:
                return "Debug";
            case Waring:
                return "Waring";
            case Error:
                return "Error";
            case Fatal:
                return "Fatal";
            default:
                return "None";
        }
    }

    void operator()(int level, const char* format,...)
    {
        time_t t = time(nullptr);
        struct tm* local_time = localtime(&t);
        char leftbuffer[SIZE] = {0};
        snprintf(leftbuffer,sizeof(leftbuffer),"[%s][%d-%d-%d %d:%d:%d]",LevelToString(level).c_str(),
            local_time->tm_year + 1900,
            local_time->tm_mon + 1,
            local_time->tm_mday,
            local_time->tm_hour,
            local_time->tm_min,
            local_time->tm_sec);

        va_list s;
        va_start(s, format);
        char rightbuffer[SIZE];
        vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
        va_end(s);

        // 格式:默认部分+自定义部分
        char logtxt[SIZE * 2];
        snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);

        // printf("%s", logtxt); // 暂时打印
        PrintLog(level, logtxt);
    }

    void PrintLog(int level,const std::string &logtxt)
    {
        switch(PrintMethod)
        {
            case Screen:
                std::cout << logtxt << std::endl; break;
            case OneFile:
                PrintOneFile(LogFileName,logtxt); break;
            case ClassFile:
                PrintClassFile(level,logtxt); break;
            default:
                std::cout << logtxt << std::endl; break;
        }
    }
    void PrintOneFile(const std::string &logname,const std::string &logtxt)
    {
        const std::string _logname = path + logname;
        int fd = open(_logname.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);
        if(fd < 0) 
        {
            perror("open");
            return;
        }

        write(fd,logtxt.c_str(),logtxt.size());
        close(fd);
    }

    void PrintClassFile(int level, const std::string &logtxt)
    {
        const std::string logname = LogFileName + '.' + LevelToString(level);
        PrintOneFile(logname,logtxt);
    }

    ~Log()
    {}

private:
    int PrintMethod;
    std::string path;
};

Log lg;

项目扩展方向

  • 建⽴ boost 整站搜索
  • 设计⼀个在线更新的⽅案,信号,爬⾍,完成整个服务器的设计
  • 不使⽤组件,⽽是⾃⼰设计⼀下对应的各种⽅案(有时间,有精⼒)
  • 在我们的搜索引擎中,添加竞价排名(强烈推荐)
  • 热词统计,智能显⽰搜索关键词(字典树,优先级队列)(⽐较推荐)
  • 设置登陆注册,引⼊对mysql的使⽤(⽐较推荐的)

更多推荐