哈囉推薦引擎搭建實戰

導讀:逛逛是哈囉 APP 推出的內容社區,旨在爲用戶提供優質的生活攻略。本次分享以逛逛爲例,介紹一下逛逛業務的推薦升級之路。

什麼是推薦引擎

圖片

推薦引擎本質上是一種信息過濾系統,特點是用戶無明確意圖。它跟搜索不一樣,用戶搜索的時候明確知道自己想看什麼,比如說會輸入一個關鍵詞,或者是有一些特定的條件,而推薦是希望挖掘出用戶感興趣的東西,然後推給用戶。所以,推薦的定義是對於用戶,在特定場景下針對海量物品構建函數,預測用戶對所有物品的感興趣程度並排序生成推薦列表。

如何構建推薦引擎

圖片

推薦要解決的問題是在一個場景下給用戶推薦他感興趣的物品。對於逛逛業務來說,在我負責前原先使用的推薦服務是基於 dataman 的業務流程開發,非常複雜,需要將逛逛業務的帖子數據、用戶行爲數據或用戶本身的數據導入到 hive 裏,通過各個 hive 任務的依賴去計算出推薦的表。如圖,最下面的表用來建推薦的,比如需要給用戶推過去 7 天內看過的一些帖子,或用戶看過的關注過的人發過的帖子。通過這種方式生成若干個任務,每個任務會生成一個 hive 表,最終業務會把這些 hive 表導入到業務的 MySQL 或者 pg 裏。這其實是一種基於規則的推薦引擎。

圖片

爲了引入算法能力,我們構建了一個新的基於算法的推薦引擎,其中最核心的部分在於推薦服務。推薦服務用來接收用戶請求並生成推薦結果,裏面需要用到一些數據源,我們目前使用的是 es 和 redis。其次,引入算法需要有排序模型,本質上是部署在決策流平臺上的。如上圖,黑線的實線可以認爲是請求的流轉過程,虛線可以認爲是數據的流轉過程。數據可以是物品數據、用戶數據或行爲數據,這三個數據存儲在業務的數據庫裏面。由於我們最終推的是物品,所以需要把物品數據導入到數據源裏面。爲什麼使用 es 和 redis 兩個數據源,這裏是有權衡考慮的。es 可以支持比較複雜的搜索條件和排序需求,redis 比較簡單,但 es 的缺點在於性能相對較差。我們會根據不同的召回需求選用不同的數據源存儲,物品數據我們目前存儲在 es。除了物品數據,我們還需要考慮用戶數據和行爲數據,把這些數據拿到後需要做離線定時計算,生成物品的質量分或標籤。此外還需要做離線定時訓練,訓練出排序模型,由於每隔一段時間用戶的行爲模式會發生變化,所說這個模型本身也需要變化。

數據源準備好後,我們整個推薦服務分爲四步。第一步是召回,也就是從這兩個數據源中撈取數據,這部分後面會詳細介紹。第二步和第三步叫粗排和精排,粗排的性能比較好但效果會比較差,精排的性能較差但效果較好。接着我們拿到比較好的結果列表進行重排,再返回給業務後端,這裏沒有把業務後端畫出來。業務後端把這個結果透傳給前端,這樣就得到了用戶的推薦列表。

圖片

接下來,我們對比一下兩種推薦方法。第一,原來基於規則的推薦會造成千人一面,即每個人看到的推薦頁面第一頁都是一樣的。對於基於算法的推薦,由於引入了一些用戶的特徵,因此可以達到千人千面的效果。

從時效性上,基於規則的推薦由於所有的調度任務都放在 dataman 上,它可能是定時的處理,所以時效性較差。基於算法的推薦是基於 flink 任務的實時性開發,所以時效性較高,用戶的行爲數據可以馬上影響到下一頁的推薦結果。

第三,基於規則的推薦無法體現數據的價值,因爲它是根據產品的需求,產品會拍腦袋認爲符合某種模式的帖子效果比較好,並作爲需求提出,寫一個固定的 Hive SQL 語句。基於算法的推薦主要通過模型做數據的排序,所以它會通過模型來反映用戶的行爲數據,能更好體現行爲數據的價值。

圖片

接着我們講一下召回,就是從海量數據中獲取用戶感興趣的帖子。上圖是我們召回的分層結構,原始數據在最下面,包括 pg、hive 和 kafka。hive 是歸檔數據,各種依賴全,方便計算;pg 是實時業務數據,及時反映業務變化;kafka 主要是用戶行爲數據,及時反映用戶行爲。接着,通過搜索平臺和 dataman 兩個產品將這些數據導入到在線存儲的 es 和 redis 中,再通過這兩個數據存儲去支持在線服務進行多路召回。在線服務層和在線存儲層間用中間件做,如 rpc 服務去調用。

圖片

召回後我們需要通過兩輪排序進行優中選優,也就是粗排和精排。在之前的架構圖中提到粗排和精排都走的模型,但實際算法同學只訓練一個模型,所以我們目前粗排是基於規則的。最重要的區別在於粗排要參與排序的數量多,效果較差。精排要參與排序的數量較少,但效果更好。之所以兩輪排序,是在性能和效果間取得平衡。當然如果有需求,也可以引入更多輪排序,但這樣可能 rpc 調用的耗時佔比會更高,可能得不償失。

圖片

在粗排和精排後,最後一步是重排。重排的目的是爲了更細緻地調節推薦列表,比如對逛逛來講,如果有個大 V 發帖子質量分都很高,某個用戶非常關注他,這樣用戶推薦列表裏面可能一頁都是同一個人發的帖子,會造成用戶審美疲勞。所以,需要有一些業務規則去進行打散,目前我們的算法有滑動窗口法和權重分配法。

第二個目的是爲了培養用戶的心智。舉個例子,在逛逛業務裏面我們有一個需求,對於某些人羣需要給他推薦某一類帖子,但帖子質量不一定非常高,排序模型不能精確達到把這些帖子排在前面的目的。所以需要在精排後加入重排,然後把特定的帖子置頂。當然置頂也不是直接全排前面,而是通過跳一個插一個的方式把這些帖子放前面,通過這種方式來培養用戶的心智。

還有另一種做法是流量池的設定,比如運營覺得某些帖子質量比較高,但他並不知道用戶喜不喜歡,或者一些新品也可以放到流量池裏面,給它相應的曝光,這樣能讓用戶看到這些帖子並由用戶來決定質量高不高。用戶如何決定可以通過離線任務來計算,比如看過去一個小時內帖子的 CTR 怎麼樣來判斷質量高不高,這種方式的實現也是在重排中從流量池撈一些帖子進行置頂,再去回收效果。

這裏可以把它抽象成一個算法問題,叫做多臂老虎機問題,解決這個問題的算法是 bandit 算法。多臂老虎機有多個不同的臂,搖動不同的臂會吐出不同數量的金幣,要解決的問題就是通過什麼樣的策略搖臂,能吐出最大數量的金幣。有很多算法可以去解決這個問題,bandit 是其中一種算法,映射到推薦服務來講,就是新品池裏每個帖子是一個臂,帖子 CTR 的值是它吐的金幣數,因爲我們曝光量有限,應該怎樣去把更優秀的帖子獲取更大的曝光率,一種比較簡單的解決算法叫 bandit 算法。

圖片

推薦的步驟講完了,這裏還涉及到一個問題是曝光過濾。曝光過濾的目的是防止給用戶重複推薦物品,右邊是它的實現方案。手機代表用戶的 APP,他的行爲數據由前端採集到 kafka 裏面,再通過 flink 任務實時讀取 kafka 中的數據,寫入到 redis 裏面,redis 裏面就存儲了用戶看過的帖子。當一個用戶從手機上發送請求,錄入到我們的推薦服務上獲取曝光數據。我們從多路召回拿到數據之後,需要經過曝光過濾,從 redis 中獲取用戶看過的帖子並刪除,然後返回給用戶。

這裏有一個細節,是怎麼樣定義給用戶曝光的帖子。假如我們把通過 flink 任務寫出來的數據作爲用戶曝光帖子的話會有個問題,比如用戶一屏刷了 10 個,等他剛看完第 10 個再往下刷的時候,第二屏請求就已經發起了,這時候 flink 的數據還沒來得及寫入 redis,所以會出現重複。考慮到這個問題,我們可以有另一種解決方案,就是推薦服務在我們自己這邊,我們的推薦服務推出來 10 個,就認爲這 10 個全曝光了,直接把它作爲曝光過濾的列表。但這裏也有一個問題,很可能用戶請求了 10 個,但一屏可能只看了兩三個,這時候就有七八個被浪費了。所以我們的曝光過濾有兩個設計目標,一是高時效性,即不能給用戶推薦重複的東西;二是避免浪費,比如接口曝光有 10 個,用戶只看 2 個的話就浪費了 8 個。我們的實現方案就是在 redis 中存兩個 key,一個 key 寫它的真實曝光列表,另一個 key 寫它的接口曝光列表,接口曝光列表是會滾動過期的。我們進行曝光過濾的時候,需要把這兩個列表都拿到取個並集作爲曝光列表,過濾召回的物品。因爲接口曝光數據會定時過期,所以被接口曝光多曝光的一些物品,會在後面適當釋放出來,最終還是用真實曝光數據來作它的曝光過濾結果。

圖片

接下來是冷啓動問題,我們考慮的不是非常多,但它是推薦中大家都面臨的一個問題。冷啓動分爲用戶冷啓動和物品冷啓動,用戶冷啓動我們沒有考慮很多,因爲一般用哈囉逛逛業務的用戶可能只是沒用過這個業務,但其他業務如單車或助力車都已經用過了,所以用戶的信息我們已經存在了。假如對一些不存在信息,比如說 i2i 召回,即系統過濾召回,它的含義是根據用戶過往點贊過或評論過的帖子,去找相似的帖子推出來,這種情況可能訪問爲空,但本質上一些熱門或者 LBS 的路它能訪問結果,所以說用戶冷啓動並不是比較大的問題。

比較大的問題在於物品冷啓動,因爲我們大量的召回階段都依賴於算法離線算的數據,比如帖子的質量分、帖子跟帖子的相似度。我們具體的解決算法分成兩類,一類是在召回階段新增新品召回的方法,讓新品能夠獲得一定的曝光量。還有一類是剛提到流量池的方法,可以把一些新品放到流量池裏,通過 bandit 算法把它展示獲取一定的曝光量。考慮到排序模型中需要用到特徵,因此我們需要對冷啓動用戶或冷啓動物品添加特徵默認值。

圖片

在推薦做完之後,會涉及到很多性能優化。我們推薦服務的步驟非常多,因此整個推薦請求如果耗時比較長的話,我們並不能知道每一步耗時多久,也不能通過單個 case 去看,比如只看某一個請求每一步耗時多久,這種情況可能得到的數據只是特例,並沒有通用性。所以最終我們的做法是埋了一些點,在推薦請求執行過程中,每一步耗時多久都打印了出來,然後通過採集功能進行採集,在 grafana 上根據篩選數據源配置大盤。上圖就是大盤產生結果,大家可以看到我們推薦的平均耗時大概 400 毫秒不到,圖中每條小線代表各個步驟的耗時,每次請求都是各個步驟耗時之和,取各個不重疊的步驟耗時之和來決定整個耗時,這樣我們可以通過曲線的趨勢來看到哪一塊是耗時的性能熱點,我們才需要去解決。

通過這樣的圖表,我們主要分析出兩點。一是召回耗時比較久,因爲涉及到很多路召回。二是排序模型耗時比較久,排序模型耗時會由算法同學去優化。接下來重點介紹一下召回階段如何做性能優化。

圖片

左邊這張圖是我們推薦請求召回一開始實現的版本,在性能優化時就發現了問題。第一步需要進行多路召回,比如 LBS 召回、標籤召回、關注者召回,由於召回複雜所以走的都是 es,後面兩個召回走的都是 redis。我們的做法是每個召回都去線程池中拿一個線程往 es 或 redis 中去查詢,並返回出結果,這樣它的最長耗時就是由所有請求中最長的那個耗時決定的,實際上是木桶原理,即一隻水桶能裝多少水取決於它最短的那塊木板。但這個服務上線之後,在 QPS 比較低的情況下,請求耗時還可以接受;QPS 一旦高起來,耗時就會變得非常長。經過分析,我們發現在訪問 es 的時候,es 請求結果裏面會帶一個叫 took 的字段,描述了 es 在搜索引擎裏面運行了多久。然後我們發現去訪問 es 的時候,從一個線程發起請求到拿到結果,耗時比 took 耗時多了幾十倍。原因就在於一個推薦請求進來之後,它會裂變成十幾個請求,這樣就算我們線程值設置的再大,一個請求就要佔用十幾個線程,很可能 QPS 就上不去。

考慮到這點,我們就變成了右圖的執行邏輯。每個推薦請求進來之後,同樣進行多路召回,但最終從線程池發出的只是兩個請求,一個請求查 es,另一個請求查 redis。這樣一個推薦請求其實只分成了兩個請求,佔了兩個線程。es 是通過 multisearch 機制去訪問的,比如說左邊三路,LBS 召回、標籤召回、關注者召回,我都把它拼起來變成一個請求,這樣只需要請求一次。redis 是通過 pipeline 機制去訪問,這樣在 QPS 提升之後,還是能達到跟左邊一樣,甚至比左邊更好的耗時結果。

圖片

在性能優化之後,穩定性建設也是非常重要的一點。爲了在線服務階段不報錯,我們使用了多重兜底的機制。首先在召回階段引入兜底召回,保證就算其他幾路召回爲空,也能有推薦結果。第二是在排序階段也加入兜底操作,保證就算依賴的排序服務出問題,也能反饋出一個比較合理的推薦結果。另外比如說剛剛提到的 i2i 召回,可能需要獲取用戶曾經操作過的帖子,也就是說有一個外部依賴的服務去獲取。所以我們對所有外部服務的錯誤都提供默認值作爲兜底。除了上述三個在推薦服務裏完成,還要考慮到非常極端的情況,即推薦服務本身故障,所以我們在業務後端對推薦服務也做了兜底,保證用戶能看到東西。

兜底會有個問題在於 SOA 不報錯,這樣我們可能就感知不到,因此必須在兜底做報警,報警我們目前是通過 Argus 來實現。這裏我從 Argus 上截了一些圖,左邊是每路召回數據總數,如果某一路跌到變動比較大的閾值,我們就會認爲這一路出問題了,需要人工排查告警。右邊是依賴的外部服務 SOA 錯誤的告警。

圖片

上圖展示的是推薦效果,左邊是一箇舊版推薦服務的 pv-ctr 和 uv-ctr 的指標,右邊是新版推薦服務的指標。可以看到提升非常大,當然絕對值還是比較小的。

推薦引擎的後續規劃

圖片

最後介紹一下我們推薦服務後續的規劃。未來我們希望在召回這一層再引入向量召回,這也是算法強烈推薦以及覺得效果比較好的召回。因此我們後續的規劃主要是在召回多樣化,召回多樣化有不同層面的含義,在線存儲層我們會引入一種新的存儲介質,用來支持向量搜索的功能,在線服務層我們會增加更多的召回路徑。

圖片

我們還打算把推薦服務變成平臺化的服務。除了目前已經接的本地生活和逛逛業務之外,我們可以接更多業務的推薦。左邊的圖是現有服務的簡化架構,數據存儲主要是用 es 和 redis。推薦服務各自去支撐各自的業務後端。這種情況下,如果再加一個業務,可能需要再加一個推薦服務,但其實大量代碼都是重複的。這樣成本會非常高,維護起來也不容易,所以後續我們會把推薦服務平臺化。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/MGP2UkZZg0nrDJNLEZGtmw