原文《Implementing A Modern E-Commerce Search》,作者:Alexander Reelsen.
原文内容比较多,所以翻译会分三篇发出:
第一篇:讲述了好的搜索功能由好的索引数据和好的查询语句(即搜索关键词+特征过滤器)组成。电子商务搜索中的产品数据处理(包含:数据清洗、计量单位、重复数据、库存数据)和特定场景的数据建模(包含:变体、多语言、分解分词、价格)
第二篇:一些用例,后续再细化
第三篇:一些用例,后续再细化
在线kibana:http://134.175.121.78:5601/app/dev_tools#/console
(是我自己的服务器搭建的,请大家友好的体验)
简介
搜索功能很难,做好电子商务网站的搜索功能更难。实现一个好的搜索包含两个要素:好的索引数据和好的查询语句(即搜索关键词+特征过滤器),两个元素同时存在的情况是很难的。但在e-discovery这种平台上是很常见的(什么是e-discovery??是政府或法律授权机构通过网络技术向有关行业(如法律、税务机构等)提供的信息交换平台,也称作“电子储存信息”(Electronically stored information,即ESI))。使用e-discovery平台搜索数据的用户拥有深厚的专业知识,也有能力提出适当的查询语句。但是,在电子商务中基本是相反的,你通常有结构化良好的数据,但你的搜索质量却很低。用户并不确切知道他们要搜索什么,他们通常搜索的是品牌名称、产品名称或类似“便宜的”这样的形容词,而且还可能包含拼写错误。
这篇文章要讨论的另一个常用搜索场景是聚合与分析用例。这常见于仪表盘功能,但电子商务搜索通常聚焦于搜索电商产品,尽管聚合常用于深入数据分析,但对我来说,最常见的聚合用例是其可观察的特性,比如在日志、指标或跟踪(logs、metrics、traces)数据上的聚合。
我不会为文章中的每一个用例都提供使用Elasticsearch的解决方案,但我会举例说明我的观点。
为什么电商搜索这么难?
这是一个非常棒的问题,在这么多年之后,我仍然发现这个问题相当难以回答。因为这不是一个事情导致它难以回答,而是许多小事情的共同影响。有时仅仅一个小事情就足以让网站的访问者在眨眼间决定不在你的电子商店中购买。最重要的是,有许多与搜索无关的因素也会把用户赶出你的网站。
我最近的个人经历是在新冠疫情封城期间尝试在Hugendubel商城中订购一本书。Hugendubel商城是德国的读书类专业电商,但它不允许我在没有创建用户账户的情况下下单,而Thalia.de商城允许我这样做,所以我最后选择在Thalia.de商城中下单。这和搜索体验完全没有关系。
在另一个封城期间的案例中,我尝试在Ravensburger商城中为我女儿订购一本书。线上商城告诉我,这本书只能在实体店购买。而且,每当我使用Amazon pay进行支付,却没有收到我的信用卡是否被扣款的通知。我向平台写了一封电子邮件,两周后我得到了反馈:我描述的问题已经转发给负责支付的部门。另一个导致我不想再光顾这个商城的原因是,搜索体验非常糟糕。
但是,让我们不要把重点放在我对网上商店的责骂中,而要放在正确的搜索上。
产品数据
让我们从最高优先级的产品数据开始。没有数据,何谈搜索。经营一个商城意味着,商家提供数据,并且不同商家提供的数据格式会不一样。
1、数据清洗(clean data)
什么是数据清洗?它是发现并纠正数据文件中可识别的错误的最后一道程序,包括检查数据一致性,处理无效值和缺失值等。
对于客户数据,这通常意味着大量的验证:
#、有效的URLS
#、数据类型约束(eg:库存必须是int)
#、范围约束(eg:库存必须是正整数)
#、值匹配,通过表达式或自定义程序代码实现
取决于数据供应商的职业,一些供应商公司还在通过Excel来管理他们的数据(eg:手动在Excel中更新库存数据)。一些供应商有一个成熟的软件系统管理他们的数据并允许你导出此类数据。
这给我们带来了另一个有趣的话题。你接受什么样的数据格式?JSON、XML、EDIFAC或者CSV?你有API或表单上传吗?你该如何处理多年没有更新的数据?
数据清洗是一件很棘手的事情,你需要一个万无一失的处理过程。如果你的数据清洗过程将商家产品价格更改为原始价格十分之一,并且有人下了1000个订单,这种情况怎么办?责任也是很重要的话题。
2、计量单位(UOM)
计量单位(Unit of Measure/Measurement,UOM)。这不仅仅是关于系统指标,而且是关于到不同单位之间的转换。需要对所有数据的值进行规范化,这意味着,如果一个产品的尺寸是英寸,而另一产品的尺寸是厘米,那么就需要一个转换机制来进行适当的范围查询。你还需要确保对不同的产品使用了正确的计量单位,eg:显示器、饮料、视频包装等等。
3、重复数据
如果你经营一个商城,你会发现这些商家销售相同商品的几率很高。
如何处理这种情况?这个问题在图书品类中已经通过ISBN解决了。如果你是世界上最大的商城,你就有能力创建一个ASIN。
(
ISBN(International Standard Book Number)国际标准书号,是专门为识别图书等文献而设计的国际编号。
ASIN:ASIN(Amazon standard identification number),亚马逊为自家产品编的唯一编号
)
也有一些可以考虑的替代方案。你可以为提供的照片检查相似性。复杂的检查方案会浪费很多时间,有时只需简单的考虑检测相同哈希值就足够了。
你也可以比较产品的描述,因为它们通常直接从生产商处复制。另外还有:产品名称、发布日期或计量单位等。
这些替代方案都不是百分百安全的。
4、库存数据
拥有近实时的信息是非常重要的。比如产品是可用的;比如产品不能在2-3天内送达,大多客户不会下单,因为客户往往是冲动性消费。
所以,要么你能查询其他系统(eg:查询商家系统获取最新的数据),要么你的商家提供库存数据。库存数据更新通常比价格或产品内容更新更频繁,因此请确保使用一种轻量级的更新方式。
你可能还需要处理库存信息陈旧的问题,即在你平台上标识可用的商品但在商家处已经不再可用,从而导致订单取消和变更。
数据建模
现在开始为数据建模。首先你获得了一些属性,然后为它们标记上text/keyword标记,就可以开始搜索了。
1、变体
(译者注:变体,即一个产品一个属性存在不同值,就可能有多个变体。即SKU和SPU的概念)
对我来说,最棘手的问题是产品的变体。首先,你需要为不同的属性和它们的组合建模。商家总是将多个变体挂载到一个产品中,即使这些变体本应该是独立的产品。很难制定一个规则来规范什么是变体,什么不是。让我们先一起来看些简单又无处不在的商品:衣服。
#、Color(red, green, yellow, black, orange, white, blue)
#、Size(XXS, XS, S, M, L, XL, XXL, XXXL)
简单的两个维度,却已经有56个独立的产品了。如果是四个维度将会导致变体风暴,而在UI中已经很难显示哪些变体存在,哪些不存在。Amazon商城解决此问题的方案是:在点击属性后,再展现变体信息。
这种场景如何建模呢?这里有三个方案,其付出的成本相差很大。
方案一:每一个变体拥有自己的索引文档。这个方案简单容易实现,但当商品数变多时,会存在很多重复的文档和内容。另外,如果没有指定属性,该如何进行搜索过滤?让我们通过一个t-shirt示例来说明。
这个t-shirt存在不同的颜色和尺寸。
DELETE products PUT products/_bulk?refresh { "index" : {} } { "title" : "Elastic Robot T-Shirt", "size": "M", "color" : "gray" } { "index" : {} } { "title" : "Elastic Robot T-Shirt", "size": "S", "color" : "gray" } { "index" : {} } { "title" : "Elastic Robot T-Shirt", "size": "L", "color" : "gray" } { "index" : {} } { "title" : "Elastic Robot T-Shirt", "size": "M", "color" : "green" } { "index" : {} } { "title" : "Elastic Robot T-Shirt", "size": "S", "color" : "green" } { "index" : {} } { "title" : "Elastic Robot T-Shirt", "size": "L", "color" : "green" }
查询语句如下:
GET products/_search { "query": { "bool": { "must": { "match": { "title": "shirt" } }, "filter": [ { "term": { "color.keyword": "green" } }, { "term": { "size.keyword": "M" } } ] } } }
查询结果只有一条shift数据,但当我们从两个filter中移除一个后,将会返回同一个shift产品的多条document数据。
我们可以通过elasticsearch的field collapsing功能来解决这个问题,但这也意味着在查询时需要多做一些事情。
方案二:我们可以尝试使用elasticsearch的嵌套数据类型,把所有的变体放到一个数组中,如下:
DELETE products PUT products { "mappings": { "properties": { "variants" : { "type": "nested" } } } } POST products/_doc { "title" : "Elastic Robot T-Shirt", "variants" : [ { "size": "S", "color": "gray"}, { "size": "M", "color": "gray"}, { "size": "L", "color": "gray"}, { "size": "S", "color": "green"}, { "size": "M", "color": "green"}, { "size": "L", "color": "green"} ] }
查询语句如下:
GET products/_search { "query": { "bool": { "must": { "match": { "title": "shirt" } }, "filter": [ { "nested": { "path": "variants", "query": { "term": { "variants.color.keyword": "green" } } } }, { "nested": { "path": "variants", "query": { "term": { "variants.size.keyword": "M" } } } } ] } } }
我们也可以在不使用任何filter的情况下进行搜索,且只返回一个文档。需要注意一点是:通过使用自动映射来防止映射爆炸。如果你控制了属性名称,请尽量减少它们的数量并统一规范它们(eg:属性名称size,可以用于多种商品上)
使用 inner_hits 功能也很容易找出匹配的嵌套文档。
那么这个方案有什么问题呢?问题在于产品数据更新。如果你也将库存存储在该索引中,那么单个变体的库存更新将导致整个文档的索引重建。因为库存数量的变更频率,可能会是相当大的开销。但我仍然倾向于这个解决方案,因为我认为库存更新在大多数情况下是可管理的。
方案三:使用 join数据类型,允许我们在查询时将两个文档(产品文档和变体文档)进行关联。
DELETE products PUT products { "mappings": { "properties": { "join_field": { "type": "join", "relations": { "parent_product": "variant" } } } } } PUT products/_bulk?refresh { "index" : { "_id": "robot-shirt" } } { "title" : "Elastic Robot T-Shirt", "join_field" : { "name" : "parent_product" } } { "index" : { "routing": "robot-shirt" } } { "size": "M", "color" : "gray", "join_field" : { "name" : "variant", "parent" : "robot-shirt" } } { "index" : { "routing": "robot-shirt" } } { "size": "S", "color" : "gray", "join_field" : { "name" : "variant", "parent" : "robot-shirt" } } { "index" : { "routing": "robot-shirt" } } { "size": "L", "color" : "gray", "join_field" : { "name" : "variant", "parent" : "robot-shirt" } } { "index" : { "routing": "robot-shirt" } } { "size": "M", "color" : "green", "join_field" : { "name" : "variant", "parent" : "robot-shirt" } } { "index" : { "routing": "robot-shirt" } } { "size": "S", "color" : "green", "join_field" : { "name" : "variant", "parent" : "robot-shirt" } } { "index" : { "routing": "robot-shirt" } } { "size": "L", "color" : "green", "join_field" : { "name" : "variant", "parent" : "robot-shirt" } }
查询语句如下:
GET products/_search { "query": { "bool": { "must": { "match": { "title": "shirt" } }, "filter": [ { "has_child": { "inner_hits": {}, "type": "variant", "query": { "bool": { "filter": [ { "term": { "color.keyword": "green" } }, { "term": { "size.keyword": "M" } } ] } } } } ] } } }
这也会返回匹配的子产品。请注意,使用join数据类型比使用nested数据类型的查询开销要大。因此,只有在高更新负载的情况下我才会考虑使用join数据类型。搜索速度对我来说是最重要的指标之一。
上面这个示例也使用了之前提到的inner_hits 功能,所以你不仅能看到父文档,也可以看到匹配的子文档。请注意,这可能不止一次命中,所以你应该小心的将结果返回到客户端(我总是试图只返回一个变体)。为客户端返回部分变体数据可能很重要,假设你正在搜索一件XL尺寸的绿色shirt,那么返回一个绿色shirt图像比返回尺寸为XL 的shirt图像更加有用。
哪些数据属于变体,哪些数据属于父产品,这很难把控。有些商家会为每一个变体编写一个描述,我是非常反对的,因为不同变体之间,属性应该是唯一的区别。
在进入下一个话题之前,有几个问题是需要我们思考的:
#、如何在UI中处理丢失的变体?
#、如何显示不可用的变体?
#、你能处理2000个产品变体吗?
#、没有任何变体的产品如何展示和建模?
#、确保变体能拥有独立的单价(eg:手机中不同内存会有不同价格)
#、变体的属性能否支持搜索和过滤?(eg:尺寸、颜色等)
2、多语言
如果你的产品名称和描述需要支持多语言,你应该为每种语言设置专用字段,以便你使用自定义分析器。这里包含两个问题:如何识别语言和如何存储内容。首先,如果你不懂这种语言,你需要去识别它。最好的情况下,语言信息和产品数据一起交付给你。
在Elasticsearch中,在推理处理器(inference processor)中内置了一种语言识别(language identification)特性,所以你可以在索引时提取语言信息。
POST _ingest/pipeline/_simulate { "pipeline": { "processors": [ { "inference": { "model_id": "lang_ident_model_1", "inference_config": { "classification": {}}, "field_map": {} } } ] }, "docs": [ { "_source": { "text": "Das ist ein deutscher Text" } } ] }
预测结果为:de(德语)
推测出语言后,你就可以将语言和内容存储到一个特定的字段中,如description.de。如果你能分析出用户搜索关键词使用的语言,你就可以只使用德语分析器搜索德语字段(description.de),从而得到更好的搜索体验。
3、Decompounding分解分词
这是一个德语案例。虽然只针对德语一种语言做处理,但依然很难。尤其是很多产品名称存在复合词的情况。著名的:Eiersollbruchenstellenverursacher,如果你觉得好奇,你可以在Amazon网站上搜索试试,这不是一个假冒产品,但也只是一个例外。还有一些简单的例子,比如Blumentopf(flower pot,花盆)和Kochtopf(cooking pot,烹饪器)。当只输入topf时,是不能搜索出Blumentopf和Kochtopf相关的产品的,因为它们只是这个词的一部分。但英语通过pot单词(上面括号中为德语对应的英语单词)很好的解决了这个问题,pot拥有自己的词条,也被放入倒排索引中。
幸运的是,Lucene有一个分解分词过滤器(decompounder token filter),让我们在德语中可以实现pot的效果,让我们看下面这个例子。
# returns each term GET _analyze { "tokenizer": "standard", "text": [ "Blumentopf", "Kochtopf" ] } GET _analyze?filter_path=tokens.token { "tokenizer": "standard", "filter": [ { "type": "dictionary_decompounder", "word_list": ["topf"] } ], "text": [ "Blumentopf", "Kochtopf" ] }
第一条查询语句不会把topf分解为独立词条,第二条查询语句会将topf分解为独立的词条:
{ "tokens" : [ { "token" : "Blumentopf" }, { "token" : "topf" }, { "token" : "Kochtopf" }, { "token" : "topf" } ] }
但是请注意,让我们用相同的方式执行另一个词条”Stopfwatte”
GET _analyze?filter_path=tokens.token { "tokenizer": "standard", "filter": [ { "type": "dictionary_decompounder", "word_list": ["topf"] } ], "text": [ "Stopfwatte" ] }
返回结果
{ "tokens" : [ { "token" : "Stopfwatte" }, { "token" : "topf" } ] }
你可以尝试向你的用户解释,搜索topf时,Stopfwatte为什么是一个有效的返回结果,但是我相信这会非常难解释清楚。你也可以在多条件bool查询中使用多个should来影响搜索结果评分,但这很可能意味着你用错误的方式解决了这个问题。更好的解决这个问题的地方应该是在创建索引时。
这个的地方就是:断词分解(Hyphenation decompounder)处。这需要一个来自offo项目的XML文件。
GET _analyze { "tokenizer": "standard", "filter": [ { "type": "hyphenation_decompounder", "hyphenation_patterns_path": "analysis/de_DR.xml", "word_list": ["topf"] } ], "text": [ "Blumentopf", "Kochtopf", "Stopfwatte" ] }
运行结果是
{ "tokens" : [ { "token" : "Blumentopf" }, { "token" : "topf" }, { "token" : "Kochtopf" }, { "token" : "topf" }, { "token" : "Stopfwatte" } ] }
如你所见,Stopfwatte就没有创建独立的topf词条,因为现在使用段词字典更好的拆分了词条。
最后,当你决定对词条进行分解时,你需要非常清楚,你需要一个持续更新的单词列表。
你也可以根据你的业务场景创建和修改断词模型(hyphenation patterns)。
4、价格
一个产品只有一个价格的想法是错误的。可能是2个,因为有执行价格。可能是3个,因为有大量的减免。也可能是4个,因为有不同的销售税。可能是52个,因为每个州的销售税不同。但至少这些价格是静态的。
如果某些客户得到永久的10%的折扣,所有的产品,是否要对每一个客户群设定一个价格变体?
在执行搜索时,是否考虑了价格优惠的问题?如何显示价格?你是否想为搜索返回的每个产品再调用一次价格服务来获取价格?
这些都是棘手的问题。关键是你要明白:你的产品不会只有一个价格。
(译者注:淘宝在根据价格过滤时,是根据折扣之前的价格值进行过滤的)
其他推荐阅读:
==============================================================================
over,谢谢查阅,觉得文章对你有收获,请多帮推荐。欢迎向我提供更好的资料信息。