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

滴答的雨發表於2020-12-03

 

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

 

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

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

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

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

 

線上kibanahttp://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
{
  "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,謝謝查閱,覺得文章對你有收穫,請多幫推薦。歡迎向我提供更好的資料資訊。

 

 

 

 

相關文章