【譯】如何實現一個現代化電子商城搜索?(一)

 

原文《Implementing A Modern E-Commerce Search》,作者:Alexander Reelsen.

 


原文內容比較多,所以翻譯會分三篇發出:

第一篇:講述了好的搜索功能由好的索引數據和好的查詢語句(即搜索關鍵詞+特徵過濾器)組成。電子商務搜索中的產品數據處理(包含:數據清洗、計量單位、重複數據、庫存數據)和特定場景的數據建模(包含:變體、多語言、分解分詞、價格)

第二篇:一些用例,後續再細化

第三篇:一些用例,後續再細化

 

在線kibana//134.175.121.78:5601/app/dev_tools#/console

(是我自己的服務器搭建的,請大家友好的體驗)

 

簡介

    搜索功能很難,做好電子商務網站的搜索功能更難。實現一個好的搜索包含兩個要素:好的索引數據和好的查詢語句(即搜索關鍵詞+特徵過濾器),兩個元素同時存在的情況是很難的。但在e-discovery這種平台上是很常見的(什麼是e-discovery??是政府或法律授權機構通過網絡技術向有關行業(如法律、稅務機構等)提供的信息交換平台,也稱作「電子儲存信息」(Electronically stored
information
,即ESI))。使用e-discovery平台搜索數據的用戶擁有深厚的專業知識,也有能力提出適當的查詢語句。但是,在電子商務中基本是相反的,你通常有結構化良好的數據,但你的搜索質量卻很低。用戶並不確切知道他們要搜索什麼,他們通常搜索的是品牌名稱、產品名稱或類似「便宜的」這樣的形容詞,而且還可能包含拼寫錯誤。

    這篇文章要討論的另一個常用搜索場景是聚合與分析用例。這常見於儀錶盤功能,但電子商務搜索通常聚焦於搜索電商產品,儘管聚合常用於深入數據分析,但對我來說,最常見的聚合用例是其可觀察的特性,比如在日誌、指標或跟蹤(logsmetricstraces)數據上的聚合。

    我不會為文章中的每一個用例都提供使用Elasticsearch的解決方案,但我會舉例說明我的觀點。

 

為什麼電商搜索這麼難?

    這是一個非常棒的問題,在這麼多年之後,我仍然發現這個問題相當難以回答。因為這不是一個事情導致它難以回答,而是許多小事情的共同影響。有時僅僅一個小事情就足以讓網站的訪問者在眨眼間決定不在你的電子商店中購買。最重要的是,有許多與搜索無關的因素也會把用戶趕出你的網站。

    我最近的個人經歷是在新冠疫情封城期間嘗試在Hugendubel商城中訂購一本書。Hugendubel商城是德國的讀書類專業電商,但它不允許我在沒有創建用戶賬戶的情況下下單,而Thalia.de商城允許我這樣做,所以我最後選擇在Thalia.de商城中下單。這和搜索體驗完全沒有關係。

    在另一個封城期間的案例中,我嘗試在Ravensburger商城中為我女兒訂購一本書。線上商城告訴我,這本書只能在實體店購買。而且,每當我使用Amazon
pay
進行支付,卻沒有收到我的信用卡是否被扣款的通知。我向平台寫了一封電子郵件,兩周後我得到了反饋:我描述的問題已經轉發給負責支付的部門。另一個導致我不想再光顧這個商城的原因是,搜索體驗非常糟糕。

    但是,讓我們不要把重點放在我對網上商店的責罵中,而要放在正確的搜索上。

 

產品數據

    讓我們從最高優先級的產品數據開始。沒有數據,何談搜索。經營一個商城意味着,商家提供數據,並且不同商家提供的數據格式會不一樣。

 

1、數據清洗(clean data

    什麼是數據清洗?它是發現並糾正數據文件中可識別的錯誤的最後一道程序,包括檢查數據一致性,處理無效值和缺失值等。

    對於客戶數據,這通常意味着大量的驗證:

    #、有效的URLS

    #、數據類型約束(eg:庫存必須是int

    #、範圍約束(eg:庫存必須是正整數)

    #、值匹配,通過表達式或自定義程序代碼實現

    取決於數據供應商的職業,一些供應商公司還在通過Excel來管理他們的數據(eg:手動在Excel中更新庫存數據)。一些供應商有一個成熟的軟件系統管理他們的數據並允許你導出此類數據。

    這給我們帶來了另一個有趣的話題。你接受什麼樣的數據格式?JSONXMLEDIFAC或者CSV?你有API或表單上傳嗎?你該如何處理多年沒有更新的數據?

    數據清洗是一件很棘手的事情,你需要一個萬無一失的處理過程。如果你的數據清洗過程將商家產品價格更改為原始價格十分之一,並且有人下了1000個訂單,這種情況怎麼辦?責任也是很重要的話題。

 

2、計量單位(UOM

    計量單位(Unit of Measure/MeasurementUOM)。這不僅僅是關於系統指標,而且是關於到不同單位之間的轉換。需要對所有數據的值進行規範化,這意味着,如果一個產品的尺寸是英寸,而另一產品的尺寸是厘米,那麼就需要一個轉換機制來進行適當的範圍查詢。你還需要確保對不同的產品使用了正確的計量單位,eg:顯示器、飲料、視頻包裝等等。

     

3、重複數據

    如果你經營一個商城,你會發現這些商家銷售相同商品的幾率很高。

    如何處理這種情況?這個問題在圖書品類中已經通過ISBN解決了。如果你是世界上最大的商城,你就有能力創建一個ASIN

ISBNInternational Standard Book
Number
)國際標準書號,是專門為識別圖書等文獻而設計的國際編號。

ASINASIN(Amazon standard identification
number)
,亞馬遜為自家產品編的唯一編號

    也有一些可以考慮的替代方案。你可以為提供的照片檢查相似性。複雜的檢查方案會浪費很多時間,有時只需簡單的考慮檢測相同哈希值就足夠了。

    你也可以比較產品的描述,因為它們通常直接從生產商處複製。另外還有:產品名稱、發佈日期或計量單位等。

    這些替代方案都不是百分百安全的。

 

4、庫存數據

    擁有近實時的信息是非常重要的。比如產品是可用的;比如產品不能在2-3天內送達,大多客戶不會下單,因為客戶往往是衝動性消費。

    所以,要麼你能查詢其他系統(eg:查詢商家系統獲取最新的數據),要麼你的商家提供庫存數據。庫存數據更新通常比價格或產品內容更新更頻繁,因此請確保使用一種輕量級的更新方式。

    你可能還需要處理庫存信息陳舊的問題,即在你平台上標識可用的商品但在商家處已經不再可用,從而導致訂單取消和變更。

 

數據建模

    現在開始為數據建模。首先你獲得了一些屬性,然後為它們標記上text/keyword標記,就可以開始搜索了。

 

1、變體

    (譯者註:變體,即一個產品一個屬性存在不同值,就可能有多個變體。即SKU和SPU的概念

對我來說,最棘手的問題是產品的變體。首先,你需要為不同的屬性和它們的組合建模。商家總是將多個變體掛載到一個產品中,即使這些變體本應該是獨立的產品。很難制定一個規則來規範什麼是變體,什麼不是。讓我們先一起來看些簡單又無處不在的商品:衣服。

    #Colorred,
green, yellow, black, orange, white, blue

    #SizeXXS,
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數據。

    我們可以通過elasticsearchfield 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網站上搜索試試,這不是一個假冒產品,但也只是一個例外。還有一些簡單的例子,比如Blumentopfflower pot,花盆)和Kochtopfcooking pot,烹飪器)。當只輸入topf時,是不能搜索出BlumentopfKochtopf相關的產品的,因為它們只是這個詞的一部分。但英語通過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?filter_path=tokens.token

{

  “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%的折扣,所有的產品,是否要對每一個客戶群設定一個價格變體?

    在執行搜索時,是否考慮了價格優惠的問題?如何顯示價格?你是否想為搜索返回的每個產品再調用一次價格服務來獲取價格?

    這些都是棘手的問題。關鍵是你要明白:你的產品不會只有一個價格。

    (譯者註:淘寶在根據價格過濾時,是根據折扣之前的價格值進行過濾的)





其他推薦閱讀:

      Elasticsearch搜索資料匯總



==============================================================================

over,謝謝查閱,覺得文章對你有收穫,請多幫推薦。歡迎向我提供更好的資料信息。