几周前,我们为公司的项目添加了对向量搜索引擎和文本相似性查询的原生支持,以便用户可以通过简单的自然语言查询,在数据集(通常是海量的,包含几百万或几千万样本)中查找相关的图像。
这时,我们陷入了一个尴尬的局面:用户可以轻松地使用自然语言查询搜索数据集,但我们的文档仍然需要传统的关键字搜索。
我们有很多文档,这有利有弊。我自己也是一名用户,有时我发现由于文档数量的庞大,准确地找到想要的东西花费的时间远远超过了我的想象。
于是,我利用业余时间做了下面这样一个工具:
在文本中,我打算介绍一下如何将我们的文档变成一个可通过语义搜索的向量数据库:
- 将所有文档转换为统一格式。
- 将文档分成块,并添加一些自动清理。
- 每个块的计算嵌入。
- 根据这些嵌入生成向量索引。
- 定义索引查询。
- 将所有功能打包成用户友好的命令行界面和 Python API。
安装也非常简单,只需运行
pip install -e……
此外,你也可以按照本文介绍的步骤,使用这种方法为自己的网站添加语义搜索。所需工具如下:
- 安装 openai Python 包,并创建一个账号:你需要使用这个账号将文档和查询发送到推理端点,并由这个端点返回每段文本的嵌入向量。
- 安装 qdrant-client Python 包,并通过 Docker 启动 Qdrant 服务器:你需要使用 Qdrant 为文档创建本地托管的向量索引,然后运行查询。Qdrant 服务将在 Docker 容器内运行。
1.将文档转换为统一格式
我们公司的文档都采用了 HTML 的形式,托管在 https://docs.voxel51.com 上。因此,我可以很方便地使用 Python 的 requests 库下载这些文档,并使用 Beautiful Soup 解析文档。
然而,作为一名开发人员(以及许多文档的作者),我认为我可以做得更好。我的本地计算机上已有 GitHub 存储库的克隆,其中包含用于生成 HTML 文档的所有原始文件。我们的一些文档是用 Sphinx ReStructured Text(RST)编写的,还有一部分(比如教程)则是 Jupyter notebook 转换为 HTML。
我(错误地)以为,拿到 RST 和 Jupyter 文件的原始文本,接下来的工作就会更简单。
RST
在 RST 文档中,各个小节之间的分割线仅包含 =、- 或 _ 字符串。例如,下面是用户指南文档,三种分割线都出现了:
首先,我需要删除所有 RST 关键字,例如 toctree、code-block 和 button_link(以及其他等等),以及附带的关键字 :、:: 和 …、块的开始或块的描述符。
链接也很容易处理:
no_links_section = re.sub(r"<[^>]+>_?","", section)
当我想从 RST 文件中提取小节的锚点时,情况开始变得棘手。文档中的许多小节都明确指定了锚点,而有些则需要在转换为 HTML 期间进行推断。
下面是一个例子:
我们的用户指南文档中有一个 brain.rst 文件(上图包含一部分),Visualizing embeddings 小节有一个锚点 #brain-embeddings-visualization,其由 … _brain-embeddings-visualization: 指定。然而,紧随其后的 Embedding methods 小节锚点则是自动生成的。
很快,我又遇到了另一个难题:如何处理 RST 中的表。清单表非常简单。例如,下面是我们的 View Stages 备忘单中的列表:
另一方面,表格则比较麻烦。虽然表格为文档编写者提供了极大的灵活性,但解析时就会非常痛苦。我从 Filtering 的备忘单中提出了下表:
表格内部,数据可以跨多行,列的宽度也可以变化。表的单元格中的代码块也很难解析,因为它们占用了多行空间,因此它们的内容与其他列的内容穿插在一起。这意味着,在解析的过程中,我需要有效地重构这些表中的代码块。
虽然不是无法解决,但也不理想。
Jupyter
事实证明,Jupyter notebooks 解析起来相对简单。我能够读入 Jupyter notebook 的内容,并保存到字符串列表中,每个单元格对应一个字符串:
此外,这些小节的分割是以 # 开头的 Markdown 单元格。
尽管如此,考虑到 RST 带来的挑战,我决定使用 HTML,并统一处理所有的文档。
HTML
我使用 bash generate_docs.bash 从本地构建了 HTML 文档,然后使用 Beautiful Soup 解析它们。然而,很快我就意识到,当 RST 代码块和数据内包含代码的表格被转换为 HTML 时,虽然可以正确呈现,但 HTML 本身非常笨拙。以我们的 filtering 备忘单为例。
在浏览器中呈现时,filtering 备忘单中的 Dates and times 前面的代码块如下所示:
然而,对应的 HTML 如下:
虽然并非无法解析,但也远非理想。
Markdown
幸运的是,我可以使用 Markdownify 将所有 HTML 文件转换为 Markdown,这样就可以克服这些难题了。Markdown 有几个关键优势,非常适合这项工作。
- 比 HTML 更简洁:span 元素之类面条式的字符串可以简化为内联代码片段,前后会加上单引号(` )标记,而代码块前后会有三个单引号(```)标记。因此,拆分成文本和代码非常容易。
- 包含锚点:与原始 RST 不同,Markdown 包含部分标题锚点。这样,我不仅可以链接到包含结果的页面,还可以链接到该页面的特定部分或子部分。
- 标准化:Markdown 为最初的 RST 和 Jupyter 文档提供了基本统一的格式,可以方便我们在向量索应用程序中统一处理内容。
有关 LangChain
有些人可能知道大规模语言模型应用程序的开源库 LangChain,而且还想问我为什么不直接使用 LangChain 的文档加载器和文本拆分器。答案是:我需要更多的控制!
2.处理文件
在所有文件都转换成 Markdown 后,接下来我需要清理并切割内容。
清理
清理工作主要是除不必要的元素,包括:
- 页眉和页脚;
- 表格的行列脚手架,例如 |select()| select_by()| 中的 |;
- 多余的换行;
- 链接;
- 图片;
- Unicode字符;
- 粗体:text → text。
此外,我还删除了文档中的转义字符(用于转义具有特殊含义的字符):_ 和 *。前者在许多方法名称中都有使用,而后者主要用在乘法、正则表达式模式以及许多其他地方:
document = document.replace("\_", "_").replace("\*", "*")
将文档拆分为语义块
清理完文档的内容后,我开始将文档分成小块。
首先,我将每个文档分成几个部分。这项工作看似简单,只需查找任何以 # 字符开头的行。我的应用程序没有区分 h1、h2、h3 以及其他(#、##、###),因此检查第一个字符就足够了。然而,当我们意识到 Python 代码的注释也使用了 # 时,才发现有麻烦。
为了绕过这个问题,我将文档切分成文本块和代码块:
text_and_code = page_md.split('```')
text = text_and_code[::2]
code = text_and_code[1::2]
然后,我用 # 标识每一小节的开始,在文本块中新起一行,并从这一行中提取小节的标题和锚点:
def extract_title_and_anchor(header):
header = " ".join(header.split(" ")[1:])
title = header.split("[")[0]
anchor = header.split("(")[1].split(" ")[0]
return title, anchor
然后,将每个文本块或代码分配给相应的小节。
最初,我还尝试将文本块拆分为段落,由于一个小节可能包含有关许多不同主题的信息,所以整个小节的嵌入可能涉及的主题文本提示不仅限于一个。然而,这种方法会导致搜索结果的开头几个都是只有一行的段落,这样的搜索结果无法提供非常有用的信息。
3.使用 OpenAI 嵌入文本和代码块
通过文档转换、处理,以及拆分为字符串,我为每个块生成了一个嵌入向量。由于大型语言模型非常灵活,而且本质上是通用的,所以我决定将文本块和代码块统一视为文本块,并嵌入到同一个模型中。
我使用了 OpenAI 的 text-embedding-ada-002 模型,因为它易于使用。在 OpenAI 的所有嵌入模型中,该模型的性能最高(在 BEIR 基准测试中),而且也是最便宜的。由于价格非常低廉,所以为所有文档生成嵌入只花费了几美分!正如 OpenAI 所说,“我们建议所有用例都使用 text-embedding-ada-002。该模型更好、更便宜、更易于使用。”
你可以使用这个嵌入模型,生成一个 1536 维的向量来表示任何输入提示,最多可达 8,191 个token(约 3 万个字符)。
首先,你需要创建一个 OpenAI 账号,并生成一个 API 密钥(https://platform.openai.com/account/api-keys)。然后,将此 API 密钥导出为环境变量:
export OPENAI_API_KEY="<MY_API_KEY>"
另外,你还需要安装 openai Python 库:
pip install openai
我为 OpenAI 的 API 编写了一个包装器,接收文本提示并返回一个嵌入向量:
为了生成所有文档的嵌入,我需要使用这个函数处理所有文档中的每个小节,包括文本以及代码块。
4.创建 Qdrant 向量索引
有了嵌入,下一步我需要创建一个向量索引。我选择使用 Qdrant 的原因主要是:开源、免费且易于使用。
为了使用 Qdrant,你需要拉取预构建的 Docker 镜像并运行容器:
docker pull qdrant/qdrant
docker run -d -p 6333:6333 qdrant/qdrant
另外,还需要安装 Qdrant Python 客户端:
pip install qdrant-client
我创建了 Qdrant 集合:
接着,又为每个子小节(文本或代码块)创建了向量:
对于每个向量,你可以提供额外的上下文作为有效载荷的一部分。此处,我添加了结果的 URL、文档类型,这样用户就可以指定是搜索所有文档,还是只搜索某些类型的文档,以及生成嵌入向量的字符串的内容。此外,我还添加了块类型(文本或代码),如果用户正在寻找代码片段,则可以设置搜索。
接下来,我将这些向量添加到索引中,一次一页:
5.查询索引
索引创建好后,下一步是搜索添加了索引的文档。我使用同一个嵌入模型嵌入查询文本,然后搜索索引中相似的嵌入向量。有了 Qdrant 向量索引,我们可以使用用 Qdrant 客户端的 search() 命令来执行基本查询。
我希望用户在搜索公司的文档时,按文档的小节以及被编码的文本块的类型进行过滤。在向量搜索的术语中,在过滤结果的同时仍确保返回预定数量的结果(由 top_k 参数指定)称为“预过滤”。
为此,我编写了一个过滤器:
内部的 _parse_doc_types() 和 _parse_block_types() 函数可以处理参数是字符串、列表值或者是 None 的情况。
接着,我编写了一个函数 query_index(),接受用户的文本查询、预过滤、搜索索引,并从有效载荷中提取相关信息。该函数将返回形式为 (url, contents, score) 的元组列表,其中 score 表示结果与查询文本的匹配程度。
6.编写搜索包装器
最后一步是为用户提供一个干净的界面,方便对这些“向量化”文档进行语义搜索。
我编写了一个函数 print_results(),接受查询、query_index() 的结果以及 score 参数(是否输出相似性分数),并输出方便解释的结果。我使用了 Python 包 rich,格式化终端中的超链接,以便在支持超链接的终端中运行时,单击超链接在默认浏览器中打开页面。我还使用了 webbrowser,可以根据需要自动打开第一条搜索结果的链接。
对于基于 Python 的搜索,我创建了一个类 FiftyOneDocsSearch 来封装文档搜索行为,如此一来,如果 FiftyOneDocsSearch 对象被实例化(使用搜索参数的默认设置时):
from fiftyone.docs_search import FiftyOneDocsSearch
fosearch = FiftyOneDocsSearch(open_url=False, top_k=3, score=True)
你可以通过调用此对象在 Python 中进行搜索。例如,如果你想在文档中查询“How to load a dataset”,只需运行:
fosearch(“How to load a dataset”)
此外,我还使用 argparse 创建了此文档搜索功能的命令行版。安装包后,就可以使用命令行搜索文档了:
fiftyone-docs-search query "<my-query>" <args
由于输入 fiftyone-docs-search 比较麻烦,所以我在 .zsrch 文件中添加了一个别名:
alias fosearch='fiftyone-docs-search query'
有了这个别名,从命令行搜索文档就更加容易了:
fosearch "<my-query>" args
7.总结
如今的我已经摇身一变,成为了公司开源 Python 库 FiftyOne 的高级用户。我写了很多文档,而且每天都使用这个库。将文档变成可搜索数据库的过程中,我对文档的理解也进一步加深了。赠人玫瑰,手有余香。
以下是我学到的经验以及教训:
- Sphinx RST 很麻烦:虽然我们可以用它制作漂亮的文档,但解析起来有点痛苦。
- 不要为预处理浪费太多精力:OpenAI 的 text-embeddings-ada-002 模型非常擅长理解文本字符串背后的含义,尽管格式有点不常见。费尽心思删除停用词和干扰字符的日子已经一去不复返了。
- 包含语义的小段文本最佳:尽可能将文档切分成有意义的小段,并保留上下文。对于较长的文本,搜索查询与索引中的部分文本之间的重叠更有可能被同一段中不太相关的文本所掩盖。如果将文档分解得太小,索引中的许多条目包含的语义信息就会过少。
- 向量搜索十分强大:只需付出极小的努力,无需任何微调,就能大幅增强文档的可搜索性。初步估计,改进后的文档搜索返回相关结果的概率是关键字搜索的两倍多。此外,鉴于这种向量搜索方法的语义特性,用户可以使用任意短语、任意复杂的查询进行搜索,而且一定能获得指定数量的结果。
如何学习大模型 AI ?
由于新岗位的生产效率,要优于被取代岗位的生产效率,所以实际上整个社会的生产效率是提升的。
但是具体到个人,只能说是:
“最先掌握AI的人,将会比较晚掌握AI的人有竞争优势”。
这句话,放在计算机、互联网、移动互联网的开局时期,都是一样的道理。
我在一线互联网企业工作十余年里,指导过不少同行后辈。帮助很多人得到了学习和成长。
我意识到有很多经验和知识值得分享给大家,也可以通过我们的能力和经验解答大家在人工智能学习中的很多困惑,所以在工作繁忙的情况下还是坚持各种整理和分享。但苦于知识传播途径有限,很多互联网行业朋友无法获得正确的资料得到学习提升,故此将并将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。
第一阶段(10天):初阶应用
该阶段让大家对大模型 AI有一个最前沿的认识,对大模型 AI 的理解超过 95% 的人,可以在相关讨论时发表高级、不跟风、又接地气的见解,别人只会和 AI 聊天,而你能调教 AI,并能用代码将大模型和业务衔接。
- 大模型 AI 能干什么?
- 大模型是怎样获得「智能」的?
- 用好 AI 的核心心法
- 大模型应用业务架构
- 大模型应用技术架构
- 代码示例:向 GPT-3.5 灌入新知识
- 提示工程的意义和核心思想
- Prompt 典型构成
- 指令调优方法论
- 思维链和思维树
- Prompt 攻击和防范
- …
第二阶段(30天):高阶应用
该阶段我们正式进入大模型 AI 进阶实战学习,学会构造私有知识库,扩展 AI 的能力。快速开发一个完整的基于 agent 对话机器人。掌握功能最强的大模型开发框架,抓住最新的技术进展,适合 Python 和 JavaScript 程序员。
- 为什么要做 RAG
- 搭建一个简单的 ChatPDF
- 检索的基础概念
- 什么是向量表示(Embeddings)
- 向量数据库与向量检索
- 基于向量检索的 RAG
- 搭建 RAG 系统的扩展知识
- 混合检索与 RAG-Fusion 简介
- 向量模型本地部署
- …
第三阶段(30天):模型训练
恭喜你,如果学到这里,你基本可以找到一份大模型 AI相关的工作,自己也能训练 GPT 了!通过微调,训练自己的垂直大模型,能独立训练开源多模态大模型,掌握更多技术方案。
到此为止,大概2个月的时间。你已经成为了一名“AI小子”。那么你还想往下探索吗?
- 为什么要做 RAG
- 什么是模型
- 什么是模型训练
- 求解器 & 损失函数简介
- 小实验2:手写一个简单的神经网络并训练它
- 什么是训练/预训练/微调/轻量化微调
- Transformer结构简介
- 轻量化微调
- 实验数据集的构建
- …
第四阶段(20天):商业闭环
对全球大模型从性能、吞吐量、成本等方面有一定的认知,可以在云端和本地等多种环境下部署大模型,找到适合自己的项目/创业方向,做一名被 AI 武装的产品经理。
- 硬件选型
- 带你了解全球大模型
- 使用国产大模型服务
- 搭建 OpenAI 代理
- 热身:基于阿里云 PAI 部署 Stable Diffusion
- 在本地计算机运行大模型
- 大模型的私有化部署
- 基于 vLLM 部署大模型
- 案例:如何优雅地在阿里云私有部署开源大模型
- 部署一套开源 LLM 项目
- 内容安全
- 互联网信息服务算法备案
- …
学习是一个过程,只要学习就会有挑战。天道酬勤,你越努力,就会成为越优秀的自己。
如果你能在15天内完成所有的任务,那你堪称天才。然而,如果你能完成 60-70% 的内容,你就已经开始具备成为一名大模型 AI 的正确特征了。