文档和索引

Search API 提供了一个用于将包含结构化数据的文档编入索引的模型。之后,您可以搜索索引并整理和展示搜索结果。该 API 支持根据字符串字段进行全文匹配。而文档和索引保存在针对搜索操作进行优化的单独持久性存储中。Search API 可以将任意数量的文档编入索引。App Engine Datastore 可能更适合需要检索非常大的结果集的应用。

概览

Search API 涉及到四个主要概念:文档、索引、查询和结果。

文档

文档是具有唯一 ID 和一列包含用户数据的字段的对象。每个字段都具有名称和类型。字段类型有多种,由其包含的值的种类进行划分,具体如下所示:

  • Atom 字段 - 一个不可分割的字符串。
  • 文本字段 - 一个可以逐字搜索的纯文本字符串。
  • HTML 字段 - 一个包含 HTML 标记标签的字符串,只能搜索标记标签外部的文本。
  • 数字字段 - 一个浮点数。
  • 日期字段 - 日期对象。
  • 地理位置字段 - 具有纬度和经度坐标的数据对象。

文档的大小上限为 1 MB。

索引

索引存储用于检索的文档。您可以检索单个文档(按其 ID),检索一系列具有连续 ID 的文档,或者检索一个索引中的所有文档。此外,您还可以搜索索引,以检索满足特定字段及字段值标准(由查询字符串指定)的文档。要管理多组文档,您可以将它们放入多个单独的索引中。

索引中的文档数量或者您可以使用的索引数量无限制。单个索引中所有文档的总大小默认限制为 10GB,但通过从 Google Cloud Console 的 App Engine Search 页面提交请求,可能会增加到 200GB。

查询

要搜索索引,您可以构造一个查询,其中应包含一个查询字符串,可能还包含其他一些选项。查询字符串的作用是为一个或多个文档字段的值指定条件。当您搜索某个索引时,系统只会返回该索引中字段满足相应查询的文档。

最简单的查询(有时称为“全局搜索”)是一个仅包含字段值的字符串。以下搜索使用一个字符串来搜索包含字词“rose”和“water”的文档:

index.search("rose water");

以下查询搜索的是日期字段包含日期 1776 年 7 月 4 日或文本字段包含字符串“1776-07-04”的文档:

index.search("1776-07-04");

查询字符串也可以更具体。例如,它可以包含一个或多个字词,各对应一个字段并针对该字段值指定了限制条件。字词的确切形式取决于字段类型。例如,假设有一个名为“product”的文本字段和一个名为“price”的数字字段,包含这两个字词的查询字符串如下所示:

// search for documents with pianos that cost less than $5000
index.search("product = piano AND price < 5000");

顾名思义,查询选项不是必选项。但是,利用这些选项可以实现多种功能,例如:

  • 控制搜索结果返回的文档数。
  • 指定要包含在结果中的文档字段。默认设置是包含原始文档中的所有字段。但您可以让结果仅包含字段子集(原始文档不受影响)。
  • 对结果进行排序。
  • 使用 FieldExpressions 为文档创建“计算字段”,还可使用代码段创建删减的文本字段。
  • 让每个查询仅返回一部分匹配文档(使用偏移和游标),支持搜索结果分页功能。

搜索结果

search() 进行单次调用只能返回有限数量的匹配文档。您的搜索找到的文档可能要比单次调用中返回的文档多。每个搜索调用都会返回 Results 类的一个实例,其中包含有关找到的文档数和返回的文档数的信息,以及所返回文档的列表。您可以使用游标偏移重复执行同一个搜索,以检索匹配的全部文档。

其他培训资料

除了本文档之外,您还可以访问 Google Developer's Academy,阅读关于 Search API 的两段式培训课程。(虽然该课程使用 Python API,但您可以重点学习有关搜索概念的其他内容。)

文档和字段

Document 类表示文档。每个文档都有一个文档标识符和一个字段列表

文档标识符

索引中的每个文档都必须具有唯一的文档标识符 (doc_id)。标识符可用于从索引检索文档,而不执行搜索。默认情况下,Search API 会在创建文档时自动生成 doc_id。您还可以在创建文档时自行指定 doc_iddoc_id 只能包含可见且可打印的 ASCII 字符(ASCII 码值介于 33 到 126 之间,包括 33 和 126),且长度不得超过 500 个字符。文档标识符不能以感叹号(“!”)作为开头,且开头和结尾不能使用双下划线(“__”)。

虽然创建可读、有意义且唯一的文档标识符很方便,但您不能在搜索内容中包含 doc_id。假设有这样一个场景:您有一个索引,其中包含代表部件的文档,并使用部件的编号作为 doc_id。这样一来,检索文档以找出任何单个部件的效率会非常高,但是将无法搜索一系列序列号以及其他字段值(例如购买日期)。而将序列号存储在原子字段中可以解决此问题。

文档字段

文档包含的字段具有名称、类型,以及该类型对应的单个值。两个或更多字段可以具有相同名称,但类型不能相同。例如,您可以定义两个名为“age”的字段,一个为文本类型(值为“twenty-two”),另一个为数值类型(值为 22)。

字段名称

字段名称区分大小写,且只能包含 ASCII 字符。这些名称必须以字母开头,并且可以包含字母、数字或下划线。请注意,字段名称的长度不能超过 500 个字符。

多值字段

字段只能包含一个值,且该值必须与字段的类型相匹配。但是,字段名称不必是唯一的。因此,文档可以具有多个名称相同和类型相同的字段,以此表示具有多个值的字段。(请注意,同名的日期字段和数值字段不能重复使用。)此外,文档还可以包含多个名称相同但字段类型不同的字段。

字段类型

目前有三种用于存储 java.lang.String 字符串的字段,我们将其统称为字符串字段,具体包括:

  • 文本字段:最大长度为 1024**2 个字符的字符串。
  • HTML 字段:一个 HTML 格式字符串,长度最大为 1024**2 个字符。
  • Atom 字段:一个字符串,长度最大为 500 个字符。

另外还有三种用于存储非文本数据的字段,具体包括:

  • 数字字段:介于 -2147483647 和 2147483647 之间的双精度浮点值。
  • 日期字段:java.util.Date
  • 地理坐标点字段:由纬度和经度坐标描述的地球上的一个点。

使用 Field.FieldType 枚举 TEXTHTMLATOMNUMBERDATEGEO_POINT 指定字段类型。

字符串和日期字段的特殊处理

当包含日期、文本或 HTML 字段的文档添加到索引时,系统会进行一些特殊处理。为了有效地使用 Search API,了解“后台”发生了什么会很有帮助。

对字符串字段进行词法单元化处理

当 HTML 或文本字段被编入索引时,系统会对其内容进行词法单元化处理。即只要出现空格或特殊字符(标点符号、井号、反斜杠等),字符串就会被拆分为词法单元。对于每个词法单元,索引将包含一个相应条目。这样一来,您就可以搜索仅包含部分字段值的关键字和短语。例如,搜索“dark”会与文本字段包含字符串“it was a dark and stormy night”的文档匹配,而搜索“time”将与文本字段包含字符串“this is a real-time system”的文档匹配。

在 HTML 字段中,系统不会对标记标签中的文本进行词法单元化处理,因此 HTML 字段包含 it was a <strong>dark</strong> night 的文档将会与针对“night”的搜索匹配,而非与针对“strong”的搜索匹配。如果您希望能够搜索标记文本,请将其存储在文本字段中。

系统不会对 Atom 字段进行词法单元化处理。如果某文档中 Atom 字段的值为“bad weather”,则该文档将仅与针对整个字符串“bad weather”的搜索匹配,而不会与仅针对“bad”或“weather”的搜索匹配。

词法单元化处理规则
  • 下划线 (_) 以及“和”符号 (&) 字符不会将字词拆分为词法单元。

  • 下列空格字符始终会将字词拆分为词法单元:空格、回车符、换行符、水平制表符、垂直制表符、换页符和 NULL。

  • 下列字符被视为标点符号,因此会将字词拆分为词法单元:

    !"%()
    *,-|/
    []]^`
    :=>?@
    {}~$
  • 下表中的字符通常会将字词拆分为词法单元,但可以根据字符所在的上下文以不同的方式对其进行处理:

    字符 规则
    < 在 HTML 字段中,“小于”符号表示被忽略的 HTML 标记的开头。
    + 如果字词的末尾出现包含一个或多个“加号”符号的字符串 (C++),那么该字符串会被视为该字词的一部分。
    # 如果“井号”前面有 a、b、c、d、e、f、g、j 或 x(a# - g# 是音符,j# 和 x# 是编程语言,c# 既是音符也是编程语言),则该符号会被视为字词的一部分。如果某个字词前面附加了“#”(#google),则该字词会被视为 # 标签,并且井号会成为该字词的一部分
    ' 如果撇号在字母“s”前面,而字母“s”后跟分字点(如“John's hat”),则撇号会被视为一个字母。
    . 如果数字之间出现小数点,则小数点会被视为数字的一部分(即小数分隔符)。如果小数点用在首字母缩写词 (A.B.C) 中,则它也会被视为该字词的一部分。
    - 如果短划线用在首字母缩写词 (I-B-M) 中,则它会被视为字词的一部分。
  • 字母和数字(“A-Z”、“a-z”、“0-9”)之外的其他所有 7 位字符都会作为标点符号处理,并且会将字词拆分为词法单元。

  • 所有其他内容都会被解析为 UTF-8 字符。

首字母缩写词

词法单元化使用特殊规则来识别首字母缩写词(“I.B.M.”、“a-b-c”、“C I A”等字符串)。首字母缩写词是一串单字母字符,且各字符之间使用相同的分隔符。有效分隔符包括英文句点、短划线或任意数量的空格。当首字母缩写词被词法单元化后,分隔符会从字符串中移除。因此,上述示例字符串会变成“ibm”、“abc”和“cia”词法单元。但是,原始文本会保留在文档字段中。

处理首字母缩写词时,请注意以下几点:

  • 首字母缩写词所含的字母数不能超过 21 个。如果一个有效的首字母缩写词字符串包含的字母数超过 21 个,则该字符串将被拆分为一系列首字母缩写词,且每个首字母缩写词包含的字母数不超过 21 个。
  • 如果首字母缩写词中的字母是用空格分隔的,则所有字母必须采用相同的大小写形式。使用英文句点和短划线构造的首字母缩写词可以使用混合大小写的字母。
  • 搜索首字母缩写词时,您可以输入规范形式的首字母缩写词(即不含任何分隔符的字符串),或者输入以短划线或点(但不能同时使用这两者)作为标点来连接各字母的首字母缩写词。因此,检索文本“I.B.M”时,您可以使用“I-B-M”、“I.B.M”或“IBM”中的任何搜索字词。

日期字段准确性

在文档中创建日期字段时,可将该字段的值设置为 java.util.Date。为了将日期字段编入索引并进行搜索,任何时间部分都将被忽略,并且日期将转换为自世界协调时间 (UTC) 1970 年 1 月 1 日以来所经过的天数。也就是说,尽管日期字段可以包含精确的时间值,日期查询也只能以 yyyy-mm-dd 的格式指定日期字段值。这也意味着具有相同日期的日期字段的排序顺序没有明确定义。

其他文档属性

文档的排名是一个正整数,它确定了通过搜索返回的文档的默认顺序。默认情况下,当您创建文档时,系统会将文档的排名设置为自 2011 年 1 月 1 日以来所经过的秒数。您可以在创建文档时明确设置排名。建议您不要为多个文档指定相同的排名,而且绝不能为数量超过 10000 的文档提供相同的排名。如果指定排序选项,则可以将排名用作排序键。请注意,在排序表达式字段表达式中使用排名时,其引用格式为 _rank

语言区域属性可指定字段的编码语言。

如需详细了解这些特性 (Attribute),请参阅 Document 类参考页面。

从文档关联到其他资源

您可以将文档的 doc_id 和其他字段用作应用中其他资源的链接。例如,如果您使用 Blobstore,可以将文档与特定 Blob 相关联,只需将 doc_id 或 Atom 字段的值设置为数据的 BlobKey 即可。

创建文档

要创建文档,请使用 Document.newBuilder() 方法请求新的构建器。一旦应用有权访问构建器,它就可以指定可选的文档标识符并添加字段。

字段(如文档)是使用构建器创建的。Field.newBuilder() 方法会返回一个字段构建器,让您能够指定字段的名称和值。字段类型可通过选择特定的 set 方法而自动指定。例如,要指明字段包含纯文本,请调用 setText()。以下代码使用表示留言板问候语的字段构建一个文档。

User currentUser = UserServiceFactory.getUserService().getCurrentUser();
String userEmail = currentUser == null ? "" : currentUser.getEmail();
String userDomain = currentUser == null ? "" : currentUser.getAuthDomain();
String myDocId = "PA6-5000";
Document doc =
    Document.newBuilder()
        // Setting the document identifer is optional.
        // If omitted, the search service will create an identifier.
        .setId(myDocId)
        .addField(Field.newBuilder().setName("content").setText("the rain in spain"))
        .addField(Field.newBuilder().setName("email").setText(userEmail))
        .addField(Field.newBuilder().setName("domain").setAtom(userDomain))
        .addField(Field.newBuilder().setName("published").setDate(new Date()))
        .build();

要访问该文档中的字段,请使用 getOnlyField()

String coverLetter = document.getOnlyField("coverLetter").getText();
String resume = document.getOnlyField("resume").getHTML();
String fullName = document.getOnlyField("fullName").getAtom();
Date submissionDate = document.getOnlyField("submissionDate").getDate();

使用索引

将文档放入索引中

当您将某个文档放入索引时,系统会将该文档复制到永久性存储空间,并根据文档的名称、类型和 doc_id 将它的各个字段编入索引。

以下代码示例演示了如何访问索引并将文档放入索引中。具体步骤包括:

public static void indexADocument(String indexName, Document document)
    throws InterruptedException {
  IndexSpec indexSpec = IndexSpec.newBuilder().setName(indexName).build();
  Index index = SearchServiceFactory.getSearchService().getIndex(indexSpec);

  final int maxRetry = 3;
  int attempts = 0;
  int delay = 2;
  while (true) {
    try {
      index.put(document);
    } catch (PutException e) {
      if (StatusCode.TRANSIENT_ERROR.equals(e.getOperationResult().getCode())
          && ++attempts < maxRetry) { // retrying
        Thread.sleep(delay * 1000);
        delay *= 2; // easy exponential backoff
        continue;
      } else {
        throw e; // otherwise throw
      }
    }
    break;
  }
}
一次最多可以向 put() 方法传递 200 个文档。与一次添加一个文档相比,批量放置文档更高效。

当您将文档放入索引时,如果索引已包含具有相同 doc_id 的文档,则新文档将替换旧文档,系统不会发出警告。在创建文档或将文档添加到索引之前,可以调用 Index.get(id) 来检查特定 doc_id 是否已存在。

请注意,创建 Index 类的实例并不能保证永久性索引实际存在。永久性索引是在您首次使用 put 方法向其添加文档时创建的。如果要在开始使用索引之前检查索引是否实际存在,请使用 SearchService.getIndexes() 方法。

更新文档

一旦将文档添加到索引,就无法再更改文档。您既不能添加或移除字段,也不能更改字段的值。但是,您可以将文档替换为具有相同 doc_id 的新文档。

按 doc_id 检索文档

可通过以下两种方式使用文档标识符从索引中检索文档:
  • 使用 Index.get() 按文档的 doc_id 来提取单个文档。
  • 使用 Index.getRange() 检索按 doc_id 排序的一组连续文档。

以下示例演示了每个调用。

IndexSpec indexSpec = IndexSpec.newBuilder().setName(INDEX).build();
Index index = SearchServiceFactory.getSearchService().getIndex(indexSpec);

// Fetch a single document by its  doc_id
Document doc = index.get("AZ125");

// Fetch a range of documents by their doc_ids
GetResponse<Document> docs =
    index.getRange(GetRequest.newBuilder().setStartId("AZ125").setLimit(100).build());

按内容搜索文档

要从索引中检索文档,请构造查询字符串并调用 Index.search()。查询字符串可以作为参数直接传递,也可以包含在作为参数传递的 Query 对象中。默认情况下,search() 返回按降序排序的匹配文档。要控制返回文档的数量、文档的排序方式或将计算字段添加到结果中,您需要使用包含查询字符串的 Query 对象,还可以指定其他搜索和排序选项。

final int maxRetry = 3;
int attempts = 0;
int delay = 2;
while (true) {
  try {
    String queryString = "product = piano AND price < 5000";
    Results<ScoredDocument> results = getIndex().search(queryString);

    // Iterate over the documents in the results
    for (ScoredDocument document : results) {
      // handle results
      out.print("maker: " + document.getOnlyField("maker").getText());
      out.println(", price: " + document.getOnlyField("price").getNumber());
    }
  } catch (SearchException e) {
    if (StatusCode.TRANSIENT_ERROR.equals(e.getOperationResult().getCode())
        && ++attempts < maxRetry) {
      // retry
      try {
        Thread.sleep(delay * 1000);
      } catch (InterruptedException e1) {
        // ignore
      }
      delay *= 2; // easy exponential backoff
      continue;
    } else {
      throw e;
    }
  }
  break;
}

删除索引

每个索引都包含其索引文档和索引架构。要删除索引,请删除索引中的所有文档,然后删除索引架构。

您可以通过在 delete() 方法中指定要删除的一个或多个文档的 doc_id 来删除索引中的文档。建议您批量删除文档,这样做效率更高。一次最多可以向 delete() 方法传递 200 个文档 ID。

try {
  // looping because getRange by default returns up to 100 documents at a time
  while (true) {
    List<String> docIds = new ArrayList<>();
    // Return a set of doc_ids.
    GetRequest request = GetRequest.newBuilder().setReturningIdsOnly(true).build();
    GetResponse<Document> response = getIndex().getRange(request);
    if (response.getResults().isEmpty()) {
      break;
    }
    for (Document doc : response) {
      docIds.add(doc.getId());
    }
    getIndex().delete(docIds);
  }
} catch (RuntimeException e) {
  LOG.log(Level.SEVERE, "Failed to delete documents", e);
}
一次最多可以向 delete() 方法传递 200 个文档。与一次处理一个文档相比,批量删除文档更高效。

最终一致性

如果您在索引中添加、更新或删除了文档,则相应更改会传播至多个数据中心。更改通常会很快生效,但所需的时间有所不同。由于 Search API 会保证最终一致性,因此在某些情况下,如果您搜索或者检索一个或多个文档,则返回结果可能不会反映最近的更改。

确定索引的大小

索引存储用于检索的文档。您可以检索单个文档(按其 ID),检索一系列具有连续 ID 的文档,或者检索一个索引中的所有文档。此外,您还可以搜索索引,以检索满足特定字段及字段值标准(由查询字符串指定)的文档。要管理多组文档,您可以将它们放入多个单独的索引中。索引中的文档数量或者您可以使用的索引数量均无任何限制。单个索引中所有文档的总大小默认限制为 10GB,但通过从 Google Cloud Console 的 App Engine Search 页面提交请求,可能会增加到 200GB。Index.getStorageLimit() 方法返回允许的索引大小上限。

Index.getStorageUsage() 方法用于估算索引占用的存储空间大小。此数值是个估算值,因为索引监控系统不会持续运行;实际使用量是定期计算得出的。storage_usage 在采样点之间的调整考虑添加文档的情况,但不考虑删除文档的情况。

索引架构

每个索引都具有一个架构,用于显示索引所含文档中出现的所有字段名称和字段类型。但请注意,用户不能自行定义架构。相反,架构是动态维护的,比如将文档添加到索引时会更新架构。一个简单的架构可能如下所示(采用类似 JSON 的格式):

{'comment': ['TEXT'], 'date': ['DATE'], 'author': ['TEXT'], 'count': ['NUMBER']}

字典中的每个键都是文档字段的名称,而键的值是与该字段名称一起使用的字段类型的列表。如果您将相同的字段名称与不同的字段类型一起使用,则架构将为一个字段名称列出多个字段类型,如下所示:

{'ambiguous-integer': ['TEXT', 'NUMBER', 'ATOM']}

您无法移除显示在架构中的字段。即使索引不再包含具有该特定字段名称的任何文档,也无法删除字段。

您可以按如下所示查看索引的架构:

GetResponse<Index> response =
    SearchServiceFactory.getSearchService()
        .getIndexes(GetIndexesRequest.newBuilder().setSchemaFetched(true).build());

// List out elements of each Schema
for (Index index : response) {
  Schema schema = index.getSchema();
  for (String fieldName : schema.getFieldNames()) {
    List<FieldType> typesForField = schema.getFieldTypes(fieldName);
    // Just printing out the field names and types
    for (FieldType type : typesForField) {
      out.println(index.getName() + ":" + fieldName + ":" + type.name());
    }
  }
}
请注意,调用 GetIndexes() 所返回的索引不能超过 1000 个。要检索更多索引,请使用 setStartIndexName()GetIndexesRequest.Builder 重复调用该方法。

架构不会定义对象编程意义上的“类”。就 Search API 而言,每个文档都是唯一的,并且索引可以包含不同类型的文档。如果您要将具有相同字段列表的对象集合视为类的实例,就必须在代码中执行这种抽象。例如,您可以确保具有相同字段集的所有文档都保存在各自的索引中。索引架构可以视为类定义,而索引中的每个文档都是类的实例。

在 Google Cloud Console 中查看索引

在 Cloud Console 中,您可以查看有关应用索引及其所包含文档的信息。点击索引名称会显示索引包含的文档,您将看到为索引定义的所有架构字段。对于具有该名称字段的每个文档,您将看到该字段的值。此外,您还可以直接从 Console 发出对索引数据的查询。

Search API 配额

Search API 提供以下几项免费配额:

资源或 API 调用 免费配额
总存储空间(文档和索引) 0.25 GB
查询 每天 1000 次查询
将文档添加到索引中 每天 0.01 GB

Search API 设定这些限额是为了确保服务的可靠性。这些限额适用于免费应用和付费应用:

资源 安全配额
查询使用量上限 每分钟可提供共计 100 分钟的查询执行时间
添加或删除的文档数量上限 每分钟 15000 个
每个索引的大小上限(索引数量不限) 10 GB

API 用量的计算方式有所不同,具体取决于调用类型:

  • Index.search():每个 API 调用算作一个查询;执行时间相当于调用的延迟时间。
  • Index.put():向索引添加文档时,每个文档的大小和文档数量都会计入索引配额。
  • 其他所有 Search API 调用都根据它们涉及的操作数量进行计数:
    • SearchService.getIndexes():对于实际返回的每个索引,都算作 1 个操作;如果未返回任何索引,也算作 1 个操作。
    • Index.get()Index.getRange():对于实际返回的每个文档,都算作 1 个操作;如果未返回任何文档,也算作 1 个操作。
    • Index.delete():对于请求中包含的每个文档,算作 1 个操作;如果请求为空,也算作 1 个操作。

查询吞吐量设有配额,以免单个用户独占搜索服务。由于查询可以同时执行,因此允许每个应用运行查询,这些查询按 1 分钟时钟时间计算,最多可以使用 100 分钟执行时间。如果您运行许多简短查询,则可能达不到此限制。一旦超过配额,后续查询将失败,直到下一个时间片段,系统才会恢复您的配额。配额不是严格限制在一分钟时间片段内;系统会使用一个变体漏桶算法,以五秒钟为增量控制搜索带宽。

如需详细了解配额,请参阅配额页面。当应用尝试超过这些数量时,将返回配额不足错误。

请注意,虽然这些限额是按分钟实施的,但 Console 显示的是每个限额的每日总计值。拥有白银级、黄金级或白金级支持的客户可以联系支持代表来申请更高的吞吐量限额。

Search API 价格

以下费用适用于超出免费配额的使用量:

资源 费用
总存储空间(文档和索引) 每月每 GB $0.18
查询 每 10000 次查询 $0.50
为可搜索的文档编制索引 每 GB $2.00

如需详细了解价格,请参阅价格页面。