高级搜索开发指南
本页包含有关开发和使用由Elasticsearch驱动的Advanced search的信息。
有关如何启用Advanced search并执行初始索引的信息在Elasticsearch集成文档中。
深入资源
这些录音和演示提供了关于高级搜索实现的深入知识:
| 日期 | 主题 | 演讲者 | 资源 | GitLab 版本 |
|---|---|---|---|---|
| 2024年7月 | 高级搜索基础、集成、索引和搜索 | Terri Chu | YouTube上的录制(仅限GitLab团队成员) Google幻灯片(仅限GitLab团队成员) |
GitLab 17.0 |
| 2021年6月 | GitLab的高级搜索数据迁移过程 | Dmitry Gruzd | 博客文章 | GitLab 13.12 |
| 2020年8月 | GitLab特有的多索引支持架构 | Mark Chao | YouTube上的录制 Google幻灯片 |
GitLab 13.3 |
| 2019年6月 | GitLab Elasticsearch集成 | Mario de la Ossa | YouTube上的录制 Google幻灯片 |
GitLab 12.0 |
Elasticsearch配置
支持的版本
参见版本要求。
对Elasticsearch查询做出重大更改的开发人员应针对我们支持的所有版本测试其功能。
设置开发环境
-
curl "http://localhost:9200"
-
要跟踪Elasticsearch日志,请运行以下命令:
tail -f log/elasticsearch.log
有用的Rake任务
gitlab:elastic:test:index_size:告诉你当前索引使用了多少空间,以及索引中有多少文档。gitlab:elastic:test:index_size_change:输出索引大小,重新索引,再次输出索引大小。在测试索引大小改进时很有用。
此外,如果您需要大型仓库或多份分支用于测试,请考虑遵循这些说明(rake_tasks.md#extra-project-seed-options)
开发工作流
开发提示
调试与故障排除
调试Elasticsearch查询
ELASTIC_CLIENT_DEBUG 环境变量启用了Elasticsearch客户端的调试选项(参考Elasticsearch客户端调试选项),可在开发或测试环境中启用。如果需要调试代码或测试生成的Elasticsearch HTTP查询,可在运行测试套件或启动Rails控制台前开启:
ELASTIC_CLIENT_DEBUG=1 bundle exec rspec ee/spec/workers/search/elastic/trigger_indexing_worker_spec.rb
export ELASTIC_CLIENT_DEBUG=1
rails console
遇到 flood stage disk watermark [95%] exceeded 错误
您可能会遇到类似以下错误:
[2018-10-31T15:54:19,762][WARN ][o.e.c.r.a.DiskThresholdMonitor] [pval5Ct]
flood stage disk watermark [95%] exceeded on
[pval5Ct7SieH90t5MykM5w][pval5Ct][/usr/local/var/lib/elasticsearch/nodes/0] free: 56.2gb[3%],
all indices on this node will be marked read-only这是因为您超过了磁盘空间阈值——基于默认95%的阈值,系统认为剩余磁盘空间不足。
此外,read_only_allow_delete 设置会被设为 true,这将阻止索引、强制合并等操作:
curl "http://localhost:9200/gitlab-development/_settings?pretty"在 elasticsearch.yml 文件中添加以下内容:
# 关闭磁盘分配器
cluster.routing.allocation.disk.threshold_enabled: false或者:
# 自定义限制
cluster.routing.allocation.disk.threshold_enabled: true
cluster.routing.allocation.disk.watermark.flood_stage: 5gb # 仅适用于ES 6.x
cluster.routing.allocation.disk.watermark.low: 15gb
cluster.routing.allocation.disk.watermark.high: 10gb重启Elasticsearch后,read_only_allow_delete 会自动清除。
摘自 “Disk-based Shard Allocation | Elasticsearch Reference” 5.6 和 6.x
性能监控
Prometheus
GitLab导出与所有Web/API请求和Sidekiq作业的请求数量及时间相关的Prometheus指标,这有助于诊断性能趋势并比较Elasticsearch耗时对整体性能的影响。
索引队列
GitLab还导出Prometheus指标用于索引队列,可帮助诊断性能瓶颈,判断您的GitLab实例或Elasticsearch服务器能否跟上更新量。
日志
所有索引操作都在Sidekiq中进行,因此Elasticsearch集成的相关日志大多可在 sidekiq.log 中找到。特别是,所有以任何方式向Elasticsearch发送请求的Sidekiq worker都会记录查询/写入Elasticsearch的请求数量和耗时。这有助于了解集群是否能跟上索引速度。
通过普通web worker处理搜索请求。任何加载页面或发起API请求的请求(随后会向Elasticsearch发送请求),都会在 production_json.log 中记录请求数量和耗时。这些日志还会包含数据库和Gitaly请求的耗时,有助于诊断搜索哪部分性能不佳。
还有特定于Elasticsearch的其他日志被发送至 elasticsearch.log,其中可能包含帮助诊断性能问题的信息。
性能栏
Elasticsearch请求会显示在性能栏中,该功能可用于本地开发环境以及任何已部署的GitLab实例来诊断搜索性能问题。它会显示正在执行的精确查询,有助于诊断为何搜索可能变慢。
关联ID与X-Opaque-Id
我们的关联ID会被所有从Rails到Elasticsearch的请求转发,作为X-Opaque-Id头信息,这让我们能够追踪集群中的任务与GitLab中的请求之间的关联。
架构
用于与Elasticsearch通信的框架正在进行重构,相关史诗(epic)可在此处查看。
索引概述
高级搜索会选择性索引数据。每种数据类型遵循特定的索引流程:
| 数据类型 | 如何排队 | 排队位置 | 索引发生在何处 |
|---|---|---|---|
| 数据库记录 | 通过ActiveRecord回调和Gitlab::EventStore记录变更 |
Redis ZSET | ElasticIndexInitialBulkCronWorker、ElasticIndexBulkCronWorker |
| Git仓库数据 | 分支推送服务和默认分支变更worker | Sidekiq | Search::Elastic::CommitIndexerWorker、ElasticWikiIndexerWorker |
| 嵌入式数据 | 通过ActiveRecord回调和Gitlab::EventStore记录变更 |
Redis ZSET | ElasticEmbeddingBulkCronWorker |
索引组件
外部索引器
对于仓库内容,GitLab使用一个专门的用Go编写的索引器(gitlab-elasticsearch-indexer)来高效处理文件。
Rails索引生命周期
- 初始索引:管理员通过管理界面或Rake任务触发首次完整索引
- 持续更新:初始设置完成后,GitLab通过以下方式维护索引的新鲜度:
- 模型回调(如
after_create、after_update、after_destroy),定义于[/ee/app/models/concerns/elastic/application_versioned_search.rb] - 一个Redis [ZSET],跟踪所有待处理的变更
- 定期运行的Sidekiq worker,使用Elasticsearch的Bulk Request API批量处理这些队列
- 模型回调(如
搜索与安全
查询构建器框架 生成搜索查询并处理访问控制逻辑。这部分代码库在开发和代码审查时需要特别关注,因为历史上它一直是安全漏洞的来源。
返回搜索结果的最后一步是对当前用户隐藏未授权结果,以捕获查询问题或竞态条件。
迁移框架
GitLab 高级搜索包含一个强大的迁移框架,可简化索引维护和更新。该系统提供显著优势:
- 选择性重新索引:仅在需要时更新特定文档类型,避免全量重新索引
- 自动化维护:无需人工干预即可执行更新
- 一致体验:为 GitLab.com 和 GitLab 自托管实例提供相同的迁移路径
框架组件
迁移系统由以下部分组成:
- 迁移运行器:一个每5分钟执行的cron worker,用于检查和处理待处理的迁移。
- 迁移文件:类似于数据库迁移,这些 Ruby 文件定义了迁移步骤及配套的 YAML 文档
- 迁移状态跟踪:所有迁移状态存储在一个专门的 Elasticsearch 索引中
- 迁移生命周期状态:每个迁移经历以下阶段:待处理 → 进行中 → 完成(若出现问题则暂停)
配置选项
可通过多种参数微调迁移:
- 分批处理:控制文档批次大小以优化性能
- 限流:调整索引速度以平衡迁移速度和系统负载
- 空间要求:迁移开始前验证磁盘空间是否充足,防止中断
- 跳过条件:定义跳过迁移的条件
此框架使索引模式变更、字段更新和数据迁移对所有 GitLab 安装都可靠且无干扰。
搜索 DSL
本节介绍 GitLab 支持的搜索 DSL(领域特定语言),兼容 Elasticsearch 和 OpenSearch 实现。
自定义路由
Elasticsearch 中的自定义路由 用于文档类型。路由格式通常为 project_<project_id>(项目关联数据)和 group_<root_namespace_id>(群组关联数据)。路由在索引和搜索操作中设置,告诉 Elasticsearch 将数据放入哪些分片。使用自定义路由的一些优势和权衡包括:
- 项目和群组范围的搜索更快,因为不必命中所有分片。
- 若全局和群组范围搜索会命中过多分片,则不使用路由。
- 可能出现分片大小不平衡。
现有分析器和分词器
以下分析器和分词器在 ee/lib/elastic/latest/config.rb 中定义。
分析器
path_analyzer
用于索引 blob 路径时。使用 path_tokenizer 以及 lowercase 和 asciifolding 过滤器。
请参阅下文 path_tokenizer 说明示例。
sha_analyzer
用于 blob 和提交。使用 sha_tokenizer 以及 lowercase 和 asciifolding 过滤器。
请参阅下文 sha_tokenizer 说明示例。
code_analyzer
用于索引 blob 的文件名和内容。使用 whitespace 分词器以及 word_delimiter_graph、lowercase 和 asciifolding 过滤器。
选择 whitespace 分词器是为了更好地控制令牌拆分方式。例如,字符串 Foo::bar(4) 需要生成 Foo 和 bar(4) 这样的令牌才能正确搜索。
请参阅 code 过滤器的说明了解令牌如何拆分。
分词器
sha_tokenizer
这是一个自定义分词器,使用 边n元分词器(edgeNGram tokenizer) 允许通过 SHA 的任何子集进行搜索(最小长度为5个字符)。
示例:
240c29dc7e 变为:
240c2240c29240c29d240c29dc240c29dc7240c29dc7e
`path_tokenizer`
这是一个自定义分词器,它使用带有 `reverse: true` 的 [`path_hierarchy` 分词器](https://www.elastic.co/guide/en/elasticsearch/reference/5.5/analysis-pathhierarchy-tokenizer.html),以允许搜索在输入路径或多或少的情况下都能找到路径。
示例:
`'/some/path/application.js'` 变成:
- `'/some/path/application.js'`
- `'some/path/application.js'`
- `'path/application.js'`
- `'application.js'`
#### 常见陷阱
- 搜索可以有自己的分析器。记得在编辑分析器时进行检查。
- `Character` 过滤器(与标记过滤器相反)总是替换原始字符。这些过滤器可能会阻碍精确搜索。
## 实施指南
### 将新文档类型添加到 Elasticsearch
如果数据无法添加到 Elasticsearch 中现有的某个[索引](../integration/advanced_search/elasticsearch.md#advanced-search-index-scopes),请按照以下说明设置新索引并填充数据。
#### 添加新文档类型的推荐流程
让任何合并请求(MR)由全局搜索团队成员审查:
1. [设置开发环境](#setting-up-your-development-environment)
1. [创建索引](#create-the-index)。
1. [验证预期查询](#validate-expected-queries)
1. [创建新的 Elasticsearch 引用](#create-a-new-elastic-reference)。
1. 在功能标志后执行[持续更新](#continuous-updates)。在回填之前完全启用该标志。
1. [回填数据](#backfilling-data)。
完成索引后,该索引即可用于搜索。
#### 创建索引
所有新索引必须包含:
- `project_id` 和 `namespace_id` 字段(如果可用)。其中一个字段必须用于[自定义路由](#custom-routing)。
- 用于高效的全局和组搜索的 `traversal_ids` 字段。使用 `object.namespace.elastic_namespace_ancestry` 填充该字段
- 授权字段:
- 项目数据 - `visibility_level`
- 组数据 - `namespace_visibility_level`
- 任何必需的访问级别字段。这些对应于项目功能访问级别,例如 `issues_access_level` 或 `repository_access_level`
- 一个 `schema_version` 整数字段,格式为 `YYWW`(年/周)。此字段用于数据迁移。
1. 在 `ee/lib/search/elastic/types/` 中创建一个 `Search::Elastic::Types::` 类。
1. 定义以下类方法:
- `index_name`:格式为 `gitlab-<env>-<type>`(例如 `gitlab-production-work_items`)。
- `mappings`:包含索引模式的哈希,例如字段、数据类型和分析器。
- `settings`:包含索引设置的哈希,例如副本数和分词器。默认值对大多数情况足够好。
1. 添加一个新的[高级搜索迁移](search/advanced_search_migration_styleguide.md),通过执行 `scripts/elastic-migration` 并遵循说明来创建索引。迁移名称必须采用 `Create<Name>Index` 格式。
1. 使用 [`Search::Elastic::MigrationCreateIndexHelper`](search/advanced_search_migration_styleguide.md#searchelasticmigrationcreateindexhelper) 助手和 `'migration creates a new index'` 共享示例,为创建的规范文件编写内容。
1. 将目标类添加到 `Gitlab::Elastic::Helper::ES_SEPARATE_CLASSES`。
1. 要测试索引创建,请在控制台中运行 `Elastic::MigrationWorker.new.perform`,并检查索引是否已使用正确的映射和设置创建:
```shell
curl "http://localhost:9200/gitlab-development-<type>/_mappings" | jq .`curl "http://localhost:9200/gitlab-development-<type>/_settings" | jq .`
##### PostgreSQL 到 Elasticsearch 的映射
主键和外键的数据类型必须与数据库中列的类型一致。例如,数据库列类型 `integer` 映射到 `integer`,而 `bigint` 在映射中映射到 `long`。
嵌套字段 会引入显著的额外开销。建议改用扁平化的多值方法。
| PostgreSQL 类型 | Elasticsearch 映射 |
|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| bigint | long |
| smallint | short |
| integer | integer |
| boolean | boolean |
| array | keyword |
| timestamp | date |
| character varying, text | 取决于查询需求。使用 [`text`](https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/text) 进行全文搜索,使用 [`keyword`](https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/keyword) 进行词项查询、排序或聚合 |
##### 验证预期查询
在创建新索引之前,验证计划中的映射是否支持预期的查询至关重要。提前验证映射兼容性有助于避免后续需要重建索引的问题。
#### 创建新的 Elastic 引用
在 `ee/lib/search/elastic/references/` 中创建一个 `Search::Elastic::References::` 类。
该引用用于在 Elasticsearch 中执行批量操作。
文件必须继承自 `Search::Elastic::Reference` 并定义以下常量和方法:
```ruby
include Search::Elastic::Concerns::DatabaseReference # 如果每个文档都有对应的数据库记录
SCHEMA_VERSION = 24_46 # 格式为 YYWW 的整数
override :serialize
def self.serialize(record)
# 引用的字符串表示形式
end
override :instantiate
def self.instantiate(string)
# 反序列化字符串并调用 initialize
end
override :preload_indexing_data
def self.preload_indexing_data(refs)
# 若包含 `Search::Elastic::Concerns::DatabaseReference` 则可删除此方法
# 否则返回 refs
end
def initialize
# 用实例变量初始化
end
override :identifier
def identifier
# 标识引用的方式
end
override :routing
def routing
# 可选:用于在 Elasticsearch 中路由文档的标识符
end
override :operation
def operation
# 取值为 `:index`、`:upsert` 或 `:delete`
end
override :serialize
def serialize
# 引用的字符串表示形式
end
override :as_indexed_json
def as_indexed_json
# 包含此引用文档表示形式的哈希
end
override :index_name
def index_name
# 索引名
end
def model_klass
# 若包含 `Search::Elastic::Concerns::DatabaseReference`,则设置为模型类
end要将数据添加到索引中,需调用新引用类的实例到 Elastic::ProcessBookkeepingService.track!() 中,将该数据添加到待索引的引用队列中。
定时任务工作器会拉取排队中的引用,并将这些项目批量索引到 Elasticsearch 中。
要测试索引操作是否有效,请使用引用类的一个实例调用 Elastic::ProcessBookkeepingService.track!(),并运行 Elastic::ProcessBookkeepingService.new.execute。日志会显示更新情况。若要检查索引中的文档,请运行以下命令:
curl "http://localhost:9200/gitlab-development-<type>/_search"常见陷阱
- 索引操作实际上执行的是 upsert(更新插入)。如果文档存在,它会通过合并发送的字段与现有文档字段来执行部分更新。如果要显式删除字段或将其设为空,
as_indexed_json必须返回nil或空数组。
数据一致性
既然我们有了索引以及将新文档类型批量索引到 Elasticsearch 的方式,就需要向索引中添加数据。这包括进行回填(backfill)和持续更新,以确保索引数据是最新的。
回填是通过调用 Elastic::ProcessInitialBookkeepingService.track!() 来完成的,对于每个应被索引的文档,传入一个 Search::Elastic::Reference 实例。
持续更新则是通过调用 Elastic::ProcessBookkeepingService.track!() 来完成,对于每个应被创建/更新/删除的文档,传入一个 Search::Elastic::Reference 实例。
回填数据
添加一个新的 高级搜索迁移,通过执行 scripts/elastic-migration 并遵循说明来完成数据回填。
使用 MigrationDatabaseBackfillHelper。可以参考 BackfillWorkItems 迁移 作为示例。
要测试回填,请在控制台中多次运行 Elastic::MigrationWorker.new.perform,并查看索引是否已填充。
跟踪日志以查看迁移进度:
tail -f log/elasticsearch.log持续更新
对于 ActiveRecord 对象,可以在模型中包含 ApplicationVersionedSearch 关联(concern),以便基于回调来索引数据。如果不适用,则每当有文档需要索引时,调用 Elastic::ProcessBookkeepingService.track!() 并传入一个 Search::Elastic::Reference 实例。
始终检查 Gitlab::CurrentSettings.elasticsearch_indexing? 和 use_elasticsearch?,因为一些 GitLab 自托管实例未启用 Elasticsearch,并且可能启用了 命名空间限制。
还要检查索引能否处理索引请求。例如,如果索引是在当前主要版本中添加的,请验证添加索引的迁移已完成:Elastic::DataMigrationService.migration_has_finished?。
转移与删除
项目和群组的转移与删除必须更新索引以避免孤立数据。当由于转移导致自定义路由变更时,可能会出现孤立数据。旧分片中的数据必须清理。
转移的Elasticsearch更新由Projects::TransferService和Groups::TransferService处理。
包含project_id字段的索引必须使用Search::Elastic::DeleteWorker。
包含namespace_id字段且没有project_id字段的索引必须使用Search::ElasticGroupAssociationDeletionWorker。
- 将已索引的类添加到
ElasticDeleteProjectWorker的excluded_classes中 - 在
::Search::Elastic::Delete命名空间中创建一个新的服务来从索引中删除文档 - 更新worker以使用新服务
为新文档类型实现搜索
搜索数据可在SearchController和Search API中使用。两者都使用SearchService返回结果。
SearchService可用于在SearchController和Search API之外返回结果。
为新文档类型实现搜索的推荐流程
创建以下MR并由Global Search团队成员评审:
- 启用新范围
- 创建一个查询构建器
- 实现所有model要求
- 将新范围添加到
Gitlab::Elastic::SearchResults,并置于功能标志之后 - 在
Search::API中添加对该范围的支持(如果适用) - 添加测试用例,其中必须包括权限测试
- 测试新范围
- 更新高级搜索、Search API以及角色和权限的文档(如果适用)
#### 搜索范围
`SearchService` 在[全局](https://gitlab.com/gitlab-org/gitlab/-/blob/0105b56d6ad86e04ef46492dcf5537553505b678/app/services/search/global_service.rb)、[群组](https://gitlab.com/gitlab-org/gitlab/-/blob/0105b56d6ad86e04ef46492dcf5537553505b678/app/services/search/group_service.rb)和[项目](https://gitlab.com/gitlab-org/gitlab/-/blob/0105b56d6ad86e04ef46492dcf5537553505b678/app/services/search/project_service.rb)级别提供搜索功能。
新范围必须添加到以下常量中:
- 每个 EE `SearchService` 文件中的 `ALLOWED_SCOPES`(或重写 `allowed_scopes` 方法)\n- `Gitlab::Search::AbuseDetection` 中的 `ALLOWED_SCOPES`\n- `Search::Navigation` 中的 `search_tab_ability_map` 方法。如果需要,在 EE 版本中重写
可以禁用某个范围的全球搜索。要禁用全球搜索,您可以进行以下更改:
1. 在 [`app/models/application_setting.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/d52af9fafd5016ea25a665a9d5cb797b37a39b10/app/models/application_setting.rb#L738) 的 `search` jsonb 访问器下添加一个名为 `global_search_SCOPE_enabled` 的应用程序设置,默认值为 `true`。\n1. 在 JSON 模式验证文件 [`application_setting_search.json`](https://gitlab.com/gitlab-org/gitlab/-/blob/d52af9fafd5016ea25a665a9d5cb797b37a39b10/app/validators/json_schemas/application_setting_search.json) 中添加条目\n1. 通过在 [`ApplicationSettingsHelper`](https://gitlab.com/gitlab-org/gitlab/-/blob/0105b56d6ad86e04ef46492dcf5537553505b678/app/helpers/application_settings_helper.rb#L75) 的 `global_search_settings_checkboxes` 方法中创建条目,将设置复选框添加到管理界面。\n1. 将其添加到 [`SearchService`](https://gitlab.com/gitlab-org/gitlab/-/blob/0105b56d6ad86e04ef46492dcf5537553505b678/app/services/search_service.rb#L106) 的 `global_search_enabled_for_scope?` 方法中。\n1. 请记住,仅 EE 设置应添加到文件的 EE 版本中
#### 结果类
可用的搜索结果类如下:
| 搜索类型 | 搜索级别 | 类 |\n|------------------|-------------|------------------------------------------|\n| 基础搜索 | 全局 | `Gitlab::SearchResults` |\n| 基础搜索 | 群组 | `Gitlab::GroupSearchResults` |\n| 基础搜索 | 项目 | `Gitlab::ProjectSearchResults` |\n| 高级搜索 | 全局 | `Gitlab::Elastic::SearchResults` |\n| 高级搜索 | 群组 | `Gitlab::Elastic::GroupSearchResults` |\n| 高级搜索 | 项目 | `Gitlab::Elastic::ProjectSearchResults` |\n| 精确代码搜索 | 全局 | `Search::Zoekt::SearchResults` |\n| 精确代码搜索 | 群组 | `Search::Zoekt::SearchResults` |\n| 精确代码搜索 | 项目 | `Search::Zoekt::SearchResults` |\n| 所有搜索类型 | 所有级别 | `Search::EmptySearchResults` |\n\n结果类返回以下数据:
1. `objects` - 从 Elasticsearch 分页转换而来的数据库记录或 PORO 对象\n1. `formatted_count` - 从 Elasticsearch 返回的文档计数\n1. `highlight_map` - 来自 Elasticsearch 的高亮字段映射\n1. `failed?` - 如果发生失败\n1. `error` - 从 Elasticsearch 返回的错误消息\n1. `aggregations` - (可选)来自 Elasticsearch 的聚合结果\n\n新范围必须在 `Gitlab::Elastic::SearchResults` 类中为这些方法添加支持:
- `objects`\n- `formatted_count`\n- `highlight_map`\n- `failed?`\n- `error`\n\n### 更新现有范围
更新可能包括添加和删除文档字段或授权更改。要更新现有范围,请找到用于生成查询和索引 JSON 的代码。
- 查询在 `QueryBuilder` 类中生成\n- 索引文档在 `Reference` 类中构建\n\n我们还支持传统的 `Proxy` 框架:
- 查询在 `ClassProxy` 类中生成\n- 索引文档在 `InstanceProxy` 类中构建\n\n始终尝试在 `QueryBuilder` 框架中创建新的搜索过滤器,即使它们在传统框架中使用也是如此。
#### 添加字段##### 向索引添加字段
1. 将字段添加到索引映射中,以便在新建索引时包含该字段,并在同一合并请求(MR)中创建迁移以将该字段添加到现有索引,从而避免映射架构偏移。使用 [`MigrationUpdateMappingsHelper`](search/advanced_search_migration_styleguide.md#searchelasticmigrationupdatemappingshelper)
1. 在文档JSON中填充新字段。代码必须通过 `::Elastic::DataMigrationService.migration_has_finished?` 检查迁移是否已完成
1. 为文档JSON提升 `SCHEMA_VERSION` 版本号。格式为年份和周数:`YYYYWW`
1. 创建迁移以回填索引中的字段。如果字段不可为空,请使用 [`MigrationBackfillHelper`](search/advanced_search_migration_styleguide.md#searchelasticmigrationbackfillhelper);如果是可为空字段,则使用 [`MigrationReindexBasedOnSchemaVersion`](search/advanced_search_migration_styleguide.md#searchelasticmigrationreindexbasedonschemaversion)
##### 如果新字段是关联记录
1. 更新针对 [`Elastic::ProcessBookkeepingService`](https://gitlab.com/gitlab-org/gitlab/blob/8ce9add3bc412a32e655322bfcd9dcc996670f82/ee/spec/services/elastic/process_bookkeeping_service_spec.rb) 的测试规范以创建关联记录
1. 更新 `preload_search_data` 的N+1测试规范以创建关联数据记录
1. 查看 [索引中依赖关联的更新](advanced_search/tips.md#dependent-association-index-updates)
##### 将字段暴露给搜索服务
1. 通过将过滤器添加到 [`Search::Filter` concern](https://gitlab.com/gitlab-org/gitlab/-/blob/21bc3a986d27194c2387f4856ec1c5d5ef6fb4ff/app/services/concerns/search/filter.rb) 中。该 concern 用于 `Search::GlobalService`、`Search::GroupService` 和 `Search::ProjectService`
1. 通过更新 `scope_options` 方法传递作用域的字段。该方法定义于 `Gitlab::Elastic::SearchResults` 中,并在 `Gitlab::Elastic::GroupSearchResults` 和 `Gitlab::Elastic::ProjectSearchResults` 中被重写
1. 在 [查询构建器](#creating-a-query) 中使用该字段,可通过添加 [现有过滤器](#available-filters) 或 [创建新过滤器](#creating-a-filter) 实现
1. 在 [`SearchController`](https://gitlab.com/gitlab-org/gitlab/-/blob/21bc3a986d27194c2387f4856ec1c5d5ef6fb4ff/app/controllers/search_controller.rb#L277) 中跟踪搜索中的过滤器使用情况
#### 改变现有字段的映射
1. 在索引映射中更新字段类型,以应用于新建索引
1. 为文档JSON提升 `SCHEMA_VERSION` 版本号。格式为年份和周数:`YYYYWW`
1. 创建迁移以重新索引所有文档,使用 [零停机重新索引迁移](search/advanced_search_migration_styleguide.md#zero-downtime-reindex-migration)。使用 [`Search::Elastic::MigrationReindexTaskHelper`](search/advanced_search_migration_styleguide.md#searchelasticmigrationreindextaskhelper)
#### 改变字段内容
1. 更新文档JSON中的字段内容
1. 为文档JSON提升 `SCHEMA_VERSION` 版本号。格式为年份和周数:`YYYYWW`
1. 创建迁移以更新文档。使用 [`MigrationReindexBasedOnSchemaVersion`](search/advanced_search_migration_styleguide.md#searchelasticmigrationreindexbasedonschemaversion)
#### 清理索引中的文档
当文档从一个索引拆分到多个独立索引,或因缺陷导致索引中残留数据时,此操作可能有用。
1. 为文档JSON提升 `SCHEMA_VERSION` 版本号。格式为年份和周数:`YYYYWW`
1. 创建迁移以索引所有记录。使用 [`MigrationDatabaseBackfillHelper`](search/advanced_search_migration_styleguide.md#searchelasticmigrationdatabasebackfillhelper)
1. 创建迁移以删除具有先前 `SCHEMA_VERSION` 的所有文档。使用 [`MigrationDeleteBasedOnSchemaVersion`](search/advanced_search_migration_styleguide.md#searchelasticmigrationdeletebasedonschemaversion)移除字段
移除操作必须分多个里程碑进行,以支持多版本兼容性。为了避免动态映射错误,必须在执行零停机时间重新索引之前,将该字段从所有文档中移除。
里程碑 M:
- 从索引映射中移除该字段,使其不在新创建的索引中出现
- 停止在文档JSON中填充该字段
- 提升文档JSON的
SCHEMA_VERSION。格式为年份和周数:YYYYWW - 从查询构建器中移除任何使用该字段的过滤器
- 更新
scope_options方法,以移除你正在更新的作用域中的该字段。该方法定义在Gitlab::Elastic::SearchResults中,并在Gitlab::Elastic::GroupSearchResults和Gitlab::Elastic::ProjectSearchResults中有重写。
若该字段未被其他作用域使用:
- 从
Search::Filterconcern中移除该字段。该concern用于Search::GlobalService、Search::GroupService和Search::ProjectService。 - 在
SearchController中移除搜索中的过滤器跟踪
里程碑 M+1:
- 创建一个迁移来从索引的所有文档中移除该字段。使用
MigrationRemoveFieldsHelper - 创建一个迁移,使用零停机时间重新索引对所有已移除该字段的文档进行重新索引。使用
Search::Elastic::MigrationReindexTaskHelper
更新授权
在QueryBuilder框架中,授权在项目级别通过by_search_level_and_membership过滤器处理,在组级别通过by_search_level_and_group_membership过滤器处理。
在传统的Proxy框架中,授权在类内部处理。
两个框架都使用Search::GroupsFinder和Search::ProjectsFinder来查询用户有权直接搜索的组和项目。搜索依赖于每个作用域的组和项目可见性级别以及功能访问级别设置。有关更多信息,请参阅角色和权限文档。
查询构建器框架
查询构建器框架用于构建Elasticsearch查询。我们还支持一种传统查询框架,该框架在Elastic::Latest::ApplicationClassProxy类及其继承类中实现。
新文档类型必须使用查询构建器框架。
构建查询
查询是通过以下方式构建的:
- 来自
Search::Elastic::Queries的查询 - 一个或多个来自
::Search::Elastic::Filters的过滤器 - (可选)来自
::Search::Elastic::Aggregations的聚合 - 一个或多个来自
::Search::Elastic::Formats的格式
新的作用域必须创建一个新的查询构建器类,该类继承自Search::Elastic::QueryBuilder。
查询构建器框架提供了一系列预构建的过滤器,用于处理常见的搜索场景。这些过滤器简化了构建复杂查询条件的过程,无需编写原始的Elasticsearch查询DSL。
创建过滤器
过滤器是构建有效 Elasticsearch 查询的关键组件。它们帮助缩小搜索结果范围,同时不影响相关性评分。
-
所有过滤器都必须有文档记录。
-
过滤器在
Search::Elastic::Filters中作为类级方法创建。 -
方法应以
by_开头。 -
方法必须仅接受
query_hash和options参数。 -
query_hash预期包含具有以下格式的哈希。{ "query": { "bool": { "must": [], "must_not": [], "should": [], "filters": [], "minimum_should_match": null } } } -
使用
add_filter将过滤器添加到query_hash中。过滤器应添加到filters以避免计算得分。得分计算由查询本身完成。 -
在过滤器周围使用
context.name(:filters)为其添加名称。这有助于识别查询和过滤器的哪一部分允许搜索返回结果。def by_new_filter_type(query_hash:, options:) filter_selected_value = options[:field_value] context.name(:filters) do add_filter(query_hash, :query, :bool, :filter) do { term: { field_name: { _name: context.name(:field_name), value: filter_selected_value } } } end end end
理解查询与过滤器的区别
Elasticsearch 中的查询有两个关键目的:筛选文档和计算相关性评分。在构建搜索功能时:
-
当需要根据匹配搜索条件的程度对结果进行排名时,查询至关重要。它们使用布尔查询的
must、should和must_not子句,所有这些都影响文档的最终相关性评分。 -
过滤器(在查询上下文中)确定文档是否出现在搜索结果中,而不影响其评分。对于只需包含/排除结果而不按相关性排名的搜索操作,单独使用过滤器更高效,在大规模下性能更好。
根据搜索需求选择适当的方法——使用带有评分子句的查询来获取排名结果,并依靠过滤器来实现简单的包含/排除逻辑。
过滤器的要求和使用
要使用任何过滤器:
- 索引映射必须包含每个过滤器文档中指定的所有必需字段
- 调用过滤器时,通过
options哈希传递适当的参数 - 每个过滤器将生成适当的 JSON 结构并将其添加到你的
query_hash中
可以将多个过滤器组合在一起,以创建复杂的搜索查询,同时保持可读且易维护的代码。
向 Elasticsearch 发送查询
查询从 Gitlab::Elastic::SearchResults 发送到 ::Gitlab::Search::Client。结果通过 Search::Elastic::ResponseMapper 解析,以转换来自 Elasticsearch 的响应。
模型要求
模型必须响应 to_ability_name 方法,以便红化逻辑可以检查它是否有 Ability.allowed?(current_user, :"read_#{object.to_ability_name}", object)?。如果不存在,则必须添加该方法。
模型必须定义一个 preload_search_data 范围,以避免 N+1 问题。
可用查询
所有查询构建器都必须返回符合 Elasticsearch 布尔查询语法的标准化 query_hash 结构。Search::Elastic::BoolExpr 类提供了构造布尔查询的接口。
所需的 query_hash 结构如下:
{
"query": {
"bool": {
"must": [],
"must_not": [],
"should": [],
"filters": [],
"minimum_should_match": null
}
}
}by_iid
按 iid 字段和文档类型查询。需要 type 和 iid 字段。
{
"query": {
"bool": {
"filter": [
{
"term": {
"iid": {
"_name": "milestone:related:iid",
"value": 1
}
}
},
{
"term": {
"type": {
"_name": "doc:is_a:milestone",
"value": "milestone"
}
}
}
]
}
}
}by_full_text
执行全文搜索。如果查询字符串中使用高级搜索语法,此查询将使用 by_multi_match_query 或 by_simple_query_string。
by_multi_match_query
通过多匹配查询
使用 Elasticsearch API 的 multi_match。可通过以下选项自定义:
count_only- 使用布尔查询子句filter。不执行评分和高亮。query- 若未传入查询,则使用 Elasticsearch API 的match_allkeyword_match_clause- 若传入:should,则使用布尔查询子句should。默认:must子句
{
"query": {
"bool": {
"must": [
{
"bool": {
"must": [],
"must_not": [],
"should": [
{
"multi_match": {
"_name": "project:multi_match:and:search_terms",
"fields": [
"name^10",
"name_with_namespace^2",
"path_with_namespace",
"path^9",
"description"
],
"query": "search",
"operator": "and",
"lenient": true
}
},
{
"multi_match": {
"_name": "project:multi_match_phrase:search_terms",
"type": "phrase",
"fields": [
"name^10",
"name_with_namespace^2",
"path_with_namespace",
"path^9",
"description"
],
"query": "search",
"lenient": true
}
}
],
"filter": [],
"minimum_should_match": 1
}
}
],
"must_not": [],
"should": [],
"filter": [],
"minimum_should_match": null
}
}
}by_simple_query_string
通过简单查询字符串
使用 Elasticsearch API 的 simple_query_string。可通过以下选项自定义:
count_only- 使用布尔查询子句filter。不执行评分和高亮。query- 若未传入查询,则使用 Elasticsearch API 的match_allkeyword_match_clause- 若传入:should,则使用布尔查询子句should。默认:must子句
{
"query": {
"bool": {
"must": [
{
"simple_query_string": {
"_name": "project:match:search_terms",
"fields": [
"name^10",
"name_with_namespace^2",
"path_with_namespace",
"path^9",
"description"
],
"query": "search",
"lenient": true,
"default_operator": "and"
}
}
],
"must_not": [],
"should": [],
"filter": [],
"minimum_should_match": null
}
}
}by_knn
通过 KNN(最近邻)
需指定选项:vectors_supported(设为 :elasticsearch 或 :opensearch)和 embedding_field。调用方可选择提供选项:embeddings
执行基于嵌入向量的混合搜索。若不支持嵌入向量,则使用全文搜索。
Elasticsearch 和 OpenSearch 对 knn 查询的 DSL 不同。为同时支持两者,此查询必须配合 by_knn 过滤器使用。
下方示例适用于 Elasticsearch。
{
"query": {
"bool": {
"must": [
{
"bool": {
"must": [],
"must_not": [],
"should": [
{
"multi_match": {
"_name": "work_item:multi_match:and:search_terms",
"fields": [
"iid^50",
"title^2",
"description"
],
"query": "test",
"operator": "and",
"lenient": true
}
},
{
"multi_match": {
"_name": "work_item:multi_match_phrase:search_terms",
"type": "phrase",
"fields": [
"iid^50",
"title^2",
"description"
],
"query": "test",
"lenient": true
}
}
],
"filter": [],
"minimum_should_match": 1
}
}
],
"must_not": [],
"should": [],
"filter": [],
"minimum_should_match": null
}
},
"knn": {
"field": "embedding_0",
"query_vector": [
0.030752448365092278,
-0.05360432341694832
],
"boost": 5,
"k": 25,
"num_candidates": 100,
"similarity": 0.6,
"filter": []
}
}可用过滤器
以下各节详细说明每个可用过滤器的必填字段、支持选项及示例输出。
#### `by_type`
需要 `type` 字段。在选项中使用 `doc_type` 进行查询。
```json
{
"term": {
"type": {
"_name": "filters:doc:is_a:milestone",
"value": "milestone"
}
}
}by_group_level_confidentiality
需要 current_user 和 group_ids 字段。基于用户读取保密组实体的权限进行查询。
{
"bool": {
"must": [
{
"term": {
"confidential": {
"value": true,
"_name": "confidential:true"
}
}
},
{
"terms": {
"namespace_id": [
1
],
"_name": "groups:can:read_confidential_work_items"
}
}
]
},
"should": {
"term": {
"confidential": {
"value": false,
"_name": "confidential:false"
}
}
}
}by_project_confidentiality
需要 confidential、author_id、assignee_id、project_id 字段。在选项中使用 confidential 进行查询。
{
"bool": {
"should": [
{
"term": {
"confidential": {
"_name": "filters:non_confidential",
"value": false
}
}
},
{
"bool": {
"must": [
{
"term": {
"confidential": {
"_name": "filters:confidential",
"value": true
}
}
},
{
"bool": {
"should": [
{
"term": {
"author_id": {
"_name": "filters:confidential:as_author",
"value": 1
}
}
},
{
"term": {
"assignee_id": {
"_name": "filters:confidential:as_assignee",
"value": 1
}
}
},
{
"terms": {
"_name": "filters:confidential:project:membership:id",
"project_id": [
12345
]
}
}
]
}
}
]
}
}
]
}
}by_label_ids
需要 label_ids 字段。在选项中使用 label_names 进行查询。
{
"bool": {
"must": [
{
"terms": {
"_name": "filters:label_ids",
"label_ids": [
1
]
}
}
]
}
}by_archived
需要 archived 字段。在选项中使用 search_level 和 include_archived 进行查询。
{
"bool": {
"_name": "filters:non_archived",
"should": [
{
"bool": {
"filter": {
"term": {
"archived": {
"value": false
}
}
}
}
},
{
"bool": {
"must_not": {
"exists": {
"field": "archived"
}
}
}
}
]
}
}by_state
需要 state 字段。支持值:all、opened、closed 和 merged。在选项中使用 state 进行查询。
{
"match": {
"state": {
"_name": "filters:state",
"query": "opened"
}
}
}by_not_hidden
需要 hidden 字段。不适用于管理员。
{
"term": {
"hidden": {
"_name": "filters:not_hidden",
"value": false
}
}
}by_work_item_type_ids
需要 work_item_type_id 字段。在选项中使用 work_item_type_ids 或 not_work_item_type_ids 进行查询。
{
"bool": {
"must_not": {
"terms": {
"_name": "filters:not_work_item_type_ids",
"work_item_type_id": [
8
]
}
}
}
}by_author
需要 author_id 字段。在选项中使用 author_username 或 not_author_username 进行查询。
{
"bool": {
"should": [
{
"term": {
"author_id": {
"_name": "filters:author",
"value": 1
}
}
}
],
"minimum_should_match": 1
}
}by_target_branch
需要 target_branch 字段。在选项中使用 target_branch 或 not_target_branch 进行查询。
{
"bool": {
"should": [
{
"term": {
"target_branch": {
"_name": "filters:target_branch",
"value": "master"
}
}
}
],
"minimum_should_match": 1
}
}by_source_branch
需要 source_branch 字段。在选项中使用 source_branch 或 not_source_branch 进行查询。
{
"bool": {
"should": [
{
"term": {
"source_branch": {
"_name": "filters:source_branch",
"value": "master"
}
}
}
],
"minimum_should_match": 1
}
}by_search_level_and_group_membership
需要 current_user、group_ids、traversal_id 和 search_level 字段。使用 search_level 进行查询,并根据用户对每个组的权限过滤 namespace_visibility_level。
如果被搜索的数据不包含 project_id 字段,则此过滤器可以替代 by_search_level_and_membership 使用。
示例显示的是已认证用户的情况。对于具有授权的用户、管理员、外部用户或匿名用户,JSON 可能不同。
global
{
"bool": {
"should": [
{
"bool": {
"filter": [
{
"term": {
"namespace_visibility_level": {
"value": 20,
"_name": "filters:namespace_visibility_level:public"
}
}
}
]
}
},
{
"bool": {
"filter": [
{
"term": {
"namespace_visibility_level": {
"value": 10,
"_name": "filters:namespace_visibility_level:internal"
}
}
}
]
}
},
{
"bool": {
"filter": [
{
"term": {
"namespace_visibility_level": {
"value": 0,
"_name": "filters:namespace_visibility_level:private"
}
}
},
{
"terms": {
"namespace_id": [
33,
22
]
}
}
]
}
}
],
"minimum_should_match": 1
}
}group
[
{
"bool": {
"_name": "filters:level:group",
"minimum_should_match": 1,
"should": [
{
"prefix": {
"traversal_ids": {
"_name": "filters:level:group:ancestry_filter:descendants",
"value": "22-"
}
}
}
]
}
},
{
"bool": {
"should": [
{
"bool": {
"filter": [
{
"term": {
"namespace_visibility_level": {
"value": 20,
"_name": "filters:namespace_visibility_level:public"
}
}
}
]
}
},
{
"bool": {
"filter": [
{
"term": {
"namespace_visibility_level": {
"value": 10,
"_name": "filters:namespace_visibility_level:internal"
}
}
}
]
}
},
{
"bool": {
"filter": [
{
"term": {
"namespace_visibility_level": {
"value": 0,
"_name": "filters:namespace_visibility_level:private"
}
}
},
{
"terms": {
"namespace_id": [
22
]
}
}
]
}
}
],
"minimum_should_match": 1
}
},
{
"bool": {
"_name": "filters:level:group",
"minimum_should_match": 1,
"should": [
{
"prefix": {
"traversal_ids": {
"_name": "filters:level:group:ancestry_filter:descendants",
"value": "22-"
}
}
}
]
}
}
]by_search_level_and_membership
需要 project_id、traversal_id 以及项目可见性(默认为 visibility_level,但可通过 project_visibility_level_field 选项设置)字段。支持功能 *_access_level 字段。使用 search_level 进行查询,并在选项中可选地传入 project_ids、group_ids、features 和 current_user。
应用以下过滤条件:
- 全局、组或项目的搜索级别
- 直接加入组或项目的成员资格,或通过直接访问组获得的共享成员资格
- 通过
features传递的任何功能访问级别
示例显示的是已登录用户的情况。对于具有授权的用户、管理员、外部用户或匿名用户,JSON 可能不同。
全局
{
"bool": {
"_name": "全局权限过滤器",
"should": [
{
"bool": {
"must": [
{
"terms": {
"_name": "全局权限过滤器:可见性级别:公开与内部",
"visibility_level": [
20,
10
]
}
}
],
"should": [
{
"terms": {
"_name": "全局权限过滤器:仓库访问级别:启用",
"repository_access_level": [
20
]
}
}
],
"minimum_should_match": 1
}
},
{
"bool": {
"must": [
{
"bool": {
"should": [
{
"terms": {
"_name": "全局权限过滤器:仓库访问级别:启用或私有",
"repository_access_level": [
20,
10
]
}
}
],
"minimum_should_match": 1
}
}
],
"should": [
{
"prefix": {
"traversal_ids": {
"_name": "全局权限过滤器:祖先过滤器:后代",
"value": "123-"
}
}
},
{
"terms": {
"_name": "全局权限过滤器:项目成员",
"project_id": [
456
]
}
}
],
"minimum_should_match": 1
}
}
],
"minimum_should_match": 1
}
}组
[
{
"bool": {
"_name": "级别过滤器:组",
"minimum_should_match": 1,
"should": [
{
"prefix": {
"traversal_ids": {
"_name": "级别过滤器:组:祖先过滤器:后代",
"value": "123-"
}
}
}
]
}
},
{
"bool": {
"_name": "权限过滤器:组",
"should": [
{
"bool": {
"must": [
{
"terms": {
"_name": "权限过滤器:组:可见性级别:公开与内部",
"visibility_level": [
20,
10
]
}
}
],
"should": [
{
"terms": {
"_name": "权限过滤器:组:仓库访问级别:启用",
"repository_access_level": [
20
]
}
}
],
"minimum_should_match": 1
}
},
{
"bool": {
"must": [
{
"bool": {
"should": [
{
"terms": {
"_name": "权限过滤器:组:仓库访问级别:启用或私有",
"repository_access_level": [
20,
10
]
}
}
],
"minimum_should_match": 1
}
}
],
"should": [
{
"prefix": {
"traversal_ids": {
"_name": "权限过滤器:组:祖先过滤器:后代",
"value": "123-"
}
}
}
],
"minimum_should_match": 1
}
}
],
"minimum_should_match": 1
}
}
]project
[
{
"bool": {
"_name": "filters:level:project",
"must": {
"terms": {
"project_id": [
456
]
}
}
}
},
{
"bool": {
"_name": "filters:permissions:project",
"should": [
{
"bool": {
"must": [
{
"terms": {
"_name": "filters:permissions:project:visibility_level:public_and_internal",
"visibility_level": [
20,
10
]
}
}
],
"should": [
{
"terms": {
"_name": "filters:permissions:project:repository_access_level:enabled",
"repository_access_level": [
20
]
}
}
],
"minimum_should_match": 1
}
},
{
"bool": {
"must": [
{
"bool": {
"should": [
{
"terms": {
"_name": "filters:permissions:project:repository_access_level:enabled_or_private",
"repository_access_level": [
20,
10
]
}
}
],
"minimum_should_match": 1
}
}
],
"should": [
{
"prefix": {
"traversal_ids": {
"_name": "filters:permissions:project:ancestry_filter:descendants",
"value": "123-"
}
}
}
],
"minimum_should_match": 1
}
}
],
"minimum_should_match": 1
}
}
]by_knn
需要选项:vectors_supported(设置为 :elasticsearch 或 :opensearch)和 embedding_field。调用者可选择提供选项:embeddings
Elasticsearch 和 OpenSearch 中 knn 查询的 DSL 存在不同。为同时支持两者,此过滤器需与 by_knn 查询配合使用。
by_noteable_type
需要 noteable_type 字段。在选项中以 noteable_type 执行查询。设置 _source 仅返回 noteable_id 字段。
{
"term": {
"noteable_type": {
"_name": "filters:related:issue",
"value": "Issue"
}
}
}测试范围
在 Rails 控制台中测试任意范围
search_service = ::SearchService.new(User.first, { search: 'foo', scope: 'SCOPE_NAME' })
search_service.search_objects权限测试
搜索代码在 SearchService#redact_unauthorized_results 中包含最终安全检查。这可阻止未授权结果被返回给无权查看的用户。该检查在 Ruby 中执行,以应对因错误或索引延迟导致的 Elasticsearch 权限数据不一致问题。
新范围必须添加可见性规范以保证正确访问控制。
若要验证权限是否正常执行,可在 EE 规范中使用 'search respects visibility' 共享示例 添加测试:
ee/spec/services/ee/search/global_service_spec.rbee/spec/services/ee/search/group_service_spec.rbee/spec/services/ee/search/project_service_spec.rb
多索引零停机时间重新索引
目前这不适用,因多索引功能尚未完全实现。
当前 GitLab 仅能处理单版配置。任何配置/模式变更均需从头重新索引全部内容。由于重新索引耗时较长,可能导致搜索功能停机。
为避免停机,GitLab 正开发支持多索引并行运行的功能。每当模式变更时,管理员可新建索引并对其重新索引,同时搜索仍指向旧稳定索引;所有数据更新会被同步至两索引。待新索引就绪后,管理员可将其设为活跃状态,使所有搜索指向新索引并移除旧索引。
这对迁移至新服务器(如 AWS 迁入/迁出)也有帮助。
目前我们正逐步迁移至该新设计,现阶段所有逻辑均硬编码为仅适配单一版本。