Press "Enter" to skip to content

如何使用 OpenAI 将公司文档转化为可搜索的数据库

在过去的六个月里,我一直在一家 A 轮初创公司 Voxel51 工作,该公司是 开源计算机视觉工具包 FiftyOne 的创建者。作为一名机器学习工程师和开发者推广员,我的工作是倾听我们的开源社区并提供他们的需求 – 新功能、集成、教程、研讨会,你说的都有。

几周前,我们在 FiftyOne 中添加了对向量搜索引擎和文本相似度查询的原生支持,这样用户就可以通过简单的自然语言查询在数据集中找到最相关的图像(通常包含数百万或数千万个样本)。

这使我们处于一个奇特的位置:现在,使用开源 FiftyOne 的人们可以通过自然语言查询轻松搜索数据集,但使用我们的文档仍然需要传统的关键字搜索。

我们有很多文档,这有它的优点和缺点。作为一个用户,我有时会发现,鉴于文档的数量之多,找到我想要的确切内容需要比我想要的时间更长。

我不会让这种情况继续下去… 所以在业余时间里,我建立了这个:

从命令行语义搜索您公司的文档。作者提供的图片。

因此,这就是我如何将我们的文档转变为具有语义搜索功能的向量数据库的过程:

您可以在voxel51/fiftyone-docs-search存储库中找到本文中所有示例的代码,而且您可以使用 pip install -e . 在本地以编辑模式轻松安装该软件包。

更好的是,如果您想使用这种方法在自己的网站上实现语义搜索,您可以跟随本文!以下是您需要的材料:

  • 安装 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笔记本转换而来的。

我想到了一个(错误的)假设,即我能够尽可能地接近RST和Jupyter文件的原始文本,事情就会变得简单。

RST

在RST文档中,节由只包含字符串=-_的行分隔。例如,下面是一个包含所有三种分隔符的FiftyOne User Guide文档:

来自开源FiftyOne文档的RST文档。作者提供的图片。

然后,我删除了所有RST关键字,例如toctreecode-blockbutton_link(还有很多),以及附带关键字、新块开始或块描述符的::..

处理链接也很容易:

no_links_section = re.sub(r"<[^>]+>_?","", section)

当我想从RST文件中提取节锚点时,事情开始变得棘手。我们的许多节明确指定了锚点,而其他节在转换为HTML期间则留给推断。

以下是一个例子:

.. _brain-embeddings-visualization:

Visualizing embeddings
______________________

The FiftyOne Brain provides a powerful
:meth:`compute_visualization() &lt;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 &lt;app-embeddings-panel>`, where you can interactively
select points of interest and view the corresponding samples/labels of interest
in the :ref:`Samples panel &lt;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() &lt;fiftyone.brain.compute_visualization>`
support a variety of ways to generate embeddings for your data:

在我们用户指南文档的brain.rst文件中(上面是部分副本),Visualizing embeddings节使用.. _brain-embeddings-visualization:指定了锚点。然而,紧随其后的Embedding methods子节是自动生成的锚点。

很快,另一个困难出现了,那就是如何处理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>`

另一方面,网格表格可能会变得非常混乱。它们为文档编写者提供了很大的灵活性,但这种灵活性也使得解析它们变得很困难。看一下我们的Filtering速查表中的这个表格:

+-----------------------------------------+-----------------------------------------------------------------------+
| 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"))                      |
+-----------------------------------------+-----------------------------------------------------------------------+

在表格中,行可以占用任意数量的行,列的宽度也可能不同。网格表格单元格中的代码块也很难解析,因为它们占用多行的空间,所以它们的内容与其他列的内容交织在一起。这意味着在解析过程中需要有效地重建这些表格中的代码块。

这不是世界末日。但也远非理想。

Jupyter

结果证明,解析Jupyter笔记相对简单。我能够将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单元格的开头#来确定新部分的开始。在短时间内,我从本地的FiftyOne笔记本中获取了足够的数据。

然而,考虑到RST带来的挑战,我决定转向HTML,并以同等地位处理所有文档。

HTML

我使用bash generate_docs.bash构建HTML文档,并开始用Beautiful Soup解析它们。然而,我很快意识到,当RST代码块和包含内联代码的表格被转换为HTML时,尽管它们的呈现是正确的,但HTML本身非常难以处理。以我们的Filtering速查表为例。

在浏览器中呈现时,Filtering速查表中的代码块如下所示:

来自我们的FiftyOne Docs Filtering速查表的屏幕截图。作者提供的图片。

然而,原始HTML如下所示:

RST速查表转换为HTML。作者提供的图片。

这并不是无法解析的,但也远非理想。

Markdown

幸运的是,我通过使用markdownify将所有HTML文件转换为Markdown来克服了这些问题。Markdown具有几个关键优势,使它非常适合这个任务。

  1. 比HTML更简洁:代码格式化简化为在之前和之后使用单个“`”的内联代码段,并使用三个引号```来标记代码块。这也使得将文本和代码拆分为文本和代码变得容易。
  2. 仍然包含锚点:与原始RST不同,此Markdown包括节标题锚点,因为隐式锚点已经生成好。这样,我可以链接到包含结果的页面,而不仅仅是该页面的特定部分或子部分。
  3. 标准化:Markdown为最初的RST和Jupyter文档提供了一个基本统一的格式,使我们能够为向量搜索应用程序提供一致的处理。

关于LangChain的说明

您们中的一些人可能已经了解到了用于构建LLM应用程序的开源库LangChain,也可能想知道为什么我不只是使用LangChain的Document LoadersText Splitters。答案是:我需要更多的控制权!

处理文档

一旦文档转换完成并进行了处理,并将其拆分成了字符串,我为每个文档的每个部分生成了一个嵌入向量。由于大型语言模型本质上具有灵活性和普适性,因此我决定将文本块和代码块与文本片段一样对待,并使用同一个模型嵌入它们。

我使用了OpenAI的text-embedding-ada-002模型,因为它易于使用,是OpenAI的所有嵌入模型中性能最高的(根据BEIR基准),而且也是最便宜的。它实际上非常便宜(每1000个标记0.0004美元),事实上使用这个模型为FiftyOne文档生成所有嵌入向量只需几美分!正如OpenAI自己所说:“我们建议几乎所有用例都使用text-embedding-ada-002。它更好,更便宜,使用起来更简单。”

有了这个嵌入模型,您可以生成一个1536维的向量,表示任何输入提示,最多可以使用8191个标记(约30000个字符)。

要开始使用,请创建OpenAI帐户,并在https://platform.openai.com/account/api-keys上生成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

要为所有文档生成嵌入向量,只需将此函数应用于每个子部分 – 文本块和代码块:

创建Qdrant向量索引

有了嵌入向量,我创建了一个向量索引用于搜索。我选择使用Qdrant的原因与我们选择将原生Qdrant支持添加到FiftyOne的原因相同:它是开源的、免费的,而且易于使用。

要开始使用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
        ),
    )

查询索引

创建索引后,可以通过使用相同的向量模型将查询文本嵌入,并在索引中搜索相似的嵌入向量来执行对索引文档的搜索。具有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, contents, score) 的元组组成的列表,其中 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 参数(是否打印相似度分数),并以易于解释的方式打印结果。我使用了Python的 rich 包来在终端中格式化超链接,以便在支持超链接的终端中工作时,单击超链接将在默认浏览器中打开页面。我还使用 webbrowser 自动打开最佳结果的链接(如果需要)。

使用丰富的超链接显示搜索结果。作者提供的图片。

对于基于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 使此文档搜索功能可通过命令行使用。安装包后,可以使用以下命令行搜索文档:

只是为了好玩,因为 fiftyone-docs-search query 有点冗长,我在 .zsrc 文件中添加了一个别名:

有了这个别名,就可以使用命令行搜索文档:

结论

在这之前,我已经将自己视为我们公司开源 Python 库 FiftyOne 的高级用户。我写过很多文档,并且每天都使用(并且将继续使用)该库。但是,将我们的文档转变为可搜索的数据库的过程迫使我更深入地了解了我们的文档。当您为其他人构建某些东西,并且最终帮助到了您自己,这总是很棒!

以下是我所学到的内容:

  • Sphinx RST很麻烦:它可以生成漂亮的文档,但是解析起来有些棘手。
  • 不要过度预处理:OpenAI的text-embeddings-ada-002模型非常擅长理解文本字符串的意义,即使其具有略微非典型的格式。不再需要在词干提取、费力地删除停用词和其他字符方面进行工作。
  • 适当的小段更好:将文档拆分为最小的有意义的段落,并保留上下文。对于较长的文本,查询与索引的一部分文本之间的重叠可能会被段落中不相关的文本所掩盖。如果您将文档拆分得过细,则会导致索引中的许多条目包含非常少的语义信息。
  • 向量搜索很强大:通过最小的努力和不进行任何微调,我能够显着提高我们文档的搜索能力。根据初步估计,改进后的文档搜索结果相对于旧的关键字搜索方法返回的相关结果的可能性超过两倍。此外,向量搜索的语义性质意味着用户现在可以使用任意短语、任意复杂的查询,并保证获得指定数量的结果。

如果您发现(或他人发现)自己经常在文件中搜索特定信息,我鼓励您根据自己的用例调整此过程。您可以将其修改为适用于个人文档或公司档案。如果您这样做,我保证您将从这个经历中以不同的角度看待您的文档!

以下是您可以为自己的文档扩展这个过程的几种方法!

  • 混合搜索:将向量搜索与传统的关键字搜索结合起来。
  • 全球化:使用Qdrant Cloud在云中存储和查询集合。
  • 结合网络数据:使用requests直接从Web下载HTML。
  • 自动更新:使用Github Actions在基础文档发生更改时触发嵌入向量的重新计算。
  • 嵌入:将其包装在Javascript元素中,并将其作为传统搜索栏的替代品插入。

用于构建软件包的所有代码都是开源的,并且可以在voxel51/fiftyone-docs-search存储库中找到。

本文档由GeekAI文档翻译服务自动翻译完成:

微信扫描立刻体验极客翻译
发表回复