在这个系列中,我们正在研究一个基于我们公司Notion页面内容构建的向量索引的实现,它不仅可以让我们搜索相关信息,还可以使语言模型直接通过Notion作为知识库来回答我们的问题。在本文中,我们将看到我们如何使用向量数据库最终实现这一目标。
数字、向量和图表是真实数据,除非另有说明。
上次,我们从Notion API下载并处理了数据。让我们对它做点什么。
向量数据库 #
为了找到语义上相似的文本,我们需要计算向量之间的距离。当我们只有几个简短的文本时,我们可以采用暴力方法:逐个计算查询与每个文本嵌入之间的距离,并找出最接近的文本。然而,当我们的数据库中有数千甚至数百万条条目时,我们需要一种更高效的方法来进行计算。 检索向量是一种通过计算相似度来查找相关条目的方法,就像对于其他大量条目的搜索方式一样,索引可以在这里起到帮助作用。为了让我们的生活更轻松,我们将使用Weaviate DB (opens new window) - 一个实现了HNSW向量索引以提高向量搜索性能的向量数据库。
有很多不同的向量数据库可以使用。我们选择使用Weaviate DB是因为它具有合理的默认设置,包括向量和BM25索引可以直接使用,并且还具有许多可以通过模块启用的功能(比如前面提到的“rerank”)。您还可以考虑使用Postgres扩展程序“pgvector”来利用SQL的优势:关系、连接、子查询等,而Weaviate在这方面可能更加有限。选择明智!
我可能会在将来重新讨论向量索引的主题,但在本文中,我只会使用实现了向量索引的数据库。要了解更多关于HNSW本身的信息,请查看这里 (opens new window),要了解更多关于配置向量数据库的信息,请查看 Weaviate DB(Weaviate 数据库)
Weaviate DB 是一个开源、可扩展的向量数据库,您可以轻松地在自己的项目中使用它。只需一个 Docker 容器,就可以享受向量的优势,您可以这样运行它:
docker run -p 8080:8080 -d semitechnologies/weaviate:latest
Weaviate 是模块化的,有许多模块可以为您的数据库添加功能。您可以自己提供嵌入向量给数据库条目,但也有一些模块可以为您计算这些向量,例如使用 openAI API 的 text2vec-openai 模块。还有一些模块可以让您轻松将数据库数据备份到 S3,为搜索添加重新排列功能,以及更多功能 (opens new window)。启用一个模块只需添加一个环境变量即可:
docker
``` 现在,要从我们的TypeScript项目连接到数据库:
```typescript
import weaviate from 'weaviate-ts-client';
const client = weaviate.client({
scheme: 'http',
host: 'localhost:8080',
});
Weaviate数据库中的所有数据都存储在类中(相当于SQL中的表或MongoDB中的集合),包含数据对象。对象具有一个或多个不同类型的属性,并且每个对象可以由一个向量表示。就像SQL数据库一样,Weaviate是基于模式的。我们使用其名称、属性和其他配置(例如,应使用哪些模块进行向量化)定义一个类。这是一个具有一个属性的最简单的类。
{
"class": "MagicIndex",
"properties": [
{
"name": "content",
"dataType": ["text"]
}
]
}
``` 我们可以添加任意多的属性。有多种类型可用:整数、浮点数、文本、布尔值、地理坐标(具有基于位置查询的特殊方式)、二进制大对象(blobs)、或者这些类型的列表,如int[]或text[]:
{ class: 'MagicIndex', properties: [ { name: 'content', dataType: ['text'] }, { name: 'tags', dataType: ['text[]'] }, { name: 'lastUpdated', dataType: ['date'] }, { name: 'file', dataType: ['blob'] }, { name: 'location', dataType: ['geoCoordinates'] }, ], }
如果你不想自己提供嵌入,并且想要控制如何以及为哪些属性计算嵌入,也可以进行设置:
{ class: 'MagicIndex', properties: [ { name: 'content', dataType: ['text'] }, { name: 'metadata', dataType: ['text'], moduleConfig: { 'text2vec-openai': { skip: true, }, }, }, ], }
>Weaviate每个对象只存储一个向量,因此如果您有更多需要矢量化的字段(或启用了矢量化类名),则将从连接的文本计算嵌入。如果您希望为文档的不同属性(如不同的块、标题、元数据等)拥有单独的向量,您需要在数据库中创建单独的条目。
应用模式的方法如下:
await client.schema .classCreator() .withClass(classDefinition) .do();
让我们看看我们在Notion索引中的数据对象是什么样的:
{ pageTitle: '快速棕色狐狸的机车运动学:对懒惰的狗科障碍物上犬类速度的深入分析', chunk: '1', originalConte 摘要 快速的棕色狐狸跳过懒狗的范式长期以来一直吸引着科学界和普通民众...
摘要 快速的棕色狐狸跳过懒狗的范式长期以来一直吸引着科学界和普通民众...
请注意,我们存储了页面标题、其ID、URL和最后更新日期。我们只对内容进行向量化处理:向量化器忽略了标题、原始内容等。
您可能已经注意到了一个“块”属性。它是什么?为了使向量最好地发挥作用,最好不要太长。它通常用于短段落长度的文本,因此我们将内容分割为 将Notion页面拆分为更小的块。我们使用了lanchain的递归文本拆分器 (opens new window)。它首先尝试通过双换行符进行拆分,如果一些块仍然太长,再通过单个换行符、空格等进行拆分。这样可以尽可能地保持段落的完整性。我们将目标块长度设置为1000个字符,并设置了200个字符的重叠。
拆分块的长度和方式对向量搜索性能有很大影响。通常认为,块的大小应与查询的长度相似(因此在搜索时可以比较大小相似的文本向量)。在我们的情况下,长度为1000个字符的块似乎效果最好,但实际情况可能有所不同。此外,我们还确保表格行不会被拆分,以避免出现“孤立的”列。这是一个很大的话题,我可能会在将来的文章中重新讨论。 我们在数据库中单独保存每个块,并且chunk属性是块的索引。为什么它是字符串而不是数字呢?因为我们不对标题属性进行向量化处理,我们保存一个单独的条目,其格式如下:
{
pageTitle: '快速棕色狐狸的机车运动学:对懒惰犬类障碍物上犬速度的深入分析',
chunk: 'title',
originalContent: '快速棕色狐狸的机车运动学对懒惰犬类障碍物上犬速度的深入分析',
...
}
将来,我们可能决定要对页面的更多属性进行向量化处理,而不仅仅是内容和标题。我们只需将新的可能值添加到_chunk_属性中即可轻松实现。
_content_和_originalContent_属性是什么意思?为了减少数据中的噪音,我们准备了每个块的清理版本。我们删除所有特殊字符,替换多个空格,以及其他无关信息。 在我们的测试中,使用这种简单的清理方法,向量搜索的准确性稍微更高一些。尽管如此,我们仍然保留了 originalContent,因为这是我们传递给重新排序和用于传统的反向索引搜索的内容。
最后,我们有一个 pageType 属性,这只是一个 Notion 的怪癖的结果:在 Notion 中,页面可以是一个 page 或一个 database。如前一篇文章所述,我们在索引中将它们都视为相同的方式处理:将数据库转换为简单的表格。
好的,我们知道要在数据库中存储什么数据,但是如何添加、获取和查询这些数据呢?
Weaviate 接口 #
Weaviate 提供了两种与其进行交互的接口,即 RESTful 和 graphQL API,并且这反映在可用的 Typescript 客户端方法中。我们将重点介绍 graphQL 接口。要从数据库中获取条目,我们只需要提供一个类名和我们想要获取的字段即可。
client.graphql ```javascript
.get()
.withClassName('MagicIndex')
.withFields('pageTitle originalContent pageUrl');
建议每个查询都限制并使用基于游标的分页,如果需要的话:
client.graphql
.get()
.withClassName('MagicIndex')
.withFields('pageTitle originalContent pageUrl')
.withLimit(50)
.withAfter(cursor);
让我们向数据库中添加一些条目:
await client.data
.creator()
.withClassName('MagicIndex')
.withProperties({
pageTitle: '狐狸的敏捷性与犬类的冷漠性:一项比较研究',
chunk: '2',
originalContent: '## 背景 \n\n 虽然在排版测试中被口语化地传颂,但是一个快速的棕色狐狸越过一只懒散的狗的情景呈现...',
content: '背景\n虽然在排版测试中被口语化地传颂,但是一个快速的棕色狐狸越过一只懒散的狗的情景呈现...',
pageId: '1ba0b851-'
});
``` 传递给MagicIndex类的矢量化器启用后,我们只需要这样做。条目将与由OpenAI的ADA嵌入模型计算得出的其矢量表示一起添加到数据库中。现在我们可以整天搜索关于狐狸和狗的文本。
传统搜索
我们还可以使用传统的反向索引方法进行搜索!我们可以使用一种称为BM25F的词袋排序函数。它已经配置了合理的默认值。让我们看看它的工作原理:
await client.graphql .get() .withClassName('MagicIndex') .withBm25({ query: '狐狸真的能跳过狗吗?', properties: ['originalContent'], }) .do(); 你可以看到我们可以在查询中请求的_additional属性。它可以包含与对象本身相关的各种附加数据(如ID),或者与搜索相关的附加数据(如BM25分数或余弦距离)。
向量搜索 #
当然,反向索引搜索无法找到许多关于棕色狐狸的文本,而不使用这些词。谢天谢地,语义搜索同样容易执行:
await client.graphql
.get()
.withClassName('MagicIndex')
.withNearText({ concepts: ['狐狸真的能跳过狗吗?'] })
.withLimit(5)
.withFields('pageTitle originalContent pageUrl _additional { distance }')
.do();
我们可以做一些额外的魔法来提高搜索的效果,比如设置最大的余弦距离。 现在,我们不仅仅获取与余弦距离小于0.25的结果(这是在withNearText方法中设置的distance参数的作用),而且weaviate的autocut功能将按照相似的距离分组并返回前两个分组(有关autocut功能的更多信息,请参见这里)。
但这还不是全部。我们还可以根据一些概念进行搜索,同时避免其他一些概念:
await client.graphql
.get()
.withClassName('MagicIndex')
.withNearText({
concepts: ['Can the fox really jum 虽然狐狸的例子有点傻,但你可以想象到在许多情况下这个功能非常有用。也许你正在寻找“飞行的方式”,但你想远离“飞机”,而靠近“动物”。或者你可能在搜索一个查询,但想保持结果与数据库中的另一个对象相似:
```javascript
await client.graphql
.get()
.withClassName('MagicIndex')
.withNearText({
concepts: ['狐狸真的能跳过狗吗?'],
moveTo: {
objects: [{ id: '84ab0371-a73b-4774-8b03-eccb97b640ae' }],
force: 0.85,
},
})
.withFields('pageTitle originalContent pageUrl')
.do()
还有许多其他功能。 混合搜索
最后,我们可以将向量搜索的功能与BM25索引相结合!这就是混合搜索,它使用这两种方法并将它们与给定的权重组合起来:
await client.graphql
.get()
.withClassName('MagicIndex')
.withHybrid({
query: '狐狸真的能跳过狗吗?',
})
.withLimit(5)
.withFields('pageTitle originalContent pageUrl _additional { distance score explainScore }')
.do();
在_additional.explainScore属性中,您将找到有关来自向量和反向索引搜索的分数贡献的详细信息。默认情况下,向量搜索结果的权重为0.75,反向索引的权重为0.25,这些是我们在Notion搜索中使用的值。有关混合搜索的工作原理以及如何自定义权重的详细信息,请参见Weaviate文档 (opens new window)。 ## 重新排序
如果我们启用了重新排序模块,我们可以使用它来提高搜索结果的质量。它适用于任何搜索方法:向量、BM25或混合:
await client.graphql
.get()
.withClassName('MagicIndex')
.withHybrid({
query: '狐狸真的能跳过狗吗?',
})
.withLimit(100)
.withFields('pageTitle originalContent pageUrl _additional { rerank(property: "originalContent" query: "狐狸真的能跳过狗吗?") { score } }')
.do();
将重新排序的_score_字段添加到查询中,将使Weaviate调用重新排序模块并根据接收到的分数对结果重新排序。为了增加找到相关结果的机会,我们还增加了限制:现在重新排序有更多的文本可以处理。 总结一下。在我们的Notion索引中,我们使用了以下Weaviate DB模块:
- text2vec-openai,使Weaviate能够使用OpenAI API和ADA模型计算嵌入
- reranker-cohere,允许我们使用CohereAI的重新排序模型来改进搜索结果
- backup-s3,只是为了更容易备份数据和在环境之间迁移
为了获取要索引的数据,我们使用一个空查询的搜索端点获取所有Notion页面。在每个页面中,我们递归获取所有块,然后通过一组解析器对其进行解析:针对每种块类型的特定解析器。然后,我们为每个页面得到一个Markdown格式的字符串。
然后,我们将每个页面的内容分割成块:每个块长度为1000个字符,重叠200个字符。我们还通过删除特殊字符和多个空格来“清理”文本,以提高向量搜索的性能。
关于数据的部分 p?
The search algorithm can handle these queries and provide relevant results. It has been tested extensively and has proven to be effective in finding the desired information.
However, there are still some limitations. The algorithm may occasionally return unrelated or less relevant results, especially when the query is ambiguous or the pages have similar content. This is something we are continuously working on improving.
Overall, the semantic search has been a successful addition to our system, providing users with a more efficient and accurate way to find information. We are confident that with further enhancements, it will continue to improve and meet the needs of our users. 但并不是一切都能完美运行。在查找大型表格中的信息时(例如,我们有一个包含团队成员的表格 - 长度很长,有很多列和长文本),如果您不善于将它们分块,可能会有些挑战。例如,确保每一行都在一个块中,即使非常长,也要避免孤立的列。即使如此,搜索结果也不完美,例如,当询问我们团队中谁是用户体验设计师时,它可能会找到一个块,其中只有三个用户体验设计师中的一个人。虽然这对于搜索来说没问题(在搜索结果中,您仍然会得到指向包含整个表格的正确页面的链接),但这对于可能因此错过一些信息的问答机器人来说可能不够。
另一个问题是噪音。我们希望有一个更好的搜索的原因之一就是我们的Notion工作空间中有数千页的会议记录、过时的指南和其他大部分与主题无关的东西。我们已经实施了一些缓解措施来改进搜索结果并消除噪音。 尽管有许多小的调整需要进行,但总体而言,结果已经令人非常满意。我们成功地创建了一个实际可用的 Notion 搜索功能。降低旧页面的“搜索得分”等方法虽然有效,但还不够。最好的方法仍然是手动排除最有问题的区域。当然,这并不理想,我们希望我们的搜索引擎能够自动确定相关内容,这是需要更多研究的方向。
总的来说,我们的成果是令人满意的。尽管需要进行一些小的调整,但我们成功地创建了一个实用的 Notion 搜索功能。