Vespa在匹配数以百万计的男人和女人方面比Elasticsearch更好





OkCupid约会网站的组成部分是潜在合作伙伴的推荐。它们基于您和您的潜在合作伙伴指出的许多偏好的重叠。可以想象,有很多方法可以优化此任务。



但是,您的偏好并不是影响我们将您推荐为潜在合作伙伴(或推荐您作为他人的潜在合作伙伴)的唯一因素。如果我们只是简单地显示所有符合您条件的用户,而没有任何排名,那么该列表将不是最佳选择。例如,如果您忽略了最近的用户活动,则可以花更多的时间与不访问该站点的人交谈。除了您指定的首选项外,我们还使用多种算法和因素向您推荐我们认为您应该看到的人。



我们必须提供最好的结果和几乎无穷无尽的建议。在其他内容更改不那么频繁的应用程序中,可以通过定期更新建议来完成。例如,当使用Spotify的“每周发现”功能时,您会享受一组推荐的曲目,该曲目直到下周才会更改。在OkCupid上,用户可以无休止地实时查看他们的建议。推荐的“内容”本质上是非常动态的(例如,用户可以更改其首选项,配置文件数据,位置,随时停用等)。用户可以更改谁以及如何推荐他,因此我们要确保潜在匹配在给定时间是最佳的。



要利用不同的排名算法并提出实时建议,您需要使用不断更新用户数据并提供过滤和排名潜在候选人能力的搜索引擎。



现有比赛搜索系统有什么问题



OkCupid多年来一直在使用自己的内部搜索引擎。我们不会详细介绍,但是从高度抽象的角度来看,它是一个基于用户空间分片的map-reduce框架,其中每个分片都在内存中包含一些相关的用户数据,这些数据在启用各种过滤器和即时排序时使用。搜索词遍及所有分片,最终将结果合并以返回前k个候选词。我们编写的配对系统运作良好,为什么我们现在决定更改它?



我们知道我们需要更新系统以支持未来几年中各种基于建议的项目。我们知道我们的团队会成长,因此项目数量也会增加。最大的挑战之一是更新架构。例如,添加一条新的用户数据(例如,首选项中的性别标签)需要在模板中使用数百或数千行代码,而部署则需要进行仔细的协调以确保按正确的顺序部署系统的所有部分。简单地尝试添加一种新方法来过滤自定义数据集或对结果进行排名需要花费工程师半天的时间。他必须手动部署生产中的每个部分并监视潜在问题。更重要的是,管理和扩展系统变得越来越困难,因为碎片和副本是在没有安装任何软件的一组机器中手动分配的。



在2019年初,配对系统上的负载增加了,因此我们通过在多台机器上手动放置服务实例来添加了另一组副本。这项工作在后端和开发人员身上花费了数周的时间。在这段时间里,我们也开始注意到嵌入式服务发现,消息队列等方面的性能瓶颈,尽管这些组件以前运行良好,但到了开始质疑这些系统的可扩展性的地步。我们的任务是将大部分工作负载转移到云中。移植此配对系统本身是一项繁琐的任务,但它还涉及其他子系统。



今天,在OkCupid,许多子系统都具有更强大且对云友好的OSS选项,并且该团队在过去两年中成功采用了各种技术。我们将不在这里进行这些项目,而是将重点放在为解决上述问题而采取的步骤上,然后针对我们的建议使用对开发人员更友好且可扩展的搜索引擎:Vespa



真是巧合!为什么OkCupid与Vespa成为朋友



我们的团队历来规模很小。我们从一开始就知道选择搜索引擎会非常困难,因此我们研究了对我们有用的开源选项。两个主要竞争者是Elasticsearch和Vespa。



弹性搜索



它是一种流行的技术,具有较大的社区,良好的文档和支持。有很多功能,甚至可以在Tinder上使用。可以使用PUT映射添加新的架构字段,可以使用结构化的REST调用进行查询,还支持按查询时间进行排名,支持编写自定义插件等。在扩展和维护方面,您只需要定义分片数,并且系统本身处理副本分发。扩展要求重建具有更多分片的另一个索引。



我们放弃Elasticsearch的主要原因之一是内存中缺少真正的部分更新。这对于我们的用例非常重要,因为我们要索引的文档由于喜欢,消息等原因而必须经常更新。与广告或内容等内容相比,这些文档本质上是动态的图片,大多数是具有常量属性的静态对象。因此,更新的低效率读写周期是我们的主要性能问题。



大黄蜂类



源代码仅在几年前才打开。开发人员宣布支持实时存储,搜索,排名和组织大数据。Vespa支持的功能:



  • ( , 40-50 . )

  • ,

  • (, TensorFlow)

  • YQL (Yahoo Query Language) REST

  • Java-


在扩展和维护方面,您不再需要考虑分 -您可以为内容节点设置布局,而Vespa会自动处理如何分片文档,复制和分发数据。此外,每当您添加或删除节点时,数据都会自动从副本还原并重新分发。扩展只是意味着更新配置以添加节点,并允许Vespa实时自动重新分发此数据。



总体而言,Vespa似乎最适合我们的用例。 OkCupid包含有关用户的许多不同信息,以帮助他们找到最佳匹配-就过滤器和排序而言,有一百多个参数!我们将始终添加过滤器和排序,因此保持此工作流程非常重要。在输入和查询方面,Vespa与我们现有的系统最为相似。也就是说,我们的系统还需要在匹配请求期间处理内存中的快速部分更新和实时处理。 Vespa还具有更灵活,更简单的排名结构。与Elasticsearch中查询的不便结构相比,另一个不错的好处是可以在YQL中表达查询。在扩展和维护方面,然后证明Vespa的自动数据分发功能对我们规模较小的团队非常有吸引力。总体而言,发现Vespa比Elasticsearch更好地支持我们的用例和性能要求,同时更易于维护。



Elasticsearch是一个更知名的引擎,我们可以从Tinder的经验中受益,但任何选择都需要大量的初步研究。同时,Vespa为生产中的许多系统提供服务,例如具有数十亿图像的Zedge,Flickr,每秒超过十万个请求的Yahoo Gemini Ads广告平台,以向10亿月活跃用户提供广告。这使我们有信心成为经过考验的,高效且可靠的选择-实际上,Vespa甚至早于Elasticsearch。



此外,Vespa开发人员已被证明非常外向且乐于助人。Vespa最初用于广告和内容。据我们所知,它尚未在约会网站上使用。起初很难集成引擎,因为我们有一个独特的用例,但事实证明Vespa团队反应迅速,并快速优化了系统以帮助我们处理出现的若干问题。



Vespa的工作原理以及OkCupid中的搜索内容







在深入介绍我们的Vespa示例之前,这里是其工作原理的快速概述。 Vespa是众多服务的集合,但是每个Docker容器都可以配置为admin / config主机,无状态Java容器主机和/或有状态C ++内容主机。带有配置,组件,ML模型等的应用程序包可以通过State API进行部署在配置集群中,该集群负责将更改应用于容器集群和内容集群。供稿请求和其他请求通过供稿更新到达内容集群或将请求分叉到内容层(在其中发生分布式请求执行)之前,会通过HTTP通过无状态Java容器(允许自定义处理)。在大多数情况下,部署新的应用程序包仅需几秒钟,而Vespa会在容器和内容集群中实时处理这些更改,因此您几乎不必重新启动任何操作。



搜索是什么样的?



Vespa群集文档包含各种用户特定的属性。模式定义定义文档类型字段以及包含适用的排名表达式集的排名配置文件。假设我们有一个表示这样的用户的架构定义



search user {

    document user {

        field userId type long {
            indexing: summary | attribute
            attribute: fast-search
            rank: filter
        }

        field latLong type position {
            indexing: attribute
        }

        # UNIX timestamp
        field lastOnline type long {
            indexing: attribute
            attribute: fast-search
        }

        # Contains the users that this user document has liked
        # and the corresponding weights are UNIX timestamps when that like happened 
        field likedUserSet type weightedset<long> {
            indexing: attribute
            attribute: fast-search
        }
        
   }

    rank-profile myRankProfile inherits default {
        rank-properties {
            query(lastOnlineWeight): 0
            query(incomingLikeWeight): 0
        }

        function lastOnlineScore() {
            expression: query(lastOnlineWeight) * freshness(lastOnline)
        }

        function incomingLikeTimestamp() {
            expression: rawScore(likedUserSet)
        }

        function hasLikedMe() {
            expression:  if (incomingLikeTimestamp > 0, 1, 0)
        } 

        function incomingLikeScore() {
            expression: query(incomingLikeWeight) * hasLikedMe
        }

        first-phase {
            expression {
                lastOnlineScore + incomingLikeScore
            }
        }

        summary-features {
            lastOnlineScore incomingLikeScore
        }
    }
    
}


符号indexing: attribute表示应将这些字段存储在内存中,以实现这些字段的最佳读写性能。



假设我们用这些自定义文档填充了集群。然后,我们可以对以上任何字段进行过滤和排名。例如,向默认搜索引擎发出POST请求,http://localhost:8080/search/以查找777距我们位置50英里以内的用户(自该时间戳记以来一直在线),而不是我们自己的用户,这些用户1592486978按上次活动进行排名,并保留前两名。我们还选择摘要功能,以查看排名配置文件中每个排名表达式的贡献:



{
    "yql": "select userId, summaryfeatures from user where lastOnline > 1592486978 and !(userId contains \"777\") limit 2;",
    "ranking": {
        "profile": "myRankProfile",
        "features": {
            "query(lastOnlineWeight)": "50"
        }
    },
    "pos": {
        "radius": "50mi",
        "ll": "N40o44'22;W74o0'2",
        "attribute": "latLong"
    },
    "presentation": {
        "summary": "default"
    }
}


我们可以得到如下结果:



{
    "root": {
        "id": "toplevel",
        "relevance": 1.0,
        "fields": {
            "totalCount": 317
        },
        "coverage": {
            "coverage": 100,
            "documents": 958,
            "full": true,
            "nodes": 1,
            "results": 1,
            "resultsFull": 1
        },
        "children": [
            {
                "id": "index:user/0/bde9bd654f1d5ae17fd9abc3",
                "relevance": 48.99315843621399,
                "source": "user",
                "fields": {
                    "userId": -5800469520557156329,
                    "summaryfeatures": {
                        "rankingExpression(incomingLikeScore)": 0.0,
                        "rankingExpression(lastOnlineScore)": 48.99315843621399,
                        "vespa.summaryFeatures.cached": 0.0
                    }
                }
            },
            {
                "id": "index:user/0/e8aa37df0832905c3fa1dbbd",
                "relevance": 48.99041280864198,
                "source": "user",
                "fields": {
                    "userId": 6888497210242094612,
                    "summaryfeatures": {
                        "rankingExpression(incomingLikeScore)": 0.0,
                        "rankingExpression(lastOnlineScore)": 48.99041280864198,
                        "vespa.summaryFeatures.cached": 0.0
                    }
                }
            }
        ]
    }
}


在通过匹配结果排序进行滤波之后,计算用于对结果进行排名的第一阶段(first-phase)的表达式返回的相关性是在我们在查询中指定排名资料中执行第一阶段的所有排名功能的结果的总分ranking.profile myRankProfileranking.features我们在列表中定义query(lastOnlineWeight)50 然后由我们使用的唯一排名表达式引用该列表lastOnlineScore它使用内置的排名功能 freshness,如果属性中的时间戳比当前时间戳更新,则该排名数字将接近1。只要一切顺利,这里就没有什么复杂的了。



与静态内容不同,此内容可以影响是否显示给用户。例如,他们可能喜欢您!我们可以为每个用户文档的一个加权字段 likedUserSet建立索引,其中包含他们喜欢的用户的ID作为键以及该事件发生时的时间戳值。这样就可以轻松筛选出喜欢您的人(例如,likedUserSet contains \”777\”在YQL中添加表达式),但是如何在排名过程中包括这些信息?如何在结果中增加喜欢我们的人的用户的举止?



在先前的结果中,这incomingLikeScore两个匹配的排名表达式均为0。用户6888497210242094612实际上喜欢用户777但是即使我们放了,目前也无法在排名中使用"query(incomingLikeWeight)": 50。我们可以在YQL中使用rank函数(该函数的第一个也是唯一的第一个参数rank()确定文档是否匹配,但是所有参数都用于计算排名分数),然后在YQL排名表达式中使用dotProduct来存储和检索原始分数(在这种情况下,用户喜欢我们的时间戳),例如,以这种方式:



{
    "yql": "select userId,summaryfeatures from user where !(userId contains \"777\") and rank(lastOnline > 1592486978, dotProduct(likedUserSet, {\"777\":1})) limit 2;",
    "ranking": {
        "profile": "myRankProfile",
        "features": {
            "query(lastOnlineWeight)": "50",
            "query(incomingLikeWeight)": "50"
        }
    },
    "pos": {
        "radius": "50mi",
        "ll": "N40o44'22;W74o0'2",
        "attribute": "latLong"
    },
    "presentation": {
        "summary": "default"
    }
}


{
    "root": {
        "id": "toplevel",
        "relevance": 1.0,
        "fields": {
            "totalCount": 317
        },
        "coverage": {
            "coverage": 100,
            "documents": 958,
            "full": true,
            "nodes": 1,
            "results": 1,
            "resultsFull": 1
        },
        "children": [
            {
                "id": "index:user/0/e8aa37df0832905c3fa1dbbd",
                "relevance": 98.97595807613169,
                "source": "user",
                "fields": {
                    "userId": 6888497210242094612,
                    "summaryfeatures": {
                        "rankingExpression(incomingLikeScore)": 50.0,
                        "rankingExpression(lastOnlineScore)": 48.97595807613169,
                        "vespa.summaryFeatures.cached": 0.0
                    }
                }
            },
            {
                "id": "index:user/0/bde9bd654f1d5ae17fd9abc3",
                "relevance": 48.9787037037037,
                "source": "user",
                "fields": {
                    "userId": -5800469520557156329,
                    "summaryfeatures": {
                        "rankingExpression(incomingLikeScore)": 0.0,
                        "rankingExpression(lastOnlineScore)": 48.9787037037037,
                        "vespa.summaryFeatures.cached": 0.0
                    }
                }
            }
        ]
    }
}


现在68888497210242094612,由于用户喜欢我们的用户,因此该用户已升至顶部,它incomingLikeScore具有完整的含义。当然,当他喜欢我们时,我们实际上有一个时间戳记,以便我们可以在更复杂的表达式中使用它,但是现在,让我们简单一点。



这演示了使用排名系统过滤和排名结果的机制。排序框架提供了一种灵活的方法,可以在查询过程中将表达式(通常只是数学表达式)应用于匹配项。



在Java中设置中间件



如果我们想采用不同的路线并使该dotProduct表达式隐式成为每个请求的一部分,该怎么办?这是自定义Java容器层进入的地方-我们可以编写一个自定义Searcher组件这使您可以处理任意参数,重写查询并以特定方式处理结果。这是Kotlin中的一个示例:



@After(PhaseNames.TRANSFORMED_QUERY)
class MatchSearcher : Searcher() {

    companion object {
        // HTTP query parameter
        val USERID_QUERY_PARAM = "userid"

        val ATTRIBUTE_FIELD_LIKED_USER_SET = “likedUserSet”
    }

    override fun search(query: Query, execution: Execution): Result {
        val userId = query.properties().getString(USERID_QUERY_PARAM)?.toLong()

        // Add the dotProduct clause
        If (userId != null) {
            val rankItem = query.model.queryTree.getRankItem()
            val likedUserSetClause = DotProductItem(ATTRIBUTE_FIELD_LIKED_USER_SET)
            likedUserSetClause.addToken(userId, 1)
            rankItem.addItem(likedUserSetClause)        
       }

        // Execute the query
        query.trace("YQL after is: ${query.yqlRepresentation()}", 2)
        return  execution.search(query)
    }
}


然后,在我们的services.xml文件中,我们可以如下配置该组件:



...       
         <search>
            <chain id="default" inherits="vespa">
                <searcher id="com.okcupid.match.MatchSearcher" bundle="match-searcher"/>
            </chain>
        </search>
        <handler id="default" bundle="match-searcher">
            <binding>http://*:8080/match</binding>
        </handler>
...


然后,我们只需创建并部署应用程序包,然后向自定义处理程序发出请求http://localhost:8080/match-?userid=777



{
    "yql": "select userId,summaryfeatures from user where !(userId contains \"777\") and rank(lastOnline > 1592486978) limit 2;",
    "ranking": {
        "profile": "myRankProfile",
        "features": {
            "query(lastOnlineWeight)": "50",
            "query(incomingLikeWeight)": "50"
        }
    },
    "pos": {
        "radius": "50mi",
        "ll": "N40o44'22;W74o0'2",
        "attribute": "latLong"
    },
    "presentation": {
        "summary": "default"
    }
}


我们得到与以前相同的结果!请注意,在Kotlin代码中,我们添加了一个追溯以在更改后输出YQL视图,因此,如果tracelevel=2在URL参数中进行了设置,则还将显示响应:



...
                    {
                        "message": "YQL after is: select userId, summaryfeatures from user where ((rank(lastOnline > 1592486978, dotProduct(likedUserSet, {\"777\": 1})) AND !(userId contains \"777\") limit 2;"
                    },
...


Java中间件容器是一个强大的工具,可以通过Searcher或使用Renderer本地生成结果来添加自定义处理逻辑我们自定义我们的搜索器组件处理上述情况以及我们想在搜索中隐含的其他方面。例如,我们支持的产品概念之一就是“互惠”的思想-您可以搜索具有特定条件(例如年龄范围和距离)的用户,但是您还必须满足候选人的搜索条件。为了在我们的Searcher组件中支持此功能,我们可以获取正在搜索的用户的文档,以在随后的分叉查询中提供其某些属性以进行过滤和排名。排名框架和自定义中间件一起提供了一种支持多种用例的灵活方式。在这些示例中,我们仅涵盖了几个方面,但是在这里 您可以找到详细的文档。



我们如何构建Vespa集群并将其投入生产



在2019年春季,我们开始计划新系统。在此期间,我们还联系了Vespa团队,并定期咨询我们的用例。我们的运营团队评估并建立了初始集群设置,后端团队开始记录,设计和制作各种Vespa用例的原型。



原型制作的第一阶段



OkCupid后端系统是用Golang和C ++编写的。为了编写自定义的Vespa逻辑组件,以及使用Java Vespa HTTP feed客户端API提供较高的提要速率,我们不得不对JVM环境有所了解-我们最终在设置Vespa组件和提要管道中使用了Kotlin。



移植应用程序逻辑并发布Vespa功能花了几年时间,并根据需要与Vespa团队进行了咨询。匹配引擎的大多数系统逻辑都是用C ++编写的,因此我们还添加了逻辑,以转换当前的过滤器并将数据模型分类为等效的YQL查询,然后通过REST向Vespa集群发出该查询。早期,我们还负责创建良好的管道,以使用完整的文档用户群重新填充集群。原型制作必须进行许多更改才能确定要使用的正确字段类型,并且无意间需要重新提交文档提要。



监控和压力测试



在创建Vespa搜索集群时,我们必须确保两件事:它可以处理预期数量的搜索查询和记录,并且该系统提供的建议在质量上与现有的配对系统相当。



在进行负载测试之前,我们到处都添加了Prometheus指标。Vespa-exporter提供了大量统计信息,而Vespa本身也提供了少量附加指标。基于此,我们创建了各种Grafana仪表板,用于每秒请求,延迟,Vespa进程的资源利用率等。我们还运行vespa-fbench来测试查询性能。在Vespa开发人员的帮助下,我们已经确定,由于相对较高在静态请求的成本方面,我们分组的现成布局将提供更快的结果。在平面布局中,添加更多的节点基本上只会降低动态查询的成本(即查询的一部分取决于索引文档的数量)。分组的布局意味着每个配置的站点组将包含一组完整的文档,因此一个组可以满足请求。由于静态请求的成本很高,同时保持节点数不变,所以我们显着提高了吞吐量,将数量从一个固定组增加到三个。最后,当我们对静态基准测试的可靠性充满信心时,我们还实时测试了未报告的“影子流量”。



优化性能



结帐性能是我们早期面临的最大障碍之一。从一开始,即使在1000 QPS(每秒请求数)下,我们也无法处理更新。我们广泛使用加权集字段,但是起初它们并不有效。幸运的是,Vespa的开发人员迅速帮助解决了这些问题以及其他与数据分发有关的问题。他们后来还添加了有关Feed尺寸设置的大量文档,我们在一定程度上使用了这些文档:大加权集中的整数字段(如果可能)允许通过设置进行批处理visibility-delay通过使用多个条件更新并依赖属性字段(即在内存中),以及通过压缩和合并fmdov管道中的操作来减少来自客户端的往返数据包数量。现在,管道在稳定状态下安静地处理3000 QPS,而当由于某种原因而出现这种峰值时,我们的谦虚集群正在处理11K QPS更新。



建议的质量



在确信集群可以处理负载之后,有必要验证建议的质量是否不比现有系统差。评级实施中的任何细微偏差都会对建议的整体质量和整个生态系统产生巨大影响。我们应用了一个实验系统Vespa在某些测试组中,而对照组则继续使用现有系统。然后分析了几个业务指标,重复并记录了问题,直到Vespa组的表现好于对照组(如果不是更好)。一旦我们对Vespa的结果充满信心,就可以轻松地将比赛请求转发至Vespa集群。我们能够毫不费力地将所有搜索流量启动到Vespa集群!



系统图



以简化的形式,新系统的最终体系结构图如下所示:







Vespa现在如何运作,下一步是什么



让我们将Vespa对查找器的状态与以前的系统进行比较:



  • 模式更新

    • 之前:一周内有数百行新代码,与多个子系统进行精心协调的部署

    • :
  • /

    • :

    • : . , !


    • : ,

    • : , Vespa . -


总体而言,Vespa集群的设计和维护方面帮助开发了所有OkCupid产品。2020年1月下旬,我们将Vespa集群投入生产,并为寻找配对提供了所有建议。我们还添加了数十个新字段,排名表达式和用例,以支持今年所有新功能,例如Stacks与以前的配对系统不同,我们现在在查询时使用实时机器学习模型。



下一步是什么?



对我们而言,Vespa的主要优势之一是直接支持使用张量进行排名以及与使用TensorFlow等框架训练的模型进行集成。这是未来几个月我们将开发的主要功能之一。我们已经在某些用例中使用了张量,并且将很快着眼于集成不同的机器学习模型,希望这些模型能够更好地为用户预测结果和匹配。



另外,Vespa最近宣布支持多维最近邻索引,这些索引是完全实时,可同时搜索和动态更新的。我们对探索其他用于实时最近邻索引搜索的用例非常感兴趣



OkCupid和Vespa。走!



很多人听说过Elasticsearch或在其中使用过Elasticsearch,但是Vespa周围没有这么大的社区。我们相信,许多其他Elasticsearch应用程序在Vespa上会更好地工作。对OkCupid来说很棒,很高兴我们切换到了它。这种新的体系结构使我们能够更快地发展和开发新功能。我们是一家相对较小的公司,因此不必担心服务的复杂性就很好。现在,我们准备好更好地扩展我们的搜索引擎。没有Vespa,我们当然不可能取得过去一年所取得的进步。有关Vespa技术能力的更多信息,请务必@jobergum查看“电子商务指南中Vespa AI



我们迈出了第一步,并喜欢Vespa开发人员。他们给我们发了一条消息,结果真是巧合!没有Vespa团队的帮助,我们无法做到这一点。特别感谢@jobergum@geirst提供有关排名和查询处理的建议,以及@kkraune@vekterli的支持。从对应用案例的深入了解到诊断性能问题并立即对Vespa引擎进行改进,Vespa团队为我们提供的支持和工作水平实在令人称奇。@vekterli同志甚至飞到了我们在纽约的办公室,并直接与我们合作了一个星期,以帮助集成引擎。非常感谢Vespa团队!



总而言之,我们仅涉及Vespa使用的几个方面,但是如果没有我们的后端和运营团队在过去一年中的巨大努力,这一切都是不可能的。我们遇到了许多独特的挑战,以弥合现有系统与更现代的技术堆栈之间的鸿沟,但这是其他文章的主题。



All Articles