在过去的六个月里,我一直在一家创业公司参与开发一个大型项目。作为一名机器学习工程师和开发人员布道者,我的工作是倾听开源社区的声音,并带来他们需要的东西:新功能、集成、教程、研讨会,应有尽有。
几周前,我们为公司的项目添加了对向量搜索引擎和文本相似性查询的原生支持,以便用户可以通过简单的自然语言查询,在数据集(通常是海量的,包含几百万或几千万样本)中查找相关的图像。这时,我们陷入了一个尴尬的局面:用户可以轻松地使用自然语言查询搜索数据集,但我们的文档仍然需要传统的关键字搜索。我们有很多文档,这有利有弊。我自己也是一名用户,有时我发现由于文档数量的庞大,准确地找到想要的东西花费的时间远远超过了我的想象。在文本中,我打算介绍一下如何将我们的文档变成一个可通过语义搜索的向量数据库:- 将所有功能打包成用户友好的命令行界面和 Python API。
完整的代码,请点击这里(https://github.com/voxel51/fiftyone-docs-search)。安装也非常简单,只需运行此外,你也可以按照本文介绍的步骤,使用这种方法为自己的网站添加语义搜索。所需工具如下:- 安装 openai Python 包,并创建一个账号:你需要使用这个账号将文档和查询发送到推理端点,并由这个端点返回每段文本的嵌入向量。
- 安装 qdrant-client Python 包,并通过 Docker 启动 Qdrant 服务器:你需要使用 Qdrant 为文档创建本地托管的向量索引,然后运行查询。Qdrant 服务将在 Docker 容器内运行。
我们公司的文档都采用了 HTML 的形式,托管在 https://docs.voxel51.com 上。因此,我可以很方便地使用 Python 的 requests 库下载这些文档,并使用 Beautiful Soup 解析文档。然而,作为一名开发人员(以及许多文档的作者),我认为我可以做得更好。我的本地计算机上已有 GitHub 存储库的克隆,其中包含用于生成 HTML 文档的所有原始文件。我们的一些文档是用 Sphinx ReStructured Text(RST)编写的,还有一部分(比如教程)则是 Jupyter notebook 转换为 HTML。我(错误地)以为,拿到 RST 和 Jupyter 文件的原始文本,接下来的工作就会更简单。在 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 notebooks 解析起来相对简单。我能够读入 Jupyter notebook 的内容,并保存到字符串列表中,每个单元格对应一个字符串:此外,这些小节的分割是以 # 开头的 Markdown 单元格。尽管如此,考虑到 RST 带来的挑战,我决定使用 HTML,并统一处理所有的文档。我使用 bash generate_docs.bash 从本地构建了 HTML 文档,然后使用 Beautiful Soup 解析它们。然而,很快我就意识到,当 RST 代码块和数据内包含代码的表格被转换为 HTML 时,虽然可以正确呈现,但 HTML 本身非常笨拙。以我们的 filtering 备忘单为例。在浏览器中呈现时,filtering 备忘单中的 Dates and times 前面的代码块如下所示:幸运的是,我可以使用 Markdownify 将所有 HTML 文件转换为 Markdown,这样就可以克服这些难题了。Markdown 有几个关键优势,非常适合这项工作。- 比 HTML 更简洁:span 元素之类面条式的字符串可以简化为内联代码片段,前后会加上单引号(` )标记,而代码块前后会有三个单引号(```)标记。因此,拆分成文本和代码非常容易。
- 包含锚点:与原始 RST 不同,Markdown 包含部分标题锚点。这样,我不仅可以链接到包含结果的页面,还可以链接到该页面的特定部分或子部分。
- 标准化:Markdown 为最初的 RST 和 Jupyter 文档提供了基本统一的格式,可以方便我们在向量索应用程序中统一处理内容。
有些人可能知道大规模语言模型应用程序的开源库 LangChain,而且还想问我为什么不直接使用 LangChain 的文档加载器和文本拆分器。答案是:我需要更多的控制!在所有文件都转换成 Markdown 后,接下来我需要清理并切割内容。- 表格的行列脚手架,例如 |select()| select_by()| 中的 |;
此外,我还删除了文档中的转义字符(用于转义具有特殊含义的字符):_ 和 *。前者在许多方法名称中都有使用,而后者主要用在乘法、正则表达式模式以及许多其他地方: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
最初,我还尝试将文本块拆分为段落,由于一个小节可能包含有关许多不同主题的信息,所以整个小节的嵌入可能涉及的主题文本提示不仅限于一个。然而,这种方法会导致搜索结果的开头几个都是只有一行的段落,这样的搜索结果无法提供非常有用的信息。通过文档转换、处理,以及拆分为字符串,我为每个块生成了一个嵌入向量。由于大型语言模型非常灵活,而且本质上是通用的,所以我决定将文本块和代码块统一视为文本块,并嵌入到同一个模型中。我使用了 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 库:我为 OpenAI 的 API 编写了一个包装器,接收文本提示并返回一个嵌入向量:为了生成所有文档的嵌入,我需要使用这个函数处理所有文档中的每个小节,包括文本以及代码块。有了嵌入,下一步我需要创建一个向量索引。我选择使用 Qdrant 的原因主要是:开源、免费且易于使用。为了使用 Qdrant,你需要拉取预构建的 Docker 镜像并运行容器:docker pull qdrant/qdrant
docker run -d -p 6333:6333 qdrant/qdrant
另外,还需要安装 Qdrant Python 客户端:pip install qdrant-client
对于每个向量,你可以提供额外的上下文作为有效载荷的一部分。此处,我添加了结果的 URL、文档类型,这样用户就可以指定是搜索所有文档,还是只搜索某些类型的文档,以及生成嵌入向量的字符串的内容。此外,我还添加了块类型(文本或代码),如果用户正在寻找代码片段,则可以设置搜索。索引创建好后,下一步是搜索添加了索引的文档。我使用同一个嵌入模型嵌入查询文本,然后搜索索引中相似的嵌入向量。有了 Qdrant 向量索引,我们可以使用用 Qdrant 客户端的 search() 命令来执行基本查询。我希望用户在搜索公司的文档时,按文档的小节以及被编码的文本块的类型进行过滤。在向量搜索的术语中,在过滤结果的同时仍确保返回预定数量的结果(由 top_k 参数指定)称为“预过滤”。内部的 _parse_doc_types() 和 _parse_block_types() 函数可以处理参数是字符串、列表值或者是 None 的情况。接着,我编写了一个函数 query_index(),接受用户的文本查询、预过滤、搜索索引,并从有效载荷中提取相关信息。该函数将返回形式为 (url, contents, score) 的元组列表,其中 score 表示结果与查询文本的匹配程度。最后一步是为用户提供一个干净的界面,方便对这些“向量化”文档进行语义搜索。我编写了一个函数 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
如今的我已经摇身一变,成为了公司开源 Python 库 FiftyOne 的高级用户。我写了很多文档,而且每天都使用这个库。将文档变成可搜索数据库的过程中,我对文档的理解也进一步加深了。赠人玫瑰,手有余香。- Sphinx RST 很麻烦:虽然我们可以用它制作漂亮的文档,但解析起来有点痛苦。
- 不要为预处理浪费太多精力:OpenAI 的 text-embeddings-ada-002 模型非常擅长理解文本字符串背后的含义,尽管格式有点不常见。费尽心思删除停用词和干扰字符的日子已经一去不复返了。
- 包含语义的小段文本最佳:尽可能将文档切分成有意义的小段,并保留上下文。对于较长的文本,搜索查询与索引中的部分文本之间的重叠更有可能被同一段中不太相关的文本所掩盖。如果将文档分解得太小,索引中的许多条目包含的语义信息就会过少。
- 向量搜索十分强大:只需付出极小的努力,无需任何微调,就能大幅增强文档的可搜索性。初步估计,改进后的文档搜索返回相关结果的概率是关键字搜索的两倍多。此外,鉴于这种向量搜索方法的语义特性,用户可以使用任意短语、任意复杂的查询进行搜索,而且一定能获得指定数量的结果。
原文链接:https://towardsdatascience.com/how-i-turned-my-companys-docs-into-a-searchable-database-with-openai-4f2d34bd8736
未经允许,禁止转载!