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

1358 字
7 分钟
基于正倒排索引的 Boost 搜索引擎

项目简介#

这是一个从零手写的全文搜索引擎:给它一堆网页,它能建好索引,然后你输入关键词,它按相关度给你返回标题、摘要和原文链接。

语料用的是 Boost 1.82 官方文档的离线版本:56 个目录、9022 个文件,其中 8563 篇 HTML,合计 92.7 MB。整个搜索引擎的核心逻辑(去标签、分词、建索引、算权重、排序、生成摘要)没有用任何现成的检索库,自写 C++ 一共 734 行。

在线体验:http://106.52.158.48:8081/

效果与数据规模#

环节规模
原始语料8563 篇 HTML,92.7 MB
清洗后的语料 raw.txt16.6 MB / 8563 行(体积压到原来的 17.9%)
索引结构正排索引(8563 条文档)+ 倒排索引(词 → 文档列表)
检索响应一次查询走完”分词 → 召回 → 排序 → 生成摘要”,返回 JSON

整体流程#

离线(跑一次) 在线(服务常驻)
┌──────────────────────┐ ┌──────────────────────────────┐
│ parser 预处理 │ │ http_server(cpp-httplib) │
│ HTML → 去标签 │ │ GET /s?word=xxx │
│ 提取标题 / 拼原文 URL │ │ │ │
│ 写成 raw.txt │ │ ▼ │
└──────────┬───────────┘ │ searcher 检索 │
│ │ 分词 → 查倒排 → 排序 → 摘要 │
▼ │ │ │
data/raw_html/raw.txt ─────────►│ index 正排 + 倒排(内存) │
└──────────┬───────────────────┘
▼
wwwroot 网页结果列表

一、预处理:把网页洗成结构化文本#

parser 负责离线那一段:递归遍历语料目录取出所有 HTML,用状态机去掉 HTML 标签只留正文,单独截出 <title> 作为标题,再根据本地路径反推出这篇文档在 Boost 官网的地址,方便结果里直接点回原文。

清洗时有几个细节:原始 HTML 的换行和连续空白会被压掉(换行要留给自己当文档分隔符用),每篇文档按 标题 \3 正文 \3 原文链接 的格式写成一行 —— 用不可见字符 \3 当分隔符,是因为它几乎不可能出现在正文里,比用换行更安全。8563 篇 HTML 洗出 8563 行,一篇没丢。

二、建索引:正排 + 倒排各司其职#

这是搜索引擎最核心的一步,也是我花时间最多的地方:

std::vector<DocInfo> forward_index; // 正排:数组下标就是文档 ID
std::unordered_map<std::string, InvertedList> inverted_index; // 倒排:词 → 文档列表
  • 正排索引回答”第 N 篇文档的标题、正文、链接是什么”。用 vector 存、下标天然就是文档 ID,取一篇文档是 O(1)。
  • 倒排索引回答”哪些文档包含这个词”。每个词对应一条”倒排拉链”,链上挂着 {文档 ID, 权重}。
  • 两者缺一不可:倒排负责找到,正排负责讲清楚(结果里的标题、摘要、链接都是靠文档 ID 回正排取的)。

打分用的是最简单的相关性信号 —— 词出现在标题里比出现在正文里重要:

// 权重系数:标题词频权重远高于正文词频,体现标题关键词的重要性
item.weight = 10 * title_cnt + 1 * content_cnt;

建索引时对每篇文档的标题和正文分别统计词频,再按上面的公式合成权重写进倒排拉链。

三、检索:分词 → 召回 → 排序 → 摘要#

一次搜索走四步:

  1. 分词:把查询词交给 cppjieba 切分(跟建索引用的是同一套词典和停用词表,保证”怎么建的就怎么查”);
  2. 召回:对每个词取出倒排拉链,把命中的文档汇总到一张 文档 ID → 权重 的表里;
  3. 排序:按权重降序排列;
  4. 生成结果:回正排索引取出标题和原文链接,再按关键词开窗生成摘要 —— 找到命中的词在正文里第一次出现的位置,往前取 50 字节、往后取 100 字节,拼成一段预览。

最后用 jsoncpp 序列化成 JSON 返回,转义交给库处理,不用手写字符串拼接。

四、对外服务与前端#

服务端只有 28 行:用 cpp-httplib 起一个 HTTP 服务,把 wwwroot 设为静态目录,再注册一条 GET /s:

svr.set_base_dir("./wwwroot");
svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rsp) {
std::string word = req.get_param_value("word");
std::string json_string;
search.Search(word, &json_string);
rsp.set_content(json_string, "application/json");
});
svr.listen("0.0.0.0", 8081);

前端是一个纯静态页面:一个搜索框 + 结果列表,用 ajax 请求 /s?word=关键词,把返回的 JSON 渲染成”标题 / 摘要 / 原文链接”。

怎么跑起来#

Terminal window
make # 编译出 parser、debug、http_server 三个可执行文件
./parser # 离线预处理:扫 data/input → 生成 data/raw_html/raw.txt
./http_server # 启动服务(8081 端口,启动时建索引)

依赖:boost_system / boost_filesystem(预处理用)、jsoncpp、cpp-httplib,编译器 g++ -std=c++11。data/input/ 里的语料已经随仓库提供,所以 make 完直接跑就行。

小结#

这个项目让我真正搞明白了”索引到底是怎么把文档变成可检索结构的”:正排负责”知道 ID 能拿到原文”,倒排负责”知道词能拿到 ID 列表”,两者配合,才有”输入一个词、几百毫秒内按相关度列出一屏结果”。同时也第一次完整地走了一遍”语料 → 清洗 → 建索引 → 检索 → 排序 → 展示”的链路,中间每一环的问题都很具体:标签怎么去干净、中文怎么切词、停用词怎么过滤、摘要从哪里开始截。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
HandlerOne
おかえり
公告
本站已整理 138 篇技术笔记,并按 8 条主线排成学习路线(141 站);站内搜索与桌宠对话均已上线。
分类
标签
最新动态
站点统计
文章
138
分类
12
标签
42
总字数
597,044
站点信息
构建平台
腾讯云 · Nginx
博客版本
v6.16.8
文章目录