图片由 Unsplash 提供。
在过去六个月里,我一直在系列A初创公司Voxel51工作,该公司是开源计算机视觉工具包FiftyOne的创建者。作为一名机器学习工程师和开发者倡导者,我的工作是倾听我们的开源社区,并为他们提供所需的一切——新功能、集成、教程、研讨会等。
几周前,我们在FiftyOne中添加了对向量搜索引擎和文本相似性查询的原生支持,这样用户可以通过简单的自然语言查询,在他们的数据集中(通常包含数百万或数千万个样本)找到最相关的图像。
这使我们处于一个有趣的位置:现在使用开源FiftyOne的人可以轻松地用自然语言查询搜索数据集,但使用我们的文档仍然需要传统的关键词搜索。
我们有很多文档,这有利也有弊。作为用户,我自己有时会发现,由于文档数量庞大,找到我真正需要的内容往往需要花费比我希望更多的时间。
我不会让它就这么过去了……所以我利用业余时间做了这个:
从命令行语义搜索您公司的文档。图片由作者提供。
所以,这是我将我们的文档转换为语义可搜索的向量数据库的方法:
- 将所有文档转换为统一格式
- 将文档拆分成块并添加了一些自动化清理
- 计算每个块的嵌入
- 从这些嵌入生成向量索引
- 定义索引查询
- 将所有内容封装在一个用户友好的命令行界面和Python API中
你可以在这个 voxel51/fiftyone-docs-search 仓库中找到这篇帖子的所有代码,并且你可以使用 pip install -e .
命令轻松地以编辑模式在本地安装该包。
更好地说,如果你想使用这种方法为自己的网站实现语义搜索,你可以跟着做!你需要以下材料:
- 安装 openai Python 包并创建一个账户: 你将使用这个账户将你的文档和查询发送到推理端点,该端点将为每段文本返回一个嵌入向量。
- 安装 qdrant-client Python 包并通过 Docker 启动 Qdrant 服务器: 你将使用 Qdrant 创建一个本地托管的文档向量索引,查询将在此索引上运行。Qdrant 服务将运行在 Docker 容器中。
我的公司的文档都托管在 https://docs.voxel51.com 上的 HTML 文档中。一个自然的开始方法是使用 Python 的 requests 库下载这些文档,并使用 Beautiful Soup 库解析文档。
作为一名开发者(同时也是我们许多文档的作者),我认为我可以做得更好。我已经在我的本地计算机上克隆了GitHub仓库,其中包含了所有用于生成HTML文档的原始文件。我们的一些文档是用Sphinx ReStructured Text (RST)编写的,而其他文档,如教程,则是从Jupyter笔记本转换为HTML的。
我本以为(错误地以为),我越能接近 RST 和 Jupyter 文件的原始文本,事情就会越简单。
RST在 RST 文档中,段落由仅包含 =
, -
或 _
字符的行分隔。例如,这里是来自 FiftyOne 用户指南中的一个文档,其中包含了所有三种分隔符:
RST 文档来自开源的 FiftyOne 文档。图片由作者提供。
然后我可以删除所有 RST 关键字,例如 toctree
、code-block
和 button_link
(还有很多其他关键字),以及伴随关键字、新块的开始或块描述符的 :
、::
和 ..
。
链接也很容易处理:
no_links_section = re.sub(r"<[^>]+>_?","", section)
事情开始变得棘手,当我想要从RST文件中提取部分锚点时。我们许多部分的锚点都有明确指定,而其他部分则是在转换为HTML时才被推断出来的。
这里是一个例子:
.. _brain-embeddings-visualization:
嵌入可视化
______________________
FiftyOne Brain 提供了一个强大的
:meth:`compute_visualization() <fiftyone.brain.compute_visualization>` 方法
,你可以使用它来生成数据集中的样本和/或单个对象的低维表示。
这些表示可以在 App 的
:ref:`嵌入面板 <app-embeddings-panel>` 中原生可视化,在该面板中,你可以交互式地选择感兴趣的点,并在
:ref:`样本面板 <app-samples-panel>` 中查看对应的样本/标签,反之亦然。
.. image:: /images/brain/brain-mnist.png
:alt: mnist
:align: center
嵌入可视化的两个主要组成部分是生成嵌入的方法和用于计算嵌入低维表示的降维方法。
嵌入方法
-----------------
:meth:`compute_visualization() <fiftyone.brain.compute_visualization>` 的
`embeddings` 和 `model` 参数支持多种生成数据嵌入的方法:
在我们用户指南文档中的 brain.rst 文件(上面部分已复制)中,可视化嵌入 部分有一个通过 .. _brain-embeddings-visualization:
指定的锚点 #brain-embeddings-visualization
。然而,紧随其后的 嵌入方法 子部分则使用了一个自动生成的锚点。
另一个很快出现的难题是如何处理RST中的表格。列表表格相对简单。例如,这是我们“查看阶段”速查表中的一个列表表格:
.. list-table::
* - :meth:`match() <fiftyone.core.collections.SampleCollection.match>`
* - :meth:`match_frames() <fiftyone.core.collections.SampleCollection.match_frames>`
* - :meth:`match_labels() <fiftyone.core.collections.SampleCollection.match_labels>`
* - :meth:`match_tags() <fiftyone.core.collections.SampleCollection.match_tags>`
网格表格 另一方面,很容易变得混乱。它们为文档编写者提供了很大的灵活性,但这种灵活性也使得解析它们变得困难。以下是从我们的过滤技巧表中提取的一个表格:
+-----------------------------------------+-----------------------------------------------------------------------+
| 操作 | 命令 |
+=========================================+=======================================================================+
| 文件路径以 "/Users" 开头 | .. code-block:: |
| | |
| | ds.match(F("filepath").starts_with("/Users")) |
+-----------------------------------------+-----------------------------------------------------------------------+
| 文件路径以 "10.jpg" 或 "10.png" 结尾 | .. code-block:: |
| | |
| | ds.match(F("filepath").ends_with(("10.jpg", "10.png")) |
+-----------------------------------------+-----------------------------------------------------------------------+
| 标签包含字符串 "be" | .. code-block:: |
| | |
| | ds.filter_labels( |
| | "predictions", |
| | F("label").contains_str("be"), |
| | ) |
+-----------------------------------------+-----------------------------------------------------------------------+
| 文件路径包含 "088" 并且是 JPEG | .. code-block:: |
| | |
| | ds.match(F("filepath").re_match("088*.jpg")) |
+-----------------------------------------+-----------------------------------------------------------------------+
在表格中,行可以占用任意数量的行,列的宽度也可以不同。网格表格单元格内的代码块难以解析,因为它们占用多行的空间,所以它们的内容与其他列的内容交织在一起。这意味着在解析过程中需要有效地重建这些表格中的代码块。
不是世界末日。但也算不上理想。
JupyterJupyter 笔记本的解析相对简单。我能够将 Jupyter 笔记本的内容读取到一个字符串列表中,每个单元格对应一个字符串:
import json
ifile = "my_notebook.ipynb"
with open(ifile, "r") as f:
contents = f.read()
contents = json.loads(contents)["cells"]
contents = [(" ".join(c["source"]), c['cell_type'] for c in contents]
此外,各部分由以 #
开头的 Markdown 单元格划分。
然而,鉴于RST带来的挑战,我决定转向HTML,并将我们所有的文档一视同仁。
HTML我使用 bash generate_docs.bash
从本地安装生成了 HTML 文档,并开始使用 Beautiful Soup 进行解析。然而,我很快意识到,在将 RST 代码块和内联代码的表格转换为 HTML 时,虽然它们渲染正确,但生成的 HTML 本身却非常冗长。以我们的过滤技巧表为例。
当在浏览器中渲染时,我们过滤 cheatsheet 中 Dates and times 部分之前的代码块看起来像这样:
来自开源FiftyOne文档中的_cheat sheet_截图。图片由作者提供。
然而,原始的HTML代码如下所示:
RST 快捷表转换为 HTML。图片由作者提供。
这并不是无法解析的,但也不理想。
Markdown幸运的是,我通过将所有的HTML文件转换为Markdown格式解决了这些问题,使用的是markdownify。Markdown具有一些关键优势,使其成为这项工作的最佳选择。
- 比HTML更简洁:代码格式从混乱的
span
元素简化为前后用单个```标记的内联代码片段,代码块则用前后三对反引号`````标记。这使得将文本和代码分离变得简单。 - 仍然包含链接:与原始的RST不同,这种Markdown包含了节标题链接,因为隐式的链接已经生成。这样,我可以不仅链接到包含结果的页面,还可以链接到该页面的具体部分或子部分。
- 标准化:Markdown为初始的RST和Jupyter文档提供了相对统一的格式,使我们能够在向量搜索应用中对这些文档的内容进行一致的处理。
有些人可能知道开源库 LangChain 用于构建使用大语言模型的应用程序,并且可能在想为什么我没有直接使用 LangChain 的 文档加载器 和 文本分割器。答案是:我需要更多的控制!
处理文档一旦文档被转换为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
并将每段文本或代码分配到相应的部分。
最初,我也尝试将文本块拆分成段落,假设一个部分可能包含许多不同主题的信息,因此该部分的整体嵌入可能与仅关注其中一个主题的文本提示的嵌入不相似。然而,这种方法导致大多数搜索查询的顶级匹配结果往往是单行段落,这些结果实际上并不太具有信息量。
使用OpenAI嵌入文本和代码块查看随附的 GitHub 仓库 ,了解你可以尝试在自己的文档上实现的这些方法的实现!
在将文档转换、处理并拆分成字符串后,我为每个块生成了一个嵌入向量。由于大型语言模型本身具有灵活性和通用性,我决定将文本块和代码块视为同等的文本片段,并使用相同的模型进行嵌入。
我使用了OpenAI的text-embedding-ada-002模型,因为它易于使用,在所有OpenAI的嵌入模型中性能最高(在BEIR基准上),而且也是最便宜的。事实上,它的价格非常便宜(每1000个token只需$0.0004),生成FiftyOne文档的所有嵌入只花费了几美分!正如OpenAI自己所说,“我们推荐使用text-embedding-ada-002来处理几乎所有用例。它更好、更便宜、更简单。”
使用这个嵌入模型,你可以为任何输入提示生成一个1536维的向量表示,输入提示最多可以包含8,191个令牌(大约30,000个字符)。
要开始,请先创建一个 OpenAI 账号,在 https://platform.openai.com/account/api-keys 生成一个 API 密钥,然后通过以下命令将其导出为环境变量:
export OPENAI_API_KEY="<MY_API_KEY>"
您还需要安装 openai Python 库:
pip install openai
我编写了一个围绕OpenAI API的封装,它接受一个文本提示并返回一个嵌入向量:
MODEL = "text-embedding-ada-002"
def embed_text(text):
response = openai.Embedding.create(
input=text,
model=MODEL
)
embeddings = response['data'][0]['embedding']
return embeddings
为了为所有文档生成嵌入,我们只需将此函数应用于所有文档中的各个子部分——文本和代码块。
创建Qdrant向量索引有了嵌入向量后,我创建了一个向量索引来进行搜索。我选择使用Qdrant的原因与我们选择为FiftyOne添加原生Qdrant支持的原因相同:它是开源的、免费的,并且易于使用。
要开始使用Qdrant,您可以拉取预构建的Docker镜像并运行容器:
docker pull qdrant/qdrant
docker run -d -p 6333:6333 qdrant/qdrant
此外,您还需要安装 Qdrant Python 客户端:
pip install qdrant-client
我创建了 Qdrant 集合:
import qdrant_client as qc
import qdr ant_client.http.models as qmodels
client = qc.QdrantClient(url="localhost")
METRIC = qmodels.Distance.DOT
DIMENSION = 1536
COLLECTION_NAME = "fiftyone_docs"
def create_index():
client.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config = qmodels.VectorParams(
size=DIMENSION,
distance=METRIC,
)
)
然后为每个子部分(文本或代码块)创建了一个向量:
import uuid
def 创建子部分向量(
子部分内容,
部分锚点,
页面URL,
文档类型
):
向量 = 嵌入文本(子部分内容)
id = str(uuid.uuid1().int)[:32]
有效载荷 = {
"text": 子部分内容,
"url": 页面URL,
"section_anchor": 部分锚点,
"doc_type": 文档类型,
"block_type": block_type
}
return id, 向量, 有效载荷
对于每个向量,您可以提供额外的上下文作为payload的一部分。在这种情况下,我包含了可以找到结果的URL(和锚点)、文档的type,这样用户可以指定他们是要搜索所有文档,还是只搜索特定类型的文档,以及生成嵌入向量的字符串内容。我还添加了块类型(文本或代码),因此如果用户正在寻找代码片段,他们可以调整搜索以满足这一需求。
然后我一次一页地将这些向量添加到了索引中:
def add_doc_to_index(subsections, page_url, doc_type, block_type):
ids = []
vectors = []
payloads = []
for section_anchor, section_content in subsections.items():
for subsection in section_content:
id, vector, payload = create_subsection_vector(
subsection,
section_anchor,
page_url,
doc_type,
block_type
)
ids.append(id)
vectors.append(vector)
payloads.append(payload)
## 将向量添加到集合
client.upsert(
collection_name=COLLECTION_NAME,
points=qmodels.Batch(
ids = ids,
vectors=vectors,
payloads=payloads
),
)
查询索引
一旦索引创建完成,可以通过使用相同的嵌入模型嵌入查询文本,然后在索引中搜索相似的嵌入向量来执行搜索操作。使用 Qdrant 向量索引时,可以使用 Qdrant 客户端的 search()
命令执行基本查询。
为了使公司的文档可搜索,我希望用户能够根据文档的部分进行筛选,同时也可以根据编码的块类型进行筛选。在向量搜索的术语中,筛选结果的同时确保返回预设数量的结果(由 top_k
参数指定)被称为 预筛选。
为了实现这一点,我编写了一个程序化的过滤器:
def _generate_query_filter(query, doc_types, block_types):
"""生成查询的过滤器。
参数:
query: 包含查询的字符串。
doc_types: 要搜索的文档类型列表。
block_types: 要搜索的块类型列表。
返回:
查询的过滤器。
"""
doc_types = _parse_doc_types(doc_types)
block_types = _parse_block_types(block_types)
_filter = models.Filter(
must=[
models.Filter(
should= [
models.FieldCondition(
key="doc_type",
match=models.MatchValue(value=dt),
)
for dt in doc_types
],
),
models.Filter(
should= [
models.FieldCondition(
key="block_type",
match=models.MatchValue(value=bt),
)
for bt in block_types
]
)
]
)
return _filter
内部的 _parse_doc_types()
和 _parse_block_types()
函数处理参数为字符串、列表或 None 的情况。
然后我编写了一个函数 query_index()
,该函数接受用户的文本查询,预过滤,搜索索引,并从有效载荷中提取相关的信息。该函数返回一个元组列表,元组形式为 (url, contents, score)
,其中分数表示结果与查询文本匹配的好坏程度。
def query_index(query, top_k=10, doc_types=None, block_types=None):
vector = embed_text(query)
_filter = _generate_query_filter(query, doc_types, block_types)
results = CLIENT.search(
collection_name=COLLECTION_NAME,
query_vector=vector,
query_filter=_filter,
limit=top_k,
with_payload=True,
search_params=_search_params,
)
results = [
(
f"{res.payload['url']}#{res.payload['section_anchor']}",
res.payload["text"],
res.score,
)
for res in results
]
return results
编写搜索包装器
最后一步是为用户提供一个干净的界面,以便对这些“向量化”的文档进行语义搜索。
我编写了一个函数 print_results()
,该函数接受查询、来自 query_index()
的结果以及一个 score
参数(是否打印相似度得分),并以易于理解的方式打印结果。我使用了 rich Python 包来在终端中格式化超链接,这样在支持超链接的终端中,点击超链接会自动在默认浏览器中打开页面。我还使用了 webbrowser 自动打开最佳结果的链接(如果需要的话)。
显示带有丰富超链接的搜索结果。图片由作者提供。
对于基于Python的搜索,我创建了一个名为 FiftyOneDocsSearch
的类来封装文档搜索行为,这样一旦创建了 FiftyOneDocsSearch
对象(可能带有默认的搜索参数设置):
from fiftyone.docs_search import FiftyOneDocsSearch
fosearch = FiftyOneDocsSearch(open_url=False, top_k=3, score=True)
您可以通过调用此对象在Python中进行搜索。例如,要查询“如何加载数据集”的文档,您只需运行:
fosearch(“如何加载数据集”)
在Python进程中语义搜索您公司的文档。图片由作者提供。
我也使用了 argparse 来使此文档搜索功能可以通过命令行使用。当安装了该包后,可以通过以下命令在命令行中搜索文档:
fiftyone-docs-search query "<my-query>" <args
只是为了好玩,因为 fiftyone-docs-search query
有点繁琐,我在我的 .zsrch
文件中添加了一个别名:
alias fosearch='fiftyone-docs-search query'
有了这个别名,可以通过命令行搜索文档,命令如下:
fosearch "<我的查询>" 参数
结论
在开始之前,我已经成为了我公司开源Python库FiftyOne的高级用户。我撰写了许多文档,并且每天都在使用(并且继续使用)这个库。但是,将我们的文档转换为可搜索的数据库的过程,让我对我们的文档有了更深的理解。当你为他人构建某样东西时,它最终也帮助了你自己,这总是很棒的!
这里是我学到的内容:
- Sphinx RST 很繁琐:它能生成漂亮的文档,但解析起来有点麻烦。
- 不要过度预处理:OpenAI 的 text-embedding-ada-002 模型即使面对稍微不寻常的格式,也能很好地理解文本的含义。过去那种繁琐的词干提取和费力地移除停用词和杂乱字符的时代已经过去了。
- 语义上有意义的小片段最好:将文档拆分成尽可能小但有意义的片段,并保留上下文。对于较长的文本,搜索查询与索引中部分文本的重合可能会被片段中的不相关文本所掩盖。如果拆分得太小,可能会导致索引中的许多条目包含很少的语义信息。
- 向量搜索非常强大:只需少量的工作,无需任何微调,我就能显著提高我们文档的可搜索性。从初步估计来看,这种改进的文档搜索比旧的关键词搜索方法更有可能返回相关结果,大约是原来的两倍多。此外,这种向量搜索方法的语义特性意味着用户现在可以用任意措辞、任意复杂的查询,并保证获得指定数量的结果。
如果你发现自己(或他人)经常在庞大的文档宝库中挖掘或筛选特定的信息,我鼓励你将这个过程适应到你自己的使用场景中。你可以将其修改为适用于你个人的文档,或者你公司的档案。如果你这么做了,我保证你会以全新的视角看待你的文档!
这里有几种方法可以扩展以适应你自己的文档!
- 混合搜索: 结合向量搜索与传统的关键词搜索
- 走向全球: 使用 Qdrant Cloud 在云端存储和查询集合
- 引入网络数据: 使用 requests 直接从网络下载 HTML
- 自动更新: 使用 Github Actions 在基础文档发生变化时触发嵌入的重新计算
- 嵌入: 将此功能封装在一个 Javascript 元素中,并替换传统的搜索栏使用它
所有用于构建包的代码都是开源的,可以在 voxel51/fiftyone-docs-search 仓库中找到。
共同學習,寫下你的評論
評論加載中...
作者其他優質文章