跳转到主要内容

category

以及如何对您的文档进行同样的处理

在过去的六个月里,我一直在一个初创公司Voxel51工作,该公司是开源计算机视觉工具包FiftyOne的创始人。作为一名机器学习工程师和开发人员,我的工作是倾听我们的开源社区,并为他们带来他们需要的东西——新功能、集成、教程、研讨会,你能想到的。

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

这让我们陷入了一个奇怪的境地:现在,使用开源FiftyOne的人可以通过自然语言查询轻松搜索数据集,但使用我们的文档仍然需要传统的关键字搜索。

我们有很多文档,这些文档有其优点和缺点。作为一名用户,我有时会发现,考虑到文档的数量,准确地找到我想要的内容需要比我想要的更多的时间。

我不打算让它飞起来…所以我在业余时间建造了这个: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", |

Semantically search your company’s docs from the command line. Image courtesy of author.

因此,以下是我如何将我们的文档转化为语义可搜索的向量数据库:

你可以在voxel51/fiftyone文档搜索库中找到这篇文章的所有代码,使用pip-install-e可以很容易地在编辑模式下本地安装包。。

更好的是,如果你想用这种方法为自己的网站实现语义搜索,你可以跟着做!以下是您需要的配料:

  • 安装openai Python包并创建一个帐户:您将使用此帐户将文档和查询发送到推理端点,该端点将为每段文本返回一个嵌入向量。
  • 安装qdrant客户端Python包,并通过Docker启动qdrant服务器:您将使用qdrant为文档创建一个本地托管的向量索引,并对其运行查询。Qdrant服务将在Docker容器中运行。

将文档转换为统一格式

我公司的文档都以HTML文档的形式托管在https://docs.voxel51.com.一个自然的起点是使用Python的请求库下载这些文档,并使用Beautiful Soup解析文档。

然而,作为一名开发人员(也是我们许多文档的作者),我认为我可以做得更好。我在本地计算机上已经有了一个GitHub存储库的工作克隆,其中包含用于生成HTML文档的所有原始文件。我们的一些文档是用Sphinx ReStructured Text(RST)编写的,而其他文档,如教程,则从Jupyter笔记本转换为HTML。

我(错误地)认为,我越接近RST和Jupyter文件的原始文本,事情就越简单。

RST

在RST文件中,部分由仅由=、-或_字符串组成的线划定。例如,以下是《FiftyOne用户指南》中的一份文档,其中包含所有三个轮廓线:

RST document from open source FiftyOne Docs. Image courtesy of author.

然后,我可以删除所有RST关键字,如toctreecode-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"))                      |
+-----------------------------------------+-----------------------------------------------------------------------+

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

不是世界末日。但也不理想。

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单元格来描绘截面。

尽管如此,考虑到RST带来的挑战,我决定转向HTML,并平等对待我们所有的文档。

HTML格式

我用bash generate_docs.bash从本地安装构建了HTML文档,并开始用Beautiful Soup解析它们。然而,我很快意识到,当RST代码块和带有内联代码的表被转换为HTML时,尽管它们的呈现是正确的,但HTML本身却非常笨拙。以我们的过滤备忘单为例。

当在浏览器中呈现时,我们的过滤备忘单的日期和时间部分之前的代码块如下所示:

然而,原始HTML看起来是这样的:

RST cheat sheet converted to HTML. Image courtesy of author.

这并非不可能解析,但也远非理想。

Markdown

幸运的是,我能够通过markdownify将所有HTML文件转换为Markdown来克服这些问题。Markdown有几个关键优势,使其最适合这份工作。

  1. 比HTML更干净:代码格式从span元素的意大利面条字符串简化为用单“before”和“after”标记的内联代码片段,代码块用三引号“”标记before和after。这也使得它可以很容易地拆分为文本和代码。
  2. 仍然包含锚点:与原始RST不同,此Markdown包含部分标题锚点,因为隐式锚点已经生成。这样,我不仅可以链接到包含结果的页面,还可以链接到该页面的特定部分或小节。
  3. 标准化:Markdown为最初的RST和Jupyter文档提供了基本统一的格式,使我们能够在矢量搜索应用程序中对其内容进行一致的处理。

关于LangChain的注释

你们中的一些人可能知道用LLM构建应用程序的开源库LangChain,可能想知道为什么我不使用LangChain的文档加载程序和文本拆分器。答案是:我需要更多的控制!

处理文档

一旦文档被转换为Markdown,我就开始清理内容,并将它们分成更小的部分。

Cleaning

清洁主要包括去除不必要的元素,包括:

  • 页眉和页脚
  • 表的行和列脚手架-例如|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

并将每一块文本或代码分配到相应的部分。

最初,我还尝试将文本块拆分为段落,假设由于一个部分可能包含许多不同主题的信息,因此整个部分的嵌入可能与仅涉及其中一个主题的文本提示的嵌入不同。然而,这种方法导致大多数搜索查询的顶部匹配不成比例地是单行段落,这并不能作为搜索结果提供非常丰富的信息。

查看附带的GitHub repo,了解这些方法的实现,您可以在自己的文档中试用!

使用OpenAI嵌入文本和代码块

随着文档被转换、处理并拆分为字符串,我为每个块生成了一个嵌入向量。因为大型语言模型是灵活的,而且本质上是有能力的,所以我决定将文本块和代码块视为文本块,并将它们嵌入到同一模型中。

我使用了OpenAI的 text-embedding-ada-002 model模型,因为它易于使用,在OpenAI的所有嵌入模型中实现了最高的性能(在BEIR基准上),也是最便宜的。事实上,它非常便宜(0.0004/1K代币),为FiftyOne文档生成所有嵌入只需几美分!正如OpenAI自己所说,“我们建议几乎所有用例都使用text-embedding-ad-002。它更好、更便宜、更简单。”

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

首先,您需要创建一个OpenAI帐户,在以下位置生成一个API密钥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 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包来格式化终端中的超链接,这样当在支持超链接的终端中工作时,单击超链接将在默认浏览器中打开页面。如果需要的话,我还使用网络浏览器自动打开顶部结果的链接。

Display search results with rich hyperlinks. Image courtesy of author.

对于基于Python的搜索,我创建了一个类FiftyOneDocsSearch来封装文档搜索行为,这样,一旦实例化了FiftyoneDocsSsearch对象(可能具有搜索参数的默认设置):

from fiftyone.docs_search import FiftyOneDocsSearch
fosearch = FiftyOneDocsSearch(open_url=False, top_k=3, score=True)

您可以通过调用此对象在Python中进行搜索。例如,要查询文档中的“如何加载数据集”,您只需要运行:

fosearch(“How to load a dataset”)

Semantically search your company’s docs within a Python process. Image courtesy of author.

我还使用argparse通过命令行提供了这个文档搜索功能。安装软件包后,可以使用以下命令在CLI中搜索文档:

fiftyone-docs-search query "<my-query>" <args

为了好玩,因为fiftyne文档搜索查询有点麻烦,我在.zsrch文件中添加了一个别名:

alias fosearch='fiftyone-docs-search query'

使用此别名,可以通过以下命令行搜索文档:

fosearch "<my-query>" args

结论

说到这里,我已经把自己塑造成了公司开源Python库FiftyOne的超级用户。我已经写了很多文档,并且每天都在使用(并将继续使用)这个库。但将我们的文档变成可搜索数据库的过程迫使我更深入地理解我们的文档。当你为别人做一些事情时,这总是很棒的,它最终也会帮助你!

以下是我学到的:

  • Sphinx RST很麻烦:它制作了漂亮的文档,但解析起来有点麻烦
  • 不要疯狂地进行预处理:OpenAI的text-embeddings-ada-002模型非常善于理解文本字符串背后的含义,即使它的格式有点非典型。用词干和煞费苦心地去除停止词和杂字的日子已经一去不复返了。
  • 语义上有意义的小片段是最好的:将文档分成尽可能小的有意义的片段,并保留上下文。对于较长的文本片段,搜索查询和索引中的部分文本之间的重叠更有可能被片段中不太相关的文本所掩盖。如果你把文档分解得太小,那么索引中的许多条目就有可能包含很少的语义信息。
  • 矢量搜索功能强大:只需最小的提升,无需任何微调,我就可以显著增强文档的搜索能力。根据初步估计,这种改进的文档搜索返回相关结果的可能性似乎是旧的关键字搜索方法的两倍多。此外,这种矢量搜索方法的语义性质意味着用户现在可以使用任意措辞、任意复杂的查询进行搜索,并保证获得指定数量的结果。
  • 如果您发现自己(或他人)不断地挖掘或筛选文档宝库中的特定核心信息,我鼓励您根据自己的用例调整此过程。您可以对此进行修改,以便为您的个人文档或公司的档案工作。如果你这样做了,我保证你会从新的角度看待你的文档!

以下是一些可以为您自己的文档扩展的方法!

  • Hybrid search:将矢量搜索与传统关键词搜索相结合
  • 走向全球:使用Qdrant Cloud在云中存储和查询集合
  • 合并网络数据:使用请求直接从网络下载HTML
  • 自动更新:每当底层文档发生变化时,使用Github Actions触发嵌入的重新计算
  • 嵌入:将其封装在Javascript元素中,并将其作为传统搜索栏的替代品放置

用于构建包的所有代码都是开源的,可以在voxel51/fiftyone-docs-search文档搜索库中找到。

 

文章链接