Elasticsearch文档标签检索方案设计

1.概述

在前一篇文章中Elasticsearch实现文档标签管理介绍了如何在elasticsearch中对文档的标签进行增删改查,包括标签的复杂查询。 但这里有个问题,如果所有标签都存储在同一个字段(比如tags字段)中,如何区分不同的标签呢? 本文将通过不同的方案实现有多个不同类型标签的复杂检索,从如下几个方面进行介绍:

  • 需求描述
  • 需求分析
  • 方案设计
  • 方案对比
  • 方案选择

2.需求描述

需求描述:

  1. 一个标签有多个值,如果同时查询多个标签,多个标签之间是and的关系,如果一个标签选择多个值,那么一个标签的多个值是or的关系
  2. 将数据保存到elasticsearch并且支持标签和内容的复杂查询

需求描述举例:多个标签之间是逻辑与(AND),比如同时有标签A和标签B。而每个标签自身可能有多个值,这些值之间是逻辑或(OR)。例如,标签"颜色"可能有值"红色"或"蓝色",同时标签"尺寸"可能有值"大"或"中",希望查询同时满足颜色为红或蓝,且尺寸为大或中的文档。

标签和文档的表结构如下:

erDiagram doc ||--o{ doc_tag : "has" tag ||--o{ doc_tag : "applies" doc { string doc_id PK string title text content } tag { string tag_id PK string tag_name string value_type string allowed_values } doc_tag { string doc_id FK string tag_id FK string tag_value }

3.需求分析

使用下面的sql可以实现标签查询需求,根据多个 tag_id 和 tag_value 查询关联的 doc_id 列表的交集

sql 复制代码
SELECT doc_id
FROM doc_tag
WHERE (tag_id, tag_value) IN (('color', '红色'), ('size', '中'))
GROUP BY doc_id
HAVING COUNT(DISTINCT tag_id) = 2;

如果需要支持更多的标签组合,只需在 IN 子句中添加更多的组合,并相应地调整 HAVING 子句中的计数值,比如有3个标签需要查询,使用的sql如下:

sql 复制代码
SELECT doc_id
FROM doc_tag
WHERE (tag_id, tag_value) IN (('color', '红色'), ('size', '中'), ('category', '服饰'))
GROUP BY doc_id
HAVING COUNT(DISTINCT tag_id) = 3;

文档数据示例如下

json 复制代码
{
  "doc_id": 1,
  "title": "衣服",
  "tags": [
    {
      "tag_id": "color",
      "tag_name": "颜色",
      "tag_value": "红色",
      "value_type": "string"
    },
    {
      "tag_id": "size",
      "tag_name": "尺寸",
      "tag_value": "中",
      "value_type": "string"
    },
    {
      "tag_id": "category",
      "tag_name": "类别",
      "tag_value": "服饰",
      "value_type": "string"
    }
    
  ]
}

在elasticsearch中支持 nested type , 可以使用嵌套对象或者多字段来存储标签。例如,每个文档的标签是一个对象数组,每个对象有key和value字段。这样,可以通过 nested query 来处理每个标签的键值对。

这种情况下,可以使用Elasticsearch的nested类型来存储tags字段,然后进行嵌套查询。例如,查询颜色为红或蓝,且尺寸为大或中的文档,查询结构可能如下:

json 复制代码
{
    "query": {
        "bool": {
            "must": [
                {
                    "nested": {
                        "path": "tags",
                        "query": {
                            "bool": {
                                "must": [
                                    {
                                        "term": {
                                            "tags.key": "颜色"
                                        }
                                    },
                                    {
                                        "terms": {
                                            "tags.value": [
                                                "红",
                                                "蓝"
                                            ]
                                        }
                                    }
                                ]
                            }
                        }
                    }
                },
                {
                    "nested": {
                        "path": "tags",
                        "query": {
                            "bool": {
                                "must": [
                                    {
                                        "term": {
                                            "tags.key": "尺寸"
                                        }
                                    },
                                    {
                                        "terms": {
                                            "tags.value": [
                                                "大",
                                                "中"
                                            ]
                                        }
                                    }
                                ]
                            }
                        }
                    }
                }
            ]
        }
    }
}

这样,每个nested查询对应一个标签键的条件,其中该标签的值需要满足OR关系,而不同标签键之间是AND关系。这符合用户的需求:多个标签键之间AND,同一个标签键的多个值OR。

假设用户是将所有标签放在同一个字段中,比如tags字段,每个标签是一个独立的词条,例如"颜色:红"、"颜色:蓝"、"尺寸:大"等。此时,要查询同时满足多个标签条件,每个标签可能有多个值,就需要构造每个标签的OR条件,然后将这些OR条件用AND连接。

这种情况下,查询结构可能如下:

json 复制代码
{
    "query": {
        "bool": {
            "must": [
                {
                    "bool": {
                        "should": [
                            {
                                "term": {
                                    "tags": "颜色:红"
                                }
                            },
                            {
                                "term": {
                                    "tags": "颜色:蓝"
                                }
                            }
                        ]
                    }
                },
                {
                    "bool": {
                        "should": [
                            {
                                "term": {
                                    "tags": "尺寸:大"
                                }
                            },
                            {
                                "term": {
                                    "tags": "尺寸:中"
                                }
                            }
                        ]
                    }
                }
            ]
        }
    }
}

这确实满足了同一标签多个值OR,不同标签之间AND的需求。但这种方法依赖于标签命名的约定,可能不够灵活,但适用于简单的场景。

总结来说,解决方案有两种:

  1. 使用嵌套类型(nested)存储标签的键值对,这样可以针对每个键进行OR查询,不同键之间AND。

  2. 使用扁平化数据结构,但通过标签命名规则(如key:value),然后在查询时构造相应的条件。

方案设计

针对 Elasticsearch 中实现复杂标签查询的需求(多个标签之间使用 AND 逻辑,单个标签的多个值使用 OR 逻辑),以下是分步解决方案:

步骤 1:设计合理的数据结构

根据查询需求选择合适的数据存储方式,这里提供两种方案:

方案一:键值对嵌套结构

使用 nested 类型存储标签的键值对,便于精准控制标签逻辑关系:

python 复制代码
# 修改映射创建方法
def _create_index(self):
    if not self.es.indices.exists(index=self.index_name):
        mapping = {
            "mappings": {
                "properties": {
                    "title": {"type": "text"},
                    "content": {"type": "text"},
                    "tags": {
                        "type": "nested",  # 使用嵌套类型
                        "properties": {
                            "key": {"type": "keyword"},
                            "value": {"type": "keyword"}
                        }
                    },
                    "created_at": {"type": "date"}
                }
            }
        }
        self.es.indices.create(index=self.index_name, body=mapping)
方案二:扁平化字符串标签

使用 keyword 类型存储标签的键值对:

python 复制代码
def _create_index(self):
    """创建索引并定义映射"""
    if not self.es.indices.exists(index=self.index_name):
        mapping = {
            "mappings": {
                "properties": {
                    "title": {"type": "text"},
                    "content": {"type": "text"},
                    "tags": {
                        "type": "keyword",  # 使用keyword类型便于精确匹配和聚合
                        "null_value": []    # 处理空值情况
                    },
                    "created_at": {"type": "date"}
                }
            }
        }
        self.es.indices.create(index=self.index_name, body=mapping)

使用命名约定合并键值(如 key:value):

python 复制代码
# 文档示例
{
  "title": "示例文档",
  "tags": ["color:red", "color:blue", "size:large"]
}

步骤 2:实现复杂查询逻辑

根据数据结构选择对应的查询构建方式:

方案一:嵌套结构查询实现
python 复制代码
def search_with_nested_tags(self, tags_conditions):
    """
    tags_conditions 结构示例:
    {
        "color": ["red", "blue"],
        "size": ["large", "medium"]
    }
    表示:color必须为red或blue,且size必须为large或medium
    """
    query = {"bool": {"must": []}}
    
    for key, values in tags_conditions.items():
        nested_query = {
            "nested": {
                "path": "tags",
                "query": {
                    "bool": {
                        "must": [
                            {"term": {"tags.key": key}},
                            {"terms": {"tags.value": values}}
                        ]
                    }
                }
            }
        }
        query["bool"]["must"].append(nested_query)
    
    return self.es.search(index=self.index_name, body={"query": query})
方案二:扁平化标签查询实现
python 复制代码
def search_with_flat_tags(self, tags_conditions):
    """
    tags_conditions 结构示例:
    {
        "color": ["red", "blue"],
        "size": ["large", "medium"]
    }
    转换为实际查询标签:["color:red", "color:blue"] 和 ["size:large", "size:medium"]
    """
    query = {"bool": {"must": []}}
    
    for key, values in tags_conditions.items():
        # 生成类似 ["color:red", "color:blue"]
        combined_tags = [f"{key}:{value}" for value in values]
        tag_query = {"terms": {"tags": combined_tags}}
        query["bool"]["must"].append(tag_query)
    
    return self.es.search(index=self.index_name, body={"query": query})

步骤 3:完整类实现示例

基于嵌套结构
python 复制代码
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk

class TagSearchEngine:
    def __init__(self, host='localhost', port=9200):
        self.es = Elasticsearch([{'host': host, 'port': port}])
        self.index_name = "tagged_documents"
        self._create_index()

    def _create_index(self):
        """创建支持嵌套标签的索引"""
        if not self.es.indices.exists(index=self.index_name):
            mapping = {
                "mappings": {
                    "properties": {
                        "title": {"type": "text"},
                        "content": {"type": "text"},
                        "tags": {
                            "type": "nested",
                            "properties": {
                                "key": {"type": "keyword"},
                                "value": {"type": "keyword"}
                            }
                        },
                        "created_at": {"type": "date"}
                    }
                }
            }
            self.es.indices.create(index=self.index_name, body=mapping)

    def add_document(self, title, content, tags):
        """添加新文档"""
        doc = {
            "title": title,
            "content": content,
            "tags": [{"key": k, "value": v} for k, v in tags.items()],
            "created_at": "now"
        }
        return self.es.index(index=self.index_name, body=doc)

    def complex_tag_search(self, tag_conditions, size=10):
        """
        执行复杂标签查询
        :param tag_conditions: 字典格式 {tag_key: [values], ...}
        :return: 匹配的文档结果
        """
        query = {"bool": {"must": []}}
        
        for key, values in tag_conditions.items():
            nested_query = {
                "nested": {
                    "path": "tags",
                    "query": {
                        "bool": {
                            "must": [
                                {"term": {"tags.key": key}},
                                {"terms": {"tags.value": values}}
                            ]
                        }
                    }
                }
            }
            query["bool"]["must"].append(nested_query)
        
        return self.es.search(
            index=self.index_name,
            body={"query": query, "size": size}
        )

# 使用示例
if __name__ == "__main__":
    engine = TagSearchEngine()
    
    # 添加测试数据
    engine.add_document(
        title="夏季服装指南",
        content="夏季服装选择技巧...",
        tags={"color": ["red", "blue"], "size": ["large"]}
    )
    
    # 执行复杂查询:查找color=red或blue,且size=large或medium的文档
    results = engine.complex_tag_search(
        tag_conditions={
            "color": ["red", "blue"],
            "size": ["large", "medium"]
        }
    )
    print("查询结果:", results["hits"]["hits"])
基于扁平化结构完整代码实现
python 复制代码
from datetime import datetime

from elasticsearch import Elasticsearch


class TagSearchEngine:
    def __init__(self, host='localhost', port=9200):
        self.es = Elasticsearch(
            host=[{'host': host, 'port': port}],
            http_auth=('username', 'password'))
        self.index_name = "search_text"
        self._create_index()

    def _create_index(self):
        """创建索引并定义映射"""
        if not self.es.indices.exists(index=self.index_name):
            body = {
                "settings": {
                    "number_of_shards": 1,
                    "number_of_replicas": 1
                },
                "mappings": {
                    "_doc": {
                        "properties": {
                            "doc_id": {
                                "type": "keyword"
                            },
                            "title": {
                                "type": "text"
                            },
                            "content": {
                                "type": "text"
                            },
                            "tags": {
                                "type": "keyword",  # 使用keyword类型便于精确匹配和聚合
                                "null_value": []  # 处理空值情况
                            },
                            "gmt_create": {
                                "type": "date",
                                "format": "yyyy-MM-dd HH:mm:ss"
                            }
                        }
                    }
                }
            }

            self.es.indices.create(index=self.index_name, body=body)

    def add_document(self, doc_id, title, content, tags: dict[str, list]):
        """
        添加新文档
        tags 结构示例:
        {
            "color": ["red", "blue"],
            "size": ["large", "medium"]
        }
        转换为实际查询标签:["color:red", "color:blue"] 和 ["size:large", "size:medium"]
        """
        doc = {
            "doc_id": doc_id,
            "title": title,
            "content": content,
            "tags": [f'{k}:{t}' for k, v in tags.items() for t in v],
            "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        }
        return self.es.index(
            index=self.index_name,
            doc_type="_doc",
            body=doc,
            id=doc_id
        )

    def complex_tag_search(self, tag_conditions: dict[str, list]):
        """
        tag_conditions 结构示例:
        {
            "color": ["red", "blue"],
            "size": ["large", "medium"]
        }
        转换为实际查询标签:["color:red", "color:blue"] 和 ["size:large", "size:medium"]
        """
        query = {"bool": {"must": []}}

        for key, values in tag_conditions.items():
            # 生成类似 ["color:red", "color:blue"]
            # 生成标签键值组合列表
            combined_tags = [f"{key}:{value}" for value in values]
            # 每个标签的OR条件
            tag_query = {"terms": {"tags": combined_tags}}
            # 多个标签的AND条件
            query["bool"]["must"].append(tag_query)

        return self.es.search(index=self.index_name, body={"query": query})


# 使用示例
if __name__ == "__main__":
    engine = TagSearchEngine()

    # 添加测试数据
    engine.add_document(
        doc_id="1",
        title="夏季服装指南",
        content="夏季服装选择技巧...",
        tags={"color": ["red", "blue"], "size": ["large"]}
    )

    # 执行复杂查询:查找color=red或blue,且size=large或medium的文档
    results = engine.complex_tag_search(
        tag_conditions={
            "color": ["red", "blue"],
            "size": ["large", "medium"]
        }
    )
    print("查询结果:", results["hits"]["hits"])

方案对比

以下是两种标签查询方案的详细对比分析,帮助开发者根据实际场景选择最适合的实现方式:


方案一:嵌套结构查询实现(Nested Documents)

📌 数据结构示例
json 复制代码
{
  "title": "夏季服装",
  "tags": [
    {"key": "color", "value": "red"},
    {"key": "color", "value": "blue"},
    {"key": "size", "value": "large"}
  ]
}
✅ 优点
  1. 精准的键值关联

    • 严格保证每个值的归属关系(如 color:red 不会与 size:red 混淆)
    • 支持同一标签键的多值存储(如同时存储 color:redcolor:blue
  2. 灵活的查询能力

    • 原生支持键值组合查询:

      sql 复制代码
      WHERE tags.key='color' AND tags.value IN ('red','blue')
    • 可扩展范围查询、权重控制等高级功能

  3. 数据结构清晰

    • 适合需要严格分类的标签系统(如商品属性、用户画像)
    • 便于动态添加新标签类型
  4. 避免命名冲突

    • 天然解决标签命名歧义问题(如 color:darksize:dark 明确区分)
⚠️ 缺点
  1. 查询性能开销

    • 嵌套查询需要访问子文档,比普通查询慢约 5-10 倍(ES官方数据)
    • 每次嵌套查询相当于执行 mini-join 操作
  2. 存储成本增加

    • 嵌套文档存储为独立Lucene文档,索引体积增加约 30-50%
    • 影响写入吞吐量(需维护父子文档关系)
  3. 开发复杂度高

    • 需要处理嵌套查询的DSL语法

    • 更新操作需使用特定API:

      python 复制代码
      self.es.update(
          index=index,
          id=doc_id,
          body={"script": "ctx._source.tags.add(params.new_tag)"},
          params={"new_tag": {"key": "material", "value": "cotton"}}
      )
🎯 适用场景
  • 需要严格区分标签键值的系统(如电商商品属性)
  • 标签需要附加元信息的场景(如带权重的标签)
  • 要求精确匹配的业务场景(如医疗数据标注)

方案二:扁平化标签查询实现(Flattened Tags)

📌 数据结构示例
json 复制代码
{
  "title": "夏季服装",
  "tags": ["color:red", "color:blue", "size:large"]
}
✅ 优点
  1. 极简查询性能

    • 使用 terms 查询速度比嵌套查询快 5 倍以上
    • 适合高并发查询场景(每秒数千次请求)
  2. 存储效率优化

    • 相同数据量下索引体积减少约 40%
    • 写入速度提升约 30%(无嵌套文档维护开销)
  3. 开发简单直接

    • 使用标准DSL语法即可完成查询:

      python 复制代码
      {"terms": {"tags": ["color:red", "color:blue"]}}
    • 无需处理嵌套文档的更新逻辑

  4. 兼容现有系统

    • 可平滑迁移传统标签系统的数据格式
    • 方便与第三方系统对接(如导入/导出CSV)
⚠️ 缺点
  1. 键值管理风险

    • 需严格约定命名规则(如 key:value 分隔符)
    • 错误示例:"tags": ["red:color"](键值颠倒导致查询失效)
  2. 扩展能力受限

    • 无法直接实现值范围查询(如 price>100
    • 添加标签元信息需要重构数据结构
  3. 多值查询冗余

    • 查询 color=red|blue 需要构造多个标签:

      python 复制代码
      ["color:red", "color:blue"]
🎯 适用场景
  • 标签系统结构简单的应用(如博客分类)
  • 需要极致查询性能的场景
  • 已有扁平化标签数据的迁移项目

方案选择

决策树:如何选择方案?

graph TD A[开始] --> B{需要严格区分标签键值?} B -->|是| C[选嵌套结构] B -->|否| D{查询QPS > 1000?} D -->|是| E[选扁平化结构] D -->|否| F{需要范围查询等高级功能?} F -->|是| C F -->|否| G{已有扁平化数据?} G -->|是| E G -->|否| C

参考文档

www.elastic.co/guide/en/el...

相关推荐
写bug写bug几秒前
Java并发编程:本质上只有一种创建线程的方法
java·后端
Asthenia04127 分钟前
数据通信技术复习笔记:频带传输与数字调制详解
后端
Asthenia041211 分钟前
面试官拷问:内存溢出与内存泄漏的区别及排查方法
后端
Asthenia041244 分钟前
数据通信技术复习笔记:基带传输详解/衰减-噪音-失真/奈奎斯特的第一与第二准则
后端
南雨北斗1 小时前
8.安装laravel12和编程学习的几点思考
后端
异常君2 小时前
Java 9 特性详解:从模块系统到 API 增强的全面剖析
java·后端
程序猿chen2 小时前
《JVM考古现场(十八):造化玉碟·用字节码重写因果律的九种方法》
java·jvm·git·后端·面试·java-ee·跳槽
南雨北斗2 小时前
7.安装Laravel 12 PHP需要开启的扩展
后端
异常君2 小时前
【深度解析】Spring/Boot 核心陷阱:事务、AOP 与 Bean 生命周期的常见问题与应对策略
java·后端
福大大架构师每日一题2 小时前
2025-04-13:范围内整数的最大得分。用go语言,给定一个整数数组 start 和一个整数 d,这代表了 n 个区间 [start[i], start[i
后端