教程:为电话号码创建自定义分析器

在搜索解决方案中,具有复杂模式或特殊字符的字符串可能很难处理,因为默认分析器会去除或误解模式中有意义的部分,导致在用户找不到所期望的信息时搜索体验不佳。 电话号码是难以分析的字符串的经典示例。 它们采用各种格式,并且包含默认分析器会忽略的特殊字符。

本教程以电话号码为主题,仔细研究模式数据的问题,并演示如何使用自定义分析器解决此问题。 这里概述的方法可以按原样用于电话号码,也可改编用于具有相同特征的字段(模式数据,具有特殊字符),例如 URL、电子邮件、邮政编码和日期。

在本教程中,使用 REST 客户端和 Azure AI 搜索 REST API 来执行以下操作:

  • 了解问题
  • 开发用于处理电话号码的初始自定义分析器
  • 测试自定义分析器
  • 反复修改自定义分析器设计来进一步改进结果

先决条件

本教程需要以下服务和工具。

下载文件

本教程的源代码位于 Azure-Samples/azure-search-rest-samples GitHub 存储库中的 custom-analyzer.rest 文件夹中。

复制密钥和 URL

本教程中的 REST 调用需要搜索服务终结点和管理员 API 密钥。 可以从 Azure 门户获取这些值。

  1. 登录到 Azure 门户,导航到概述页,并复制 URL。 示例终结点可能类似于 https://mydemo.search.azure.cn

  2. 在“设置”>“密钥”下,复制管理密钥。 管理密钥用于添加、修改和删除对象。 有两个可互换的管理密钥。 复制其中任意一个。

    Azure 门户中 URL 和 API 密钥的屏幕截图。

具有有效的 API 密钥可以在发送请求的应用程序与处理请求的搜索服务之间建立信任关系,这种信任关系以每个请求为基础。

创建初始索引

  1. 在 Visual Studio Code 中打开新的文本文件。

  2. 将变量设置为在上一个步骤中收集的搜索终结点和 API 密钥。

    @baseUrl = PUT-YOUR-SEARCH-SERVICE-URL-HERE
    @apiKey = PUT-YOUR-ADMIN-API-KEY-HERE
    
  3. 使用 .rest 文件扩展名保存文件。

  4. 粘贴以下示例,创建名为 phone-numbers-index 且具有两个字段(idphone_number)的小索引。 我们尚未定义分析器,因此默认使用 standard.lucene 分析器。

    ### Create a new index
    POST {{baseUrl}}/indexes?api-version=2024-07-01  HTTP/1.1
      Content-Type: application/json
      api-key: {{apiKey}}
    
      {
        "name": "phone-numbers-index",  
        "fields": [
          {
            "name": "id",
            "type": "Edm.String",
            "key": true,
            "searchable": true,
            "filterable": false,
            "facetable": false,
            "sortable": true
          },
          {
            "name": "phone_number",
            "type": "Edm.String",
            "sortable": false,
            "searchable": true,
            "filterable": false,
            "facetable": false
          }
        ]
      }
    
  5. 选择“发送请求”。 你应该会有一个 HTTP/1.1 201 Created 响应,并且响应正文应该会包含索引架构的 JSON 表示形式。

  6. 使用包含各种电话号码格式的文档将数据加载到索引中。 这是测试数据。

    ### Load documents
    POST {{baseUrl}}/indexes/phone-numbers-index/docs/index?api-version=2024-07-01  HTTP/1.1
      Content-Type: application/json
      api-key: {{apiKey}}
    
      {
        "value": [
          {
            "@search.action": "upload",  
            "id": "1",
            "phone_number": "425-555-0100"
          },
          {
            "@search.action": "upload",  
            "id": "2",
            "phone_number": "(321) 555-0199"
          },
          {  
            "@search.action": "upload",  
            "id": "3",
            "phone_number": "+1 425-555-0100"
          },
          {  
            "@search.action": "upload",  
            "id": "4",  
            "phone_number": "+1 (321) 555-0199"
          },
          {
            "@search.action": "upload",  
            "id": "5",
            "phone_number": "4255550100"
          },
          {
            "@search.action": "upload",  
            "id": "6",
            "phone_number": "13215550199"
          },
          {
            "@search.action": "upload",  
            "id": "7",
            "phone_number": "425 555 0100"
          },
          {
            "@search.action": "upload",  
            "id": "8",
            "phone_number": "321.555.0199"
          }
        ]  
      }
    
  7. 让我们尝试一些与用户可能键入的内容类似的查询。 用户可以按任何数量的格式搜索 (425) 555-0100,并且仍然期望返回结果。 首先,搜索 (425) 555-0100

    ### Search for a phone number
    GET {{baseUrl}}/indexes/phone-numbers-index/docs/search?api-version=2024-07-01&search=(425) 555-0100  HTTP/1.1
      Content-Type: application/json
      api-key: {{apiKey}}
    

    此查询返回四个预期结果中的三个结果,同时还返回两个意外结果:

    {
        "value": [
            {
                "@search.score": 0.05634898,
                "phone_number": "+1 425-555-0100"
            },
            {
                "@search.score": 0.05634898,
                "phone_number": "425 555 0100"
            },
            {
                "@search.score": 0.05634898,
                "phone_number": "425-555-0100"
            },
            {
                "@search.score": 0.020766128,
                "phone_number": "(321) 555-0199"
            },
            {
                "@search.score": 0.020766128,
                "phone_number": "+1 (321) 555-0199"
            }
        ]
    }
    
  8. 让我们不带任何格式再试一次:4255550100

     ### Search for a phone number
     GET {{baseUrl}}/indexes/phone-numbers-index/docs/search?api-version=2024-07-01&search=4255550100  HTTP/1.1
       Content-Type: application/json
       api-key: {{apiKey}}
    

    此查询效果更糟,只返回四个正确匹配项中的一个。

    {
        "value": [
            {
                "@search.score": 0.6015292,
                "phone_number": "4255550100"
            }
        ]
    }
    

对于出现这些结果,不止你一个人会感觉到困惑。 在下一部分中,深入探讨为什么会得到这些结果。

查看分析器的工作方式

若要了解这些搜索结果,需要了解分析器正在执行的操作。 然后,可以使用分析 API 测试默认分析器,为设计可更好满足我们的需求的分析器提供基础。

分析器全文搜索引擎的组成部分,负责在查询字符串和带索引文档中进行文本处理。 不同的分析器根据具体的方案以不同的方式处理文本。 对于此场景,我们需要构建一个专为电话号码定制的分析器。

分析器包含三个组件:

  • 字符筛选器,用于从输入文本中删除或替换单个字符。
  • Tokenizer,用于将输入文本分解为标记,这些标记将成为输入索引中的密钥。
  • 标记筛选器,用于处理 tokenizer 生成的标记。

在下图中,可以看到这三个组件如何一起工作来切分一个句子:

用于标记句子的分析器进程示意图

这些标记随后被存储在倒排索引中,以便进行快速的全文搜索。 倒排索引通过将在词法分析过程中提取的所有唯一词映射到它们所在的文档来实现全文搜索。 下图提供了一个示例:

倒排索引示例

所有搜索均可归结为搜索存储在倒排索引中的字词。 用户发出查询时:

  1. 解析查询并分析查询词。
  2. 然后扫描倒排索引以查找具有匹配词的文档。
  3. 最后,根据评分算法对检索到的文档进行排序。

用于对相似性排名的分析器进程示意图

如果查询词与倒排索引中的词不匹配,则不会返回结果。 若要详细了解查询的工作方式,请参阅这篇关于全文搜索的文章。

注意

对于此规则,部分字词查询是一个重要的例外情况。 与常规字词查询不同,这些查询(前缀查询、通配符查询、正则表达式查询)绕过了词法分析过程。 部分字词在与索引中的字词匹配之前只使用小写。 如果分析器未配置为支持这些类型的查询,你通常会收到意外的结果,因为索引中不存在匹配的字词。

使用分析 API 来测试分析器

Azure AI 搜索提供了一个分析 API,通过它可测试分析器来了解其处理文本的方式。

使用以下请求调用分析 API:

POST {{baseUrl}}/indexes/phone-numbers-index/analyze?api-version=2024-07-01  HTTP/1.1
  Content-Type: application/json
  api-key: {{apiKey}}

  {
    "text": "(425) 555-0100",
    "analyzer": "standard.lucene"
  }

API 使用指定的分析器返回从文本中提取的标记。 标准 Lucene 分析器将电话号码拆分成了三个单独的标记:

{
    "tokens": [
        {
            "token": "425",
            "startOffset": 1,
            "endOffset": 4,
            "position": 0
        },
        {
            "token": "555",
            "startOffset": 6,
            "endOffset": 9,
            "position": 1
        },
        {
            "token": "0100",
            "startOffset": 10,
            "endOffset": 14,
            "position": 2
        }
    ]
}

相反,格式化为没有任何标点符号的电话号码 4255550100 被切分为单个标记。

{
  "text": "4255550100",
  "analyzer": "standard.lucene"
}

响应:

{
    "tokens": [
        {
            "token": "4255550100",
            "startOffset": 0,
            "endOffset": 10,
            "position": 0
        }
    ]
}

请记住,查询词和索引文档都要进行分析。 回顾上一步的搜索结果,我们可以开始了解为什么会返回这些结果。

在第一个查询中,返回了意外的电话号码,因为其中一个标记 555 与我们搜索的一个词匹配。 在第二个查询中,只返回了一个号码,因为它是唯一具有与 4255550100 匹配的标记的记录。

生成自定义分析器

现在我们理解了我们所看到的结果,接着我们生成一个自定义分析器来改善词汇切分逻辑。

目的是提供对电话号码的直观搜索,无论查询或索引字符串采用哪种格式。 为了实现此结果,我们将指定字符筛选器分词器标记筛选器

字符筛选器

字符筛选器用于在将文本馈送到 tokenizer 之前对其进行处理。 字符筛选器的常见用途包括筛选出 HTML 元素或替换特殊字符。

对于电话号码,我们希望删除空格和特殊字符,因为并非所有电话号码格式都包含相同的特殊字符和空格。

"charFilters": [
    {
      "@odata.type": "#Microsoft.Azure.Search.MappingCharFilter",
      "name": "phone_char_mapping",
      "mappings": [
        "-=>",
        "(=>",
        ")=>",
        "+=>",
        ".=>",
        "\\u0020=>"
      ]
    }
  ]

筛选器从输入中删除 - ( ) + . 和空格。

输入 输出
(321) 555-0199 3215550199
321.555.0199 3215550199

Tokenizer

Tokenizer 将文本拆分为标记,并在此过程中丢弃某些字符,如标点符号。 在许多情况下,词汇切分的目标是将句子拆分为单个单词。

对于此场景,我们使用关键字 tokenizer (keyword_v2),因为我们想要将电话号码捕获为单个词。 请注意,这并不是解决此问题的唯一方法。 请参阅下面的备用方法部分。

关键字 tokenizer 始终将收到的相同文本作为单个词输出。

输入 输出
The dog swims. [The dog swims.]
3215550199 [3215550199]

标记筛选器

标记筛选器将筛选出或修改由 tokenizer 生成的标记。 标记筛选器的一种常见用法是使用小写标记筛选器将所有字符小写。 另一种常见用法是筛选出非索引字,例如 theandis

在此场景中我们不需要使用上述任何一种筛选器,而将使用 nGram 标记筛选器以允许对电话号码进行部分搜索。

"tokenFilters": [
  {
    "@odata.type": "#Microsoft.Azure.Search.NGramTokenFilterV2",
    "name": "custom_ngram_filter",
    "minGram": 3,
    "maxGram": 20
  }
]

NGramTokenFilterV2

nGram_v2 标记筛选器根据 minGrammaxGram 参数将标记拆分为给定大小的 n 元语法。

对于电话分析器,我们将 minGram 设置为 3,因为这是我们希望用户搜索的最短子字符串。 将 maxGram 设置为 20,确保所有电话号码(即使是分机号)都可放入单个 n 元语法中。

令人遗憾的是,n 元语法的副作用是会返回一些误报。 我们将在稍后步骤中解决此问题,方法是为不包含 n 元语法标记筛选器的搜索构建一个单独的分析器。

输入 输出
[12345] [123, 1234, 12345, 234, 2345, 345]
[3215550199] [321, 3215, 32155, 321555, 3215550, 32155501, 321555019, 3215550199, 215, 2155, 21555, 215550, ... ]

分析器

有了字符筛选器、tokenizer 和标记筛选器,我们就可以定义分析器了。

"analyzers": [
  {
    "@odata.type": "#Microsoft.Azure.Search.CustomAnalyzer",
    "name": "phone_analyzer",
    "tokenizer": "keyword_v2",
    "tokenFilters": [
      "custom_ngram_filter"
    ],
    "charFilters": [
      "phone_char_mapping"
    ]
  }
]

在分析 API 中,鉴于以下输入,自定义分析器的输出如下表所示。

输入 输出
12345 [123, 1234, 12345, 234, 2345, 345]
(321) 555-0199 [321, 3215, 32155, 321555, 3215550, 32155501, 321555019, 3215550199, 215, 2155, 21555, 215550, ... ]

输出列中的所有标记都存在于索引中。 如果查询包含这些词中的任何一个,则返回电话号码。

使用新分析器重新生成

  1. 删除当前索引:

     ### Delete the index
     DELETE {{baseUrl}}/indexes/phone-numbers-index?api-version=2024-07-01 HTTP/1.1
         api-key: {{apiKey}}
    
  2. 使用新的分析器重新创建索引。 此索引架构在电话号码字段中添加自定义分析器定义和自定义分析器分配。

    ### Create a new index
    POST {{baseUrl}}/indexes?api-version=2024-07-01  HTTP/1.1
      Content-Type: application/json
      api-key: {{apiKey}}
    
    {
        "name": "phone-numbers-index-2",  
        "fields": [
          {
              "name": "id",
              "type": "Edm.String",
              "key": true,
              "searchable": true,
              "filterable": false,
              "facetable": false,
              "sortable": true
          },
          {
              "name": "phone_number",
              "type": "Edm.String",
              "sortable": false,
              "searchable": true,
              "filterable": false,
              "facetable": false,
              "analyzer": "phone_analyzer"
          }
        ],
        "analyzers": [
            {
              "@odata.type": "#Microsoft.Azure.Search.CustomAnalyzer",
              "name": "phone_analyzer",
              "tokenizer": "keyword_v2",
              "tokenFilters": [
              "custom_ngram_filter"
            ],
            "charFilters": [
              "phone_char_mapping"
              ]
            }
          ],
          "charFilters": [
            {
              "@odata.type": "#Microsoft.Azure.Search.MappingCharFilter",
              "name": "phone_char_mapping",
              "mappings": [
                "-=>",
                "(=>",
                ")=>",
                "+=>",
                ".=>",
                "\\u0020=>"
              ]
            }
          ],
          "tokenFilters": [
            {
              "@odata.type": "#Microsoft.Azure.Search.NGramTokenFilterV2",
              "name": "custom_ngram_filter",
              "minGram": 3,
              "maxGram": 20
            }
          ]
        }
    

测试自定义分析器

重新创建索引后,现在可使用以下请求测试分析器:

POST {{baseUrl}}/indexes/tutorial-first-analyzer/analyze?api-version=2024-07-01  HTTP/1.1
  Content-Type: application/json
  api-key: {{apiKey}} 

  {
    "text": "+1 (321) 555-0199",
    "analyzer": "phone_analyzer"
  }

现在,应该会看到由电话号码生成的标记集合:

{
    "tokens": [
        {
            "token": "132",
            "startOffset": 1,
            "endOffset": 17,
            "position": 0
        },
        {
            "token": "1321",
            "startOffset": 1,
            "endOffset": 17,
            "position": 0
        },
        {
            "token": "13215",
            "startOffset": 1,
            "endOffset": 17,
            "position": 0
        },
        ...
        ...
        ...
    ]
}

修改自定义分析器来处理误报情况

使用自定义分析器对索引进行一些示例查询后,你会发现召回率有所改进,并且所有匹配的电话号码现在均已返回。 但是,n 元语法标记筛选器也会返回一些误报。 这是 n 元语法标记筛选器的常见副作用。

为了防止误报,我们将创建一个单独的分析器用于查询。 此分析器与上一个分析器相同,只不过它省略 custom_ngram_filter

    {
      "@odata.type": "#Microsoft.Azure.Search.CustomAnalyzer",
      "name": "phone_analyzer_search",
      "tokenizer": "custom_tokenizer_phone",
      "tokenFilters": [],
      "charFilters": [
        "phone_char_mapping"
      ]
    }

然后,在索引定义中,同时指定 indexAnalyzersearchAnalyzer

    {
      "name": "phone_number",
      "type": "Edm.String",
      "sortable": false,
      "searchable": true,
      "filterable": false,
      "facetable": false,
      "indexAnalyzer": "phone_analyzer",
      "searchAnalyzer": "phone_analyzer_search"
    }

进行该更改后,一切准备就绪。 后续步骤如下:

  1. 删除索引。

  2. 在添加新的自定义分析器 (phone_analyzer-search) 并将该分析器分配给 phone-number 字段的 searchAnalyzer 属性后,重新创建索引。

  3. 重新加载数据。

  4. 重新测试查询来验证搜索是否按预期工作。 如果使用示例文件,此步骤将创建名为 phone-number-index-3 的第三个索引。

备用方法

上一部分所述的分析器旨在最大程度地提高搜索的灵活性。 但这样做的代价是,在索引中存储许多可能无关紧要的字词。

以下示例演示了一个替代分析器,该分析器在词汇切分方面效率更高,但存在缺点。

给定 14255550100 的输入,分析器无法以逻辑方式对电话号码进行分块。 例如,它无法将国家/地区代码 1 与区号 425 分开。 如果用户在搜索中未包含国家/地区代码,这种差异将导致不返回电话号码。

"analyzers": [
  {
    "@odata.type": "#Microsoft.Azure.Search.CustomAnalyzer",
    "name": "phone_analyzer_shingles",
    "tokenizer": "custom_tokenizer_phone",
    "tokenFilters": [
      "custom_shingle_filter"
    ]
  }
],
"tokenizers": [
  {
    "@odata.type": "#Microsoft.Azure.Search.StandardTokenizerV2",
    "name": "custom_tokenizer_phone",
    "maxTokenLength": 4
  }
],
"tokenFilters": [
  {
    "@odata.type": "#Microsoft.Azure.Search.ShingleTokenFilter",
    "name": "custom_shingle_filter",
    "minShingleSize": 2,
    "maxShingleSize": 6,
    "tokenSeparator": ""
  }
]

在下面的示例中,你可以看到电话号码被拆分为你通常希望用户搜索的区块。

输入 输出
(321) 555-0199 [321, 555, 0199, 321555, 5550199, 3215550199]

根据你的需求,这可能是解决问题的更有效的方法。

要点

本教程演示了生成和测试自定义分析器的过程。 你已创建了索引、为数据编制了索引,然后针对索引进行了查询以查看返回的搜索结果。 然后,你使用了分析 API 来查看实际的词法分析过程。

本教程中定义的分析器为搜索电话号码提供了一种简单的解决方案,但可以使用这个相同的过程来为共享类似特征的任何场景构建自定义分析器。

清理资源

在自己的订阅中操作时,最好在项目结束时删除不再需要的资源。 持续运行资源可能会产生费用。 可以逐个删除资源,也可以删除资源组以删除整个资源集。

可以使用左侧导航窗格中的“所有资源”或“资源组”链接 ,在门户中查找和管理资源。

后续步骤

现在,你已经熟悉了如何创建自定义分析器,接下来让我们看看可用于构建丰富的搜索体验的所有不同筛选器、tokenizer 和分析器。