Compartir a través de

在 Azure AI 搜索中为复杂数据类型建模

用于填充 Azure AI 搜索索引的外部数据集可以采用多种形状。 有时它们包含分层或嵌套的子结构。 示例包括单个客户的多个地址、单个产品的多个颜色和大小、一本书籍的多位作者等等。 在建模术语中,这些结构可能称作复杂、组合、复合或聚合数据类型。 Azure AI 搜索对此概念使用的术语是“复杂类型”。 在 Azure AI 搜索中,复杂类型是使用复杂字段建模的。 复杂字段是包含子级(子字段)的字段,这些子级可以是任何数据类型(包括其他复杂类型)。 其工作原理类似于编程语言中的结构化数据类型。

复杂字段表示文档中的单个对象,或对象的数组,具体取决于数据类型。 Edm.ComplexType 类型的字段表示单个对象,而 Collection(Edm.ComplexType) 类型的字段表示对象的数组。

Azure AI 搜索原生支持复杂类型和集合。 使用这些类型几乎可为 Azure AI 搜索索引中的任何 JSON 结构建模。 在旧版的 Azure AI 搜索 API 中,只能导入平展的行集。 在最新版本中,索引可以更密切地对应于源数据。 换言之,如果源数据使用复杂类型,则索引也可以使用复杂类型。

若要开始,我们建议使用 Hotels 数据集,可以在 Azure 门户上“导入数据”向导中加载该数据集。 该向导会检测源中的复杂类型,并根据检测到的结构建议一个索引架构。

注意

api-version=2019-05-06 开始正式提供对复杂类型的支持。

如果你的搜索解决方案是基于以前的解决方法(集合中的平展数据集)生成的,应更改索引,使之包含最新 API 版本支持的复杂类型。 有关升级的 API 版本的详细信息,请参阅升级到最新的 REST API 版本升级到最新的 .NET SDK 版本

复杂结构的示例

以下 JSON 文档由简单字段和复杂字段构成。 复杂字段(例如 AddressRooms)包含子字段。 Address 包含这些子字段的单一值集,因为它是文档中的单个对象。 相反,Rooms 包含其子字段的多个值集,集合中的每个对象各有一个值集。

{
  "HotelId": "1",
  "HotelName": "Stay-Kay City Hotel",
  "Description": "Ideally located on the main commercial artery of the city in the heart of Beijing.",
  "Tags": ["Free wifi", "on-site parking", "indoor pool", "continental breakfast"],
  "Address": {
    "StreetAddress": "677 5th Ave",
    "City": "Beijing",
    "StateProvince": "NY"
  },
  "Rooms": [
    {
      "Description": "Budget Room, 1 Queen Bed (Cityside)",
      "RoomNumber": 1105,
      "BaseRate": 96.99,
    },
    {
      "Description": "Deluxe Room, 2 Double Beds (City View)",
      "Type": "Deluxe Room",
      "BaseRate": 150.99,
    }
    . . .
  ]
}

创建复杂字段

与处理任何索引定义时一样,可以使用门户、REST API.NET SDK 创建包含复杂类型的架构。

其他 Azure Sdk 提供 PythonJavaJavaScript 的示例。

  1. 登录 Azure 门户

  2. 在搜索服务概述页上,选择“索引”选项卡。

  3. 打开现有索引或创建新索引。

  4. 选择“字段”选项卡,然后选择“添加字段”。 添加一个空字段。 如果使用现有字段集合,请向下滚动以设置字段。

  5. 为字段命名,并将类型设置为 Edm.ComplexTypeCollection(Edm.ComplexType)

  6. 选择最右边的省略号,然后选择“添加字段”或“添加子字段”,然后分配属性。

复杂集合限制

在编制索引期间,单个文档中的所有复杂集合总共最多可包含 3,000 个元素。 复杂集合的元素为该集合的成员。 对于“房间”(酒店示例中唯一的复杂集合),每个房间都是一个元素。 在上面的示例中,如果“Stay-Kay 城市酒店”有 500 个客房,酒店文档将包含 500 个客房元素。 对于嵌套的复杂集合,除了外部(父)元素之外,还计入每个嵌套元素。

此限制仅适用于复杂集合,不适用于复杂类型(如地址)或字符串集合(如标记)。

更新复杂字段

一般情况下,应用于字段的所有重建索引规则仍会应用于复杂字段。 在复杂类型中添加新字段不需要重建索引,但大多数其他修改都需要重建索引。

对定义的结构更新

随时可以将新的子字段添加到复杂字段,而无需索引重建。 例如,允许将“ZipCode”添加到 Address或者将“Amenities”添加到 Rooms,就如同将顶级字段添加到索引一样。 在通过更新数据显式填充新字段之前,现有文档将对这些字段使用 null 值。

请注意,在复杂类型中,像顶级字段一样,每个子字段包含一个类型,有时还包含属性

数据更新

对于复杂字段和简单字段而言,使用 upload 操作更新索引中现有文档的过程是相同的:将替换所有字段。 但是,merge(应用于现有文档时使用 mergeOrUpload)对所有字段的运行方式不同。 具体而言,merge 不支持合并集合中的元素。 基元类型集合与复杂集合存在此限制。 要更新集合,需要检索整个集合值,进行更改,然后在索引 API 请求中包含新的集合。

在文本查询中搜索复杂字段

可按预期方式对复杂类型运行自由形式的搜索表达式。 如果文档中任何位置的任何可搜索字段或子字段匹配,则文档本身就是匹配项。

如果使用多个字词或运算符,并且某些字词指定了字段名(可以使用 Lucene 语法来指定),则查询会变得更微妙。 例如,此查询尝试将两个字词“Portland”和“OR”与 Address 字段的两个子字段相匹配:

search=Address/City:Portland AND Address/State:OR

此类查询对于全文搜索是不相关联的,这与筛选器不同。 在筛选器中,使用 anyall 中的范围变量来关联复杂集合子字段上的查询。 上述 Lucene 查询返回包含“Portland, Maine”和“Portland, Oregon”以及 Oregon 中其他城市的文档。 之所以会发生这种情况,是因为每个子句都应用于整个文档中其字段的所有值,因此没有“当前子文档”的概念。 有关详细信息,请参阅了解 Azure AI 搜索中的 OData 集合筛选器

在 RAG 查询中搜索复杂字段

RAG 模式将搜索结果传递到用于生成式 AI 和聊天搜索的聊天模型。 默认情况下,传递给 LLM 的搜索结果是平展行集。 不过,如果索引具有复杂类型,那么只要先将搜索结果转换为 JSON,然后将 JSON 传递给 LLM,查询就可以提供这些字段。

一个部分示例说明了该技术:

  • 在提示或查询中指示所需的字段
  • 确保字段可在索引中搜索和检索
  • 选择搜索结果的字段
  • 将结果格式设置为 JSON
  • 将聊天补全请求发送到模型提供程序
import json

# Query is the question being asked. It's sent to the search engine and the LLM.
query="Can you recommend a few hotels that offer complimentary breakfast? Tell me their description, address, tags, and the rate for one room they have which sleep 4 people."

# Set up the search results and the chat thread.
# Retrieve the selected fields from the search index related to the question.
selected_fields = ["HotelName","Description","Address","Rooms","Tags"]
search_results = search_client.search(
    search_text=query,
    top=5,
    select=selected_fields,
    query_type="semantic"
)
sources_filtered = [{field: result[field] for field in selected_fields} for result in search_results]
sources_formatted = "\n".join([json.dumps(source) for source in sources_filtered])

response = openai_client.chat.completions.create(
    messages=[
        {
            "role": "user",
            "content": GROUNDED_PROMPT.format(query=query, sources=sources_formatted)
        }
    ],
    model=AZURE_DEPLOYMENT_MODEL
)

print(response.choices[0].message.content)

有关端到端示例,请参阅快速入门:将生成式搜索 (RAG) 与 Azure AI 搜索中的上下文关联数据配合使用

选择复杂字段

$select 参数用于选择要在搜索结果中返回哪些字段。 要使用此参数选择复杂字段的特定子字段,请包括父字段和用斜线 (/) 分隔的子字段。

$select=HotelName, Address/City, Rooms/BaseRate

如果希望这些字段在搜索结果中出现,必须在索引中将其标记为可检索。 只有标记为可检索的字段才能在 $select 语句中使用。

筛选、分面和排序复杂字段

用作筛选和带字段搜索的 OData 路径语法同样也可用于分面、排序和选择搜索请求中的字段。 对于复杂类型,可以应用规则来控制可将哪些子字段标记为可排序或可分面。 有关这些规则的详细信息,请参阅创建索引 API 参考

分面子字段

除非类型为 Edm.GeographyPointCollection(Edm.GeographyPoint),否则任何子字段都可标记为可分面。

分面结果中返回的文档计数是根据父文档(酒店)计算的,而不是根据复杂集合中的子文档(客房)计算的。 例如,假设某家酒店有 20 间“套房”类型的客房。 如果此分面参数为 facet=Rooms/Type,则分面计数是 1 家酒店,而不是 20 间客房。

排序复杂字段

排序操作将应用于文档(酒店)而不是子文档(客房)。 使用复杂类型集合(例如客房)时必须认识到,根据无法按“客房”排序。 事实上,无法按任何集合进行排序。

当每个文档中的字段只有一个值时,无论字段是简单字段还是复杂类型中的子字段,排序操作都会正常运行。 例如,允许 Address/City 可排序,因为每家酒店只有一个地址,因此 $orderby=Address/City 会按城市对酒店排序。

根据复杂字段进行筛选

可以在筛选表达式中引用复杂字段的子字段。 只需使用对分面、排序和选择字段所用的相同 OData 路径语法。 例如,以下筛选器会返回位于加拿大的所有酒店:

$filter=Address/Country eq 'Canada'

若要根据复杂集合字段进行筛选,可以结合 anyall 运算符使用 Lambda 表达式。 在这种情况下,Lambda 表达式的范围变量是具有子字段的对象。 可以使用标准 OData 路径语法来引用这些子字段。 例如,以下筛选器会返回至少有一间豪华客房,且所有客房都禁止吸烟的所有酒店:

$filter=Rooms/any(room: room/Type eq 'Deluxe Room') and Rooms/all(room: not room/SmokingAllowed)

与顶级简单字段一样,仅当已在索引定义中将复杂字段的简单子字段的 filterable 属性设置为 true 时,才能在筛选器中包含这些子字段。 有关详细信息,请参阅创建索引 API 参考

复杂集合限制的解决方法

回想一下,Azure AI 搜索将集合中的复杂对象限制为每个文档 3,000 个对象。 超出此限制会导致以下消息:

A collection in your document exceeds the maximum elements across all complex collections limit. 
The document with key '1052' has '4303' objects in collections (JSON arrays). 
At most '3000' objects are allowed to be in collections across the entire document. 
Remove objects from collections and try indexing the document again."

如果需要超过 3,000 个项,可以使用竖线 (|) 或使用任何形式的分隔符来分隔值、连接这些值并将其存储为分隔字符串。 数组中存储的字符串数没有限制。 将复杂值存储为字符串会绕过复杂集合限制。

为了说明这一点,假设你有一个 "searchScope" 数组,其中包含超过 3,000 个元素:


"searchScope": [
  {
     "countryCode": "FRA",
     "productCode": 1234,
     "categoryCode": "C100" 
  },
  {
     "countryCode": "USA",
     "productCode": 1235,
     "categoryCode": "C200" 
  }
  . . .
]

将值存储为带分隔符字符串的解决方法可能如下所示:

"searchScope": [
        "|FRA|1234|C100|",
        "|FRA|*|*|",
        "|*|1234|*|",
        "|*|*|C100|",
        "|FRA|*|C100|",
        "|*|1234|C100|"
]

在搜索场景中,如果希望搜索仅包含“FRA”或“1234”或数组中其他组合的项,将所有搜索变体存储在带分隔符字符串中会有所帮助。

下面是一个用 C# 编写的筛选器格式设置代码片段,可将输入转换为可搜索字符串:

foreach (var filterItem in filterCombinations)
        {
            var formattedCondition = $"searchScope/any(s: s eq '{filterItem}')";
            combFilter.Append(combFilter.Length > 0 ? " or (" + formattedCondition + ")" : "(" + formattedCondition + ")");
        }

以下列表并排提供输入和搜索字符串(输出):

  • 对于“FRA”县代码和“1234”产品代码,格式化输出为 |FRA|1234|*|

  • 对于“1234”产品代码,格式化输出为 |*|1234|*|

  • 对于“C100”类别代码,格式化输出为 |*|*|C100|

只有在实施字符串数组解决方法时才提供通配符 (*)。 否则,如果使用复杂类型,筛选器可能如以下示例所示:

var countryFilter = $"searchScope/any(ss: search.in(countryCode ,'FRA'))";
var catgFilter = $"searchScope/any(ss: search.in(categoryCode ,'C100'))";
var combinedCountryCategoryFilter = "(" + countryFilter + " and " + catgFilter + ")";

如果实施解决方法,请务必进行广泛测试。

后续步骤

尝试在“导入数据”向导中练习 Hotels 数据集。 需要使用自述文件中提供的 Azure Cosmos DB 连接信息来访问这些数据。

获取该信息后,向导中的第一步是创建新的 Azure Cosmos DB 数据源。 在向导中到达目标索引页时,会看到具有复杂类型的索引。 请创建并加载此索引,然后执行查询来了解新结构。