1. 基本原理

与推荐系统不同的是,搜索系统比较重要的用户特征是query,信息检索的过程则是根据query,给用户返回doc集合。

传统的检索系统,对文本进行切词, 然后每个词下面会生成一个倒排索引。 query查询时,则是对query进行分词,然后到对应的词进行直接召回即可实现,数据集合的返回。

有了数据集之后,如何给doc排序是一个比较关键的问题,毕竟人的精力是比较有限的,在浩渺的知识大海里面,捞出一条你最想要的doc,并将之排在最前面,需要涉及到算法的设计。而且每个人的需求都是千奇百怪的, 需要呈现出来的排序应当也是不一样的。这一点跟推荐系统很像。

在传统的信息检索系统里面,一般采用TF-IDF和BM25等文本相似度算法来实现和衡量一个doc和query的相似程度。这样的实现方案,纯粹,实在,只需要doc语料即可完成数据集的生成。目前还有大量的检索系统在使用。例如开源的ES的默认标配就是这两种算法。

这种传统信息检索方案比较实在,当遇到大数据的问题,可以采用分布式的方案来实现,特别是一些常用词,可能召出来成千上万的doc,可以通过分布式提高效率, doc分布到多个倒排索引当中,先进行初筛,然后在merge合并。

与传统的信息检索不同的是,目前越来越多的搜索引擎通过向量检索的方式,实现搜索。确实有些问题,传统的信息检索方案是实现不了的,例如以图搜图。因为图是一个数字向量,而不是以文字的形式存在,上述方案很难套用。

不过话说回来,向量检索的结果有时候很难解释,而且向量化的模型比较关键,如果训练的向量出问题了,那召回的结果自然而然就错了。不过目前已经有大量的推荐系统和搜索系统进行使用了,也是经过考验的,核心是要处理好数据和特征,不然真的是garbage in, garbage out 了。

目前比较火的词向量化的常用模型包括:word2vec/fasttext/doc2vec/dnn等等。

说白了,我们的目标是:

  1. 将query转化成vector
  2. 将doc转化成vector

2. example

这里举一个简单的例子, 训练物料使用经典的电影语料库。

https://grouplens.org/datasets/movielens/

这里我们直接使用gensim的doc2vec对电影的数据进行训练。电影的数据维度包括如下:

  • 电影id
  • 电影标题
  • 电影类型
  • 电影标签

doc2vec的输入时,[words, labels]。words是电影标题、电影类型、电影标签的分词结果。而labels是电影id。每个电影id生成200维的数据。具体实现如下:

#!/usr/bin/python2.7
#-- coding: utf-8 --

from gensim.models.doc2vec import Doc2Vec, TaggedDocument
import jieba
import json
from collections import OrderedDict
import csv

# 首先对数据进行预处理
# id, title, genres, tag 
def get_data():
    tag_dict = {}
    tags = csv.reader(open("./movies/tags.csv"))
    for row in tags:
        m_id = row[1]
        tag = row[2]
        if tag_dict.get(m_id, None) == None:
            tags = ""
            tags += tag
            tag_dict[m_id] = tags
        else:
            tags = tag_dict[m_id]
            tags += ","
            tags += tag
            tag_dict[m_id] = tags            
        
    movies = csv.reader(open('./movies/movies.csv'))
    tagged_data = []
    # 将数据导入到tagged_document当中
    for row in movies:
        m_id = row[0]
        title = row[1]
        genres = row[2]
        desc = title + "|" + genres
        if tag_dict.get(m_id, None) != None:
            tags = tag_dict[m_id]
            desc += "|"
            desc += tags
        tagged_data.append(TaggedDocument(words=jieba.lcut(desc.lower()), tags=[m_id]))

    return tagged_data

def tarin_data(tagged_data):
    max_epochs = 100
    vector_size = 200
    alpha = 0.025
    
    model = Doc2Vec(vector_size=vector_size, 
                    alpha=alpha, 
                    min_alpha=0.00025, 
                    min_count=1, 
                    dm=1)
    
    model.build_vocab(tagged_data)
    
    for epoch in range(max_epochs):
        model.train(tagged_data, 
                    total_examples=model.corpus_count, 
                    epochs=model.iter)
        # decrease the learning rate
        model.alpha -= 0.0002
        # fix the learning rate, no decay
        model.min_alpha = model.alpha
    
    model.save("d2v.model")
    print("model saved")

def predict_data():
    model = Doc2Vec.load("d2v.model")
    # to find the vector of a document which is not in training data
    test_data = jieba.lcut("funny".lower())
    v1 = model.infer_vector(test_data)
    print("v1_infer", v1)

    # to find most similar doc using tags
    similar_doc = model.docvecs.most_similar('60756')
    print(similar_doc)

    # to find most similar doc using vector
    similar_doc = model.docvecs.most_similar([v1], topn=10) 
    print(similar_doc)

    # to find vector of doc in trainning data using tags or in other words
    print(model.docvecs["60756"]) 


if __name__ == '__main__':
    #tagged_data = get_data()
    #tarin_data(tagged_data)
    predict_data()

在预测数据的时候,可以给词生成vector, 可以直接用labels找到相近的物品,也可以使用vector找到相近的物品, 同时也可以生成物品的vector。例如上面给出一个funny这词,搜索相关电影,结果如下:

[('2796', 0.6416218280792236), ('3446', 0.6061424016952515), ('126548', 0.5998696088790894), ('171495', 0.5651712417602539), ('901', 0.5470060706138611), ('66509', 0.5319580435752869), ('26726', 0.5319346785545349), ('1184', 0.5318185091018677), ('3395', 0.5307272672653198), ('6860', 0.5305958986282349)]

搜索出来的基本上都是喜剧,直接人工来看,结果还算合格。

我们可以通过TensorBoard将结果展示出来, 具体参考:

#https://github.com/ArdalanM/gensim2tensorboard

import numpy as np
import tensorflow as tf
import os
from gensim.models.word2vec import Word2Vec
from tensorflow.contrib.tensorboard.plugins import projector

log_dir = '/tmp/embedding_log'
if not os.path.exists(log_dir):
    os.mkdir(log_dir)


# load model
model_file = '/tmp/emb.bin'
word2vec = Word2Vec.load(model_file)

# create a list of vectors
embedding = np.empty((len(word2vec.vocab.keys()), word2vec.vector_size), dtype=np.float32)
for i, word in enumerate(word2vec.vocab.keys()):
    embedding[i] = word2vec[word]

# setup a TensorFlow session
tf.reset_default_graph()
sess = tf.InteractiveSession()
X = tf.Variable([0.0], name='embedding')
place = tf.placeholder(tf.float32, shape=embedding.shape)
set_x = tf.assign(X, place, validate_shape=False)
sess.run(tf.global_variables_initializer())
sess.run(set_x, feed_dict={place: embedding})

# write labels
with open(os.path.join(log_dir, 'metadata.tsv'), 'w') as f:
    for word in word2vec.vocab.keys():
        f.write(word + '\n')

# create a TensorFlow summary writer
summary_writer = tf.summary.FileWriter(log_dir, sess.graph)
config = projector.ProjectorConfig()
embedding_conf = config.embeddings.add()
embedding_conf.tensor_name = 'embedding:0'
embedding_conf.metadata_path = os.path.join(log_dir, 'metadata.tsv')
projector.visualize_embeddings(summary_writer, config)

# save the model
saver = tf.train.Saver()
saver.save(sess, os.path.join(log_dir, "model.ckpt"))

print("完成!")

实际上, 在工业级别的应用的流程没有那么简单, 需要对物料进行多次加工,并加入用户行为数据,效果调优, 单纯靠文本的话,很难解决一些蹭热度的文本语义。

第一步,训练word2vec模型,得到词向量和实体短语向量;
第二步,学习商品(doc)的语义向量和商品所属类目(cat)语义向量;
第三步,利用用户行为数据优化商品语义向量。

训练是一个串行的流程,前一步学习到的模型参数用来初始化后一步的模型参数,相当于前一步是为后一步做预训练(pre-training)。

3. 效果评估

线下评估方法首先人工标注一批query和doc的相关性档位作为评估数据集。每个query和doc,计算两种的语义向量的余弦相似度。同一query的所有doc根据余弦相似度分数降序排序,根据标准的相关性档位计算排序的NDCG。然后求评估数据集上的整体NDCG相对于baseline的提升幅度来评估模型的好坏。

根据模型的学习结果,用词向量求和的方法生成query的语义向量。对于doc向量的生成,有好几种方法,我们分别对比了各个方法的效果。定义向量doc1为训练过程的第二步得到的doc向量;doc2为训练过程第二步得到的doc向量、cat向量与第一步得到的词向量加权融合。

首先,我们用和生成query的语义向量同样的方法,把doc中的所有词向量相加的方法,得到doc向量,作为baseline。

我们发现Paragraph Vector方法得到的doc向量的效果并不如直接对词向量求和的方法得到的doc向量;但是当它和词向量、类目向量融合之后,效果大大提升。

对于训练过程第三步得到的doc向量,即利用用户行为数据优化得到的doc向量,我们做了单独的效果对比,分为单独对比和融合对比两个版本。单独对比是指第三步得到的doc向量(定义为doc3)相对于第二步得到的doc向量(doc1)的比较;融合对比是指用第一步得到的词向量、第二步得到的类目向量、第三步得到的doc向量加权求和得到的向量,定义为doc4,与上面定义的doc2,即第二步融合之后的向量的对比。