推荐:用 NSDT编辑器 快速搭建可编程3D场景
在过去的六个月里,我一直在 A 系列初创公司 Voxel51 工作,该公司是开源计算机视觉工具包 FiftyOne 的创建者。 作为一名机器学习工程师和开发人员布道者,我的工作是倾听我们的开源社区的声音,并为他们提供他们所需要的东西——新功能、集成、教程、研讨会,等等。
几周前,我们在 FiftyOne 中添加了对矢量搜索引擎和文本相似性查询的原生支持,以便用户可以通过简单的自然语言在其(通常是庞大的——包含数百万或数千万个样本)数据集中找到最相关的图像查询。
这让我们陷入了一个奇怪的境地:现在使用开源 FiftyOne 的人们可以轻松地通过自然语言查询来搜索数据集,但使用我们的文档仍然需要传统的关键字搜索。
我们有很多文档,它们各有利弊。 作为一名用户,我有时发现,鉴于文档数量巨大,准确找到我要查找的内容需要比我想要的更多时间。
我不会让它飞……所以我在业余时间构建了这个:
从命令行按语义搜索公司的文档
因此,以下是我如何将我们的文档转变为语义可搜索的矢量数据库的方法:
- 将所有文档转换为统一格式
- 将文档分割成块并添加一些自动清理
- 计算每个块的嵌入
- 从这些嵌入生成向量索引
- 定义索引查询
- 将所有内容封装在用户友好的命令行界面和 Python API 中
你可以在 voxel51/fiftyone-docs-search 存储库中找到本文的所有代码,并且可以使用 pip install -e 轻松在编辑模式下在本地安装软件包。
更好的是,如果你想使用这种方法为自己的网站实现语义搜索,你可以跟着做! 以下是你需要的操作:
- 安装 openai Python 包并创建一个帐户:你将使用此帐户将文档和查询发送到推理端点,该端点将为每段文本返回一个嵌入向量。
- 安装 qdrant-client Python 包并通过 Docker 启动 Qdrant 服务器:你将使用 Qdrant 为文档创建本地托管的向量索引,并将针对该索引运行查询。 Qdrant 服务将在 Docker 容器内运行。
1、将文档转换为统一格式
我公司的文档均以 HTML 文档形式托管在网站上。 一个自然的起点是使用 Python 的 requests 库下载这些文档并使用 Beautiful Soup 解析文档。
然而,作为一名开发人员(以及我们许多文档的作者),我认为我可以做得更好。 我的本地计算机上已经有 GitHub 存储库的工作克隆,其中包含用于生成 HTML 文档的所有原始文件。 我们的一些文档是用 Sphinx ReStructured Text (RST) 编写的,而其他文档(例如教程)则从 Jupyter Notebook 转换为 HTML。
我(错误地)认为我越接近 RST 和 Jupyter 文件的原始文本,事情就会越简单。
1.1 RST文档
在 RST 文档中,节由仅由 =、- 或 _ 字符串组成的行来划分。 例如,以下是《FiftyOne 用户指南》中的一份文档,其中包含所有三个描述符:
来自开源 FiftyOne Docs 的 RST 文档
然后我可以删除所有 RST 关键字,例如 toctree、 code-block 和 button_link(还有更多),以及伴随关键字(新块的开头)的 :、 :: 和 … ,或块描述符。
链接也很容易处理:
no_links_section = re.sub(r"<[^>]+>_?","", section)
当我想从 RST 文件中提取部分锚点时,事情开始变得危险。 我们的许多部分都明确指定了锚点,而其他部分则在转换为 HTML 期间进行推断。
这是一个例子:
.. _brain-embeddings-visualization:
Visualizing embeddings
______________________
The FiftyOne Brain provides a powerful
:meth:`compute_visualization() <fiftyone.brain.compute_visualization>` method
that you can use to generate low-dimensional representations of the samples
and/or individual objects in your datasets.
These representations can be visualized natively in the App's
:ref:`Embeddings panel <app-embeddings-panel>`, where you can interactively
select points of interest and view the corresponding samples/labels of interest
in the :ref:`Samples panel <app-samples-panel>`, and vice versa.
.. image:: /images/brain/brain-mnist.png
:alt: mnist
:align: center
There are two primary components to an embedding visualization: the method used
to generate the embeddings, and the dimensionality reduction method used to
compute a low-dimensional representation of the embeddings.
Embedding methods
-----------------
The `embeddings` and `model` parameters of
:meth:`compute_visualization() <fiftyone.brain.compute_visualization>`
support a variety of ways to generate embeddings for your data:
在我们的用户指南文档的 Brain.rst 文件中(上面复制了其中的一部分),可视化嵌入部分有一个由 … _brain-embeddings-visualization: 指定的锚点 #brain-embeddings-visualization, 然而,紧随其后的嵌入方法小节给出了一个自动生成的锚点。
另一个很快出现的困难是如何处理 RST 中的表。 列表相当简单。 例如,下面是我们的 View Stages 备忘单中的列表:
.. 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>`
另一方面,网格表很快就会变得混乱。 它们为文档编写者提供了极大的灵活性,但同样的灵活性也使解析它们变得很痛苦。 从我们的过滤备忘单中获取此表:
+-----------------------------------------+-----------------------------------------------------------------------+
| Operation | Command |
+=========================================+=======================================================================+
| Filepath starts with "/Users" | .. code-block:: |
| | |
| | ds.match(F("filepath").starts_with("/Users")) |
+-----------------------------------------+-----------------------------------------------------------------------+
| Filepath ends with "10.jpg" or "10.png" | .. code-block:: |
| | |
| | ds.match(F("filepath").ends_with(("10.jpg", "10.png")) |
+-----------------------------------------+-----------------------------------------------------------------------+
| Label contains string "be" | .. code-block:: |
| | |
| | ds.filter_labels( |
| | "predictions", |
| | F("label").contains_str("be"), |
| | ) |
+-----------------------------------------+-----------------------------------------------------------------------+
| Filepath contains "088" and is JPEG | .. code-block:: |
| | |
| | ds.match(F("filepath").re_match("088*.jpg")) |
+-----------------------------------------+-----------------------------------------------------------------------+
在表中,行可以占据任意行数,列的宽度可以不同。 网格表单元格内的代码块也很难解析,因为它们占用多行空间,因此它们的内容散布在其他列的内容中。 这意味着在解析过程中需要有效地重建这些表中的代码块。
不是世界末日。 但也并不理想。
1.2 Jupyter
Jupyter Notebook 的解析相对简单。 我能够将 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 并平等对待我们所有的文档。
1.3 HTML文档
我使用 bashgenerate_docs.bash 从本地安装构建了 HTML 文档,并开始使用 BeautifulSoup 解析它们。 然而,我很快意识到,当 RST 代码块和带有内联代码的表格转换为 HTML 时,尽管它们正确呈现,但 HTML 本身却非常笨拙。 以我们的过滤备忘单为例。
当在浏览器中呈现时,过滤备忘单的日期和时间部分之前的代码块如下所示:
开源 FiftyOne 文档中备忘单的屏幕截图
然而,原始 HTML 看起来像这样:
RST 备忘单转换为 HTML
这并非不可能解析,但也远非理想。
1.4 Markdown
幸运的是,我能够通过使用 markdownify 将所有 HTML 文件转换为 Markdown 来克服这些问题。 Markdown 具有一些关键优势,使其最适合这项工作。
- 比 HTML 更干净:代码格式从 Spaghetti 字符串简化为前后用单个 ` 标记的内联代码片段,并且代码块在前后用三引号 `` 标记。 这也使得拆分为文本和代码变得容易。
- 仍然包含锚点:与原始 RST 不同,此 Markdown 包含节标题锚点,因为隐式锚点已经生成。 这样,我不仅可以链接到包含结果的页面,还可以链接到该页面的特定部分或子部分。
- 标准化:Markdown 为初始 RST 和 Jupyter 文档提供了基本统一的格式,使我们能够在矢量搜索应用程序中对其内容进行一致的处理。
你们中的一些人可能知道用于使用 LLM 构建应用程序的开源库 LangChain,并且可能想知道为什么我不只使用 LangChain 的文档加载器和文本拆分器。 答案是:我需要更多的控制!
2、文档处理
将文档转换为 Markdown 后,我开始清理内容并将它们分成更小的部分。
2.1 清理
清理主要是去除不必要的元素,包括:
- 页眉和页脚
- 表格行和列脚手架 - 例如 |select()| 中的 | select_by()|
- 额外的换行符
- 链接
- 图片
- Unicode字符
- 粗体 — 即 文本 → 文本
我还删除了文档中具有特殊含义的字符转义的转义字符: _ 和 *。 前者用在许多方法名称中,后者像往常一样用在乘法、正则表达式模式和许多其他地方:
document = document.replace("\_", "_").replace("\*", "*")
2.2 将文档拆分为语义块
清理完文档的内容后,我开始将文档分成小块。
首先,我将每个文档分成几个部分。 乍一看,这似乎可以通过查找任何以 # 字符开头的行来完成。 在我的应用程序中,我没有区分 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
并将每个文本或代码块分配给适当的部分。
最初,我还尝试将文本块拆分为段落,假设由于一个部分可能包含有关许多不同主题的信息,因此整个部分的嵌入可能与仅涉及其中一个主题的文本提示的嵌入不同。 然而,这种方法导致大多数搜索查询的顶级匹配不成比例地是单行段落,结果证明这作为搜索结果并没有提供太多信息。
你可以查看随附的 GitHub 存储库,了解这些方法的实现,也可以在自己的文档中尝试这些方法!
3、使用 OpenAI 嵌入文本和代码块
通过转换、处理文档并拆分为字符串,我为每个块生成了一个嵌入向量。 由于大型语言模型本质上是灵活且通常具有能力的,因此我决定将文本块和代码块与文本片段同等对待,并将它们嵌入到相同的模型中。
我使用 OpenAI 的 text-embedding-ada-002 模型,因为它易于使用,在所有 OpenAI 嵌入模型中实现了最高的性能(在 BEIR 基准上),而且也是最便宜的。 事实上,它非常便宜(0.0004 美元/1K tokens),为 FiftyOne 文档生成所有嵌入仅花费几美分! 正如 OpenAI 自己所说,“我们建议在几乎所有用例中使用 text-embedding-ada-002。 它更好、更便宜、使用更简单。”
通过此嵌入模型,你可以生成表示任何输入提示的 1536 维向量,最多 8,191 个标记(大约 30,000 个字符)。
首先,你需要创建一个 OpenAI 帐户,在 这里 生成 API 密钥,然后将此 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
要为所有文档生成嵌入,我们只需将此函数应用于所有文档中的每个小节(文本和代码块)。
4、创建 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 qdrant_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 create_subsection_vector(
subsection_content,
section_anchor,
page_url,
doc_type
):
vector = embed_text(subsection_content)
id = str(uuid.uuid1().int)[:32]
payload = {
"text": subsection_content,
"url": page_url,
"section_anchor": section_anchor,
"doc_type": doc_type,
"block_type": block_type
}
return id, vector, payload
对于每个向量,你可以提供额外的上下文作为负载的一部分。 在本例中,我包含了可以找到结果的 URL(和锚点)、文档类型,以便用户可以指定是否要搜索所有文档,或者仅搜索某些类型的文档以及内容 生成嵌入向量的字符串的。 我还添加了块类型(文本或代码),因此如果用户正在寻找代码片段,他们可以根据该目的定制搜索。
然后我将这些向量添加到索引中,一次一页:
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)
## Add vectors to collection
client.upsert(
collection_name=COLLECTION_NAME,
points=qmodels.Batch(
ids = ids,
vectors=vectors,
payloads=payloads
),
)
5、查询索引
创建索引后,可以通过使用相同的嵌入模型嵌入查询文本,然后在索引中搜索相似的嵌入向量来完成对索引文档的搜索。 借助 Qdrant 矢量索引,可以使用 Qdrant 客户端的 search() 命令执行基本查询。
为了使我公司的文档可搜索,我希望允许用户按文档的部分以及编码的块类型进行过滤。 用向量搜索的术语来说,过滤结果同时仍确保返回预定数量的结果(由 top_k 参数指定)被称为预过滤。
为了实现这一目标,我编写了一个过滤器:
def _generate_query_filter(query, doc_types, block_types):
"""Generates a filter for the query.
Args:
query: A string containing the query.
doc_types: A list of document types to search.
block_types: A list of block types to search.
Returns:
A filter for the query.
"""
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、内容、分数)形式的元组列表,其中分数表示结果与查询文本的匹配程度。
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
6、封装搜索功能
最后一步是为用户提供一个干净的界面,以便对这些“矢量化”文档进行语义搜索。
我编写了一个函数 print_results(),它接受查询、 query_index() 的结果和得分参数(是否打印相似度分数),并以易于解释的方式打印结果。 我使用丰富的 Python 包来格式化终端中的超链接,以便在支持超链接的终端中工作时,单击超链接将在默认浏览器中打开页面。 如果需要的话,我还使用网络浏览器自动打开顶部结果的链接。
使用丰富的超链接显示搜索结果
对于基于 Python 的搜索,我创建了一个类 FiftyOneDocsSearch 来封装文档搜索行为,以便一旦实例化 FiftyOneDocsSearch 对象(可能使用搜索参数的默认设置):
from fiftyone.docs_search import FiftyOneDocsSearch
fosearch = FiftyOneDocsSearch(open_url=False, top_k=3, score=True)
你可以通过调用此对象在 Python 中进行搜索。 例如,要查询文档“如何加载数据集”,只需运行:
fosearch(“How to load a dataset”)
在 Python 流程中语义搜索公司的文档
我还使用 argparse 来通过命令行提供此文档搜索功能。 安装该软件包后,可以通过 CLI 搜索文档:
fiftyone-docs-search query "<my-query>" <args
只是为了好玩,因为搜索查询有点麻烦,我向我的 .zsrch 文件添加了一个别名:
alias fosearch='fiftyone-docs-search query'
使用此别名,可以使用以下命令从命令行搜索文档:
fosearch "<my-query>" args
7、结束语
进入这个阶段,我已经将自己打造成公司开源 Python 库 FiftyOne 的高级用户。 我已经编写了许多文档,并且每天都使用(并将继续使用)该库。 但是将我们的文档转变为可搜索数据库的过程迫使我更深入地了解我们的文档。 当你为别人构建一些东西时,这总是很棒的,而且它最终也会帮助你!
这是我学到的:
- Sphinx RST 很麻烦:它制作了漂亮的文档,但解析起来有点痛苦
- 不要疯狂地进行预处理:OpenAI 的 text-embeddings-ada-002 模型非常适合理解文本字符串背后的含义,即使它的格式稍微不典型。 词干和煞费苦心地删除停用词和杂项字符的日子已经一去不复返了。
- 具有语义意义的小片段是最好的:将文档分成尽可能小的有意义的片段,并保留上下文。 对于较长的文本片段,搜索查询与索引中的部分文本之间的重叠更有可能被片段中不太相关的文本所掩盖。 如果将文档分解得太小,则会面临索引中许多条目包含很少语义信息的风险。
- 矢量搜索功能强大:只需最小的提升,并且无需任何微调,我就能够显着增强文档的可搜索性。 根据初步估计,这种改进的文档搜索返回相关结果的可能性是旧的关键字搜索方法的两倍多。 此外,这种矢量搜索方法的语义本质意味着用户现在可以使用任意短语、任意复杂的查询进行搜索,并保证获得指定数量的结果。
如果你发现自己(或其他人)不断挖掘或筛选文档宝库以获取特定的信息核心,我鼓励你根据自己的用例调整此过程。 你可以修改它以适用于个人文档或公司档案。 如果这样做了,我保证你将会以新的眼光看待您的文档!
可以通过以下几种方法将其扩展为自己的文档!
- 混合搜索:将矢量搜索与传统关键词搜索相结合
- 走向全球:使用Qdrant Cloud在云端存储和查询集合
- 合并网络数据:使用请求直接从网络下载 HTML
- 自动更新:每当底层文档发生变化时,使用 Github Actions 触发嵌入的重新计算
- 嵌入:将其包装在 Javascript 元素中并将其作为传统搜索栏的替代品
用于构建该包的所有代码都是开源的,可以在 voxel51/fiftyone-docs-search 存储库中找到。
原文链接:自然语言文档检索实战 — BimAnt