敏锐的读者会注意,目前为止本书介绍的所有查询都是针对整个词的操作。为了能匹配,只能查找倒排索引中存在的词,最小的单元为单个词。
但如果想匹配部分而不是全部的词该怎么办? 部分匹配 允许用户指定查找词的一部分并找出所有包含这部分片段的词。
与想象的不太一样,对词进行部分匹配的需求在全文搜索引擎领域并不常见,但是如果读者有 SQL 方面的背景,可能会在某个时候实现一个 低效的全文搜索 用下面的 SQL 语句对全文进行搜索:
WHERE text LIKE "%quick%"
AND text LIKE "%brown%"
AND text LIKE "%fox%"
- fox 会与 “fox” 和 “foxes” 匹配。
当然, Elasticsearch 提供分析过程,倒排索引让我们不需要使用这种粗笨的技术。为了能应对同时匹配 “fox” 和 “foxes” 的情况,只需简单的将它们的词干作为索引形式,没有必要做部分匹配。
也就是说,在某些情况下部分匹配会比较有用,常见的应用如下:
匹配邮编、产品序列号或其他 not_analyzed 未分析值,这些值可以是以某个特定前缀开始,也可以是与某种模式匹配的,甚至可以是与某个正则式相匹配的。
输入即搜索(search-as-you-type) ——在用户键入搜索词过程的同时就呈现最可能的结果。
匹配如德语或荷兰语这样有长组合词的语言,如: Weltgesundheitsorganisation (世界卫生组织,英文 World Health Organization)。
邮编与结构化数据
我们会使用美国目前使用的邮编形式(United Kingdom postcodes 标准)来说明如何用部分匹配查询结构化数据。这种邮编形式有很好的结构定义。例如,邮编 W1V 3DG 可以分解成如下形式:
- W1V :这是邮编的外部,它定义了邮件的区域和行政区:
- W 代表区域( 1 或 2 个字母)
- 1V 代表行政区( 1 或 2 个数字,可能跟着一个字符)
- 3DG :内部定义了街道或建筑:
- 3 代表街区区块( 1 个数字)
- DG 代表单元( 2 个字母)
假设将邮编作为 keword 的精确值字段索引,所以可以为其创建索引,如下:
PUT /my_index
{
"mappings": {
"properties": {
"postcode": {
"type": "keyword"
}
}
}
}
然后索引一些邮编:
PUT /my_index/_doc/1
{ "postcode": "W1V 3DG" }
PUT /my_index/_doc/2
{ "postcode": "W2F 8HW" }
PUT /my_index/_doc/3
{ "postcode": "W1F 7HW" }
PUT /my_index/_doc/4
{ "postcode": "WC1N 1LZ" }
PUT /my_index/_doc/5
{ "postcode": "SW5 0BE" }
现在这些数据已可查询。
prefix 前缀查询
为了找到所有以 W1 开始的邮编,可以使用简单的 prefix 查询:
GET /my_index/_search
{
"query": {
"prefix": {
"postcode": "W1"
}
}
}
prefix 查询是一个词级别的底层的查询,它不会在搜索之前分析查询字符串,它假定传入前缀就正是要查找的前缀。
默认状态下, prefix 查询不做相关度评分计算,它只是将所有匹配的文档返回,并为每条结果赋予评分值 1 。它的行为更像是过滤器而不是查询。 prefix 查询和 prefix 过滤器这两者实际的区别就是过滤器是可以被缓存的,而查询不行。
之前已经提过:“只能在倒排索引中找到存在的词”,但我们并没有对这些邮编的索引进行特殊处理,每个邮编还是以它们精确值的方式存在于每个文档的索引中,那么 prefix 查询是如何工作的呢?
回想倒排索引包含了一个有序的唯一词列表(本例是邮编)。对于每个词,倒排索引都会将包含词的文档 ID 列入 倒排表(postings list) 。与示例对应的倒排索引是:
Term: Doc IDs:
-------------------------
"SW5 0BE" | 5
"W1F 7HW" | 3
"W1V 3DG" | 1
"W2F 8HW" | 2
"WC1N 1LZ" | 4
-------------------------
为了支持前缀匹配,查询会做以下事情:
- 扫描词列表并查找到第一个以 W1 开始的词。
- 搜集关联的文档 ID 。
- 移动到下一个词。
- 如果这个词也是以 W1 开头,查询跳回到第二步再重复执行,直到下一个词不以 W1 为止。
这对于小的例子当然可以正常工作,但是如果倒排索引中有数以百万的邮编都是以 W1 开头时,前缀查询则需要访问每个词然后计算结果!
前缀越短所需访问的词越多。如果我们要以 W 作为前缀而不是 W1 ,那么就可能需要做千万次的匹配。
prefix 查询或过滤对于一些特定的匹配是有效的,但使用方式还是应当注意。当字段中词的集合很小时,可以放心使用,但是它的伸缩性并不好,会对我们的集群带来很多压力。可以使用较长的前缀来限制这种影响,减少需要访问的量。
本章后面会介绍另一个索引时的解决方案,这个方案能使前缀匹配更高效,不过在此之前,需要先看看两个相关的查询: wildcard 和 regexp (模糊和正则)。
通配符与正则表达式查询
与 prefix 前缀查询的特性类似, wildcard 通配符查询也是一种底层基于词的查询,与前缀查询不同的是它允许指定匹配的正则式。它使用标准的 shell 通配符查询: ? 匹配任意字符, * 匹配 0 或多个字符。
这个查询会匹配包含 W1F 7HW 和 W2F 8HW 的文档:
GET /my_index/_search
{
"query": {
"wildcard": {
"postcode": "W?F*HW"
}
}
}
- ? 匹配 1 和 2 , * 与空格及 7 和 8 匹配。
设想如果现在只想匹配 W 区域的所有邮编,前缀匹配也会包括以 WC 开头的所有邮编,与通配符匹配碰到的问题类似,如果想匹配只以 W 开始并跟随一个数字的所有邮编, regexp 正则式查询允许写出这样更复杂的模式:
GET /my_index/_search
{
"query": {
"regexp": {
"postcode": "W[0-9].+"
}
}
}
- 这个正则表达式要求词必须以 W 开头,紧跟 0 至 9 之间的任何一个数字,然后接一或多个其他字符。
wildcard 和 regexp 查询的工作方式与 prefix 查询完全一样,它们也需要扫描倒排索引中的词列表才能找到所有匹配的词,然后依次获取每个词相关的文档 ID ,与 prefix 查询的唯一不同是:它们能支持更为复杂的匹配模式。
这也意味着需要同样注意前缀查询存在性能问题,对有很多唯一词的字段执行这些查询可能会消耗非常多的资源,所以要避免使用左通配这样的模式匹配(如: *foo 或 .*foo 这样的正则式)。
数据在索引时的预处理有助于提高前缀匹配的效率,而通配符和正则表达式查询只能在查询时完成,尽管这些查询有其应用场景,但使用仍需谨慎。
prefix 、 wildcard 和 regexp 查询是基于词操作的,如果用它们来查询 analyzed 字段,它们会检查字段里面的每个词,而不是将字段作为整体来处理。
比方说包含 “Quick brown fox” (快速的棕色狐狸)的 title 字段会生成词: quick 、 brown 和 fox 。
会匹配以下这个查询:
{ "regexp": { "title": "br.*" }}
但是不会匹配以下两个查询:
{ "regexp": { "title": "Qu.*" }}
{ "regexp": { "title": "quick br*" }}
- 在索引里的词是 quick 而不是 Quick 。
- quick 和 brown 在词表中是分开的。
查询时输入即搜索
把邮编的事情先放一边,让我们先看看前缀查询是如何在全文查询中起作用的。用户已经渐渐习惯在输完查询内容之前,就能为他们展现搜索结果,这就是所谓的 即时搜索(instant search) 或 输入即搜索(search-as-you-type) 。不仅用户能在更短的时间内得到搜索结果,我们也能引导用户搜索索引中真实存在的结果。
例如,如果用户输入 johnnie walker bl ,我们希望在它们完成输入搜索条件前就能得到:Johnnie Walker Black Label 和 Johnnie Walker Blue Label 。
生活总是这样,就像猫的花色远不只一种!我们希望能找到一种最简单的实现方式。并不需要对数据做任何准备,在查询时就能对任意的全文字段实现 输入即搜索(search-as-you-type) 的查询。
在 短语匹配 中,我们引入了 match_phrase 短语匹配查询,它匹配相对顺序一致的所有指定词语,对于查询时的输入即搜索,可以使用 match_phrase 的一种特殊形式, match_phrase_prefix 查询:
{
"match_phrase_prefix" : {
"brand" : "johnnie walker bl"
}
}
这种查询的行为与 match_phrase 查询一致,不同的是它将查询字符串的最后一个词作为前缀使用,换句话说,可以将之前的例子看成如下这样:
- johnnie
- 跟着 walker
- 跟着以 bl 开始的词
如果通过 validate-query API 运行这个查询查询,explanation 的解释结果为:
“johnnie walker bl*”
与 match_phrase 一样,它也可以接受 slop 参数(参照 slop )让相对词序位置不那么严格:
{
"match_phrase_prefix" : {
"brand" : {
"query": "walker johnnie bl",
"slop": 10
}
}
}
尽管词语的顺序不正确,查询仍然能匹配,因为我们为它设置了足够高的 slop 值使匹配时的词序有更大的灵活性。
但是只有查询字符串的最后一个词才能当作前缀使用。
在之前的 前缀查询 中,我们警告过使用前缀的风险,即 prefix 查询存在严重的资源消耗问题,短语查询的这种方式也同样如此。前缀 a 可能会匹配成千上万的词,这不仅会消耗很多系统资源,而且结果的用处也不大。
可以通过设置 max_expansions 参数来限制前缀扩展的影响,一个合理的值是可能是 50 :
{
"match_phrase_prefix" : {
"brand" : {
"query": "johnnie walker bl",
"max_expansions": 50
}
}
}
参数 max_expansions 控制着可以与前缀匹配的词的数量,它会先查找第一个与前缀 bl 匹配的词,然后依次查找搜集与之匹配的词(按字母顺序),直到没有更多可匹配的词或当数量超过 max_expansions 时结束。
不要忘记,当用户每多输入一个字符时,这个查询又会执行一遍,所以查询需要快,如果第一个结果集不是用户想要的,他们会继续输入直到能搜出满意的结果为止。
索引时优化
到目前为止,所有谈论过的解决方案都是在 查询时(query time) 实现的。这样做并不需要特殊的映射或特殊的索引模式,只是简单使用已经索引的数据。
查询时的灵活性通常会以牺牲搜索性能为代价,有时候将这些消耗从查询过程中转移到别的地方是有意义的。在实时 web 应用中, 100 毫秒可能是一个难以忍受的巨大延迟。
可以通过在索引时处理数据提高搜索的灵活性以及提升系统性能。为此仍然需要付出应有的代价:增加的索引空间与变慢的索引能力,但这与每次查询都需要付出代价不同,索引时的代价只用付出一次。
用户会感谢我们。
Ngrams 在部分匹配的应用
之前提到:“只能在倒排索引中找到存在的词。” 尽管 prefix 、 wildcard 、 regexp 查询告诉我们这种说法并不完全正确,但单个词的查找 确实 要比在词列表中盲目挨个查找的效率要高得多。在搜索之前准备好供部分匹配的数据可以提高搜索的性能。
在索引时准备数据意味着要选择合适的分析链,这里部分匹配使用的工具是 n-gram 。可以将 n-gram 看成一个在词语上 滑动窗口服务器托管 , n 代表这个 “窗口” 的长度。如果我们要 n-gram quick 这个词 —— 它的结果取决于 n 的选择长度:
长度 1(unigram): [ q, u, i, c, k ]
长度 2(bigram): [ qu, ui, ic, ck ]
长度 3(trigram): [ qui, uic, ick ]
长度 4(four-gram): [ quic, uick ]
长度 5(five-gram): [ quick ]
朴素的 n-gram 对 词语内部的匹配 非常有用,即在 Ngram 匹配复合词 介绍的那样。但对于输入即搜索(search-as-you-type)这种应用场景,我们会使用一种特殊的 n-gram 称为 边界 n-grams (edge n-grams)。所谓的边界 n-gram 是说它会固定词语开始的一边,以单词 quick 为例,它的边界 n-gram 的结果为:
q
qu
qui
quic
quick
可能会注意到这与用户在搜索时输入 “quick” 的字母次序是一致的,换句话说,这种方式正好满足即时搜索(instant search)!
索引时输入即搜索
设置索引时输入即搜索的第一步是需要定义好分析链,我们已在 配置分析器 中讨论过,这里会对这些步骤再次说明。
准备索引
第一步需要配置一个自定义的 edge_ngram token 过滤器,称为 autocomplete_filter :
{
"filter": {
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 20
}
}
}
这个配置的意思是:对于这个 token 过滤器接收的任意词项,过滤器会为之生成一个最小固定值为 1 ,最大为 20 的 n-gram 。
然后会在一个自定义分析器 autocomplete 中使用上面这个 token 过滤器:
{
"analyzer": {
"autocomplete": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"autocomplete_filter"
]
}
}
}
自定义的 edge-ngram token 过滤器。
这个分析器使用 standard 分词器将字符串拆分为独立的词,并且将它们都变成小写形式,然后为每个词生成一个边界 n-gram,这要感谢 autocomplete_filter 起的作用。
创建索引、实例化 token 过滤器和分析器的完整示例如下:
PUT /my_index
{
"settings": {
"number_of_shards": 1,
"analysis": {
"filter": {
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 20
}
},
"analyzer": {
"autocomplete": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"autocomplete_filter"
]
}
}
}
}
}
- 首先自定义 token 过滤器。
- 然后在分析器中使用它。
可以拿 analyze API 测试这个新的分析器确保它行为正确:
GET /my_index/_analyze
{
"analyzer": "autocomplete",
"text": "quick brown"
}
结果表明分析器能正确工作,并返回以下词:
q
qu
qui
quic
quick
b
br
bro
brow
brown
{
"tokens": [
{
"token": "q",
"start_offset": 0,
"end_offset": 5,
"type": "",
"position": 0
},
{
"token": "qu",
"s服务器托管tart_offset": 0,
"end_offset": 5,
"type": "",
"position": 0
},
{
"token": "qui",
"start_offset": 0,
"end_offset": 5,
"type": "",
"position": 0
},
{
"token": "quic",
"start_offset": 0,
"end_offset": 5,
"type": "",
"position": 0
},
{
"token": "quick",
"start_offset": 0,
"end_offset": 5,
"type": "",
"position": 0
},
{
"token": "b",
"start_offset": 6,
"end_offset": 11,
"type": "",
"position": 1
},
{
"token": "br",
"start_offset": 6,
"end_offset": 11,
"type": "",
"position": 1
},
{
"token": "bro",
"start_offset": 6,
"end_offset": 11,
"type": "",
"position": 1
},
{
"token": "brow",
"start_offset": 6,
"end_offset": 11,
"type": "",
"position": 1
},
{
"token": "brown",
"start_offset": 6,
"end_offset": 11,
"type": "",
"position": 1
}
]
}
可以用 update-mapping API 将这个分析器应用到具体字段:
PUT /my_index/_mapping
{
"properties": {
"name": {
"type": "text",
"analyzer": "autocomplete"
}
}
}
现在创建一些测试文档:
POST /my_index/_bulk
{"index":{"_id":1}}
{"name":"Brown foxes"}
{"index":{"_id":2}}
{"name":"Yellow furballs"}
查询字段
如果使用简单 match 查询测试查询 “brown fo” :
GET /my_index/_search
{
"query": {
"match": {
"name": "brown fo"
}
}
}
可以看到两个文档同时 都能 匹配,尽管 Yellow furballs 这个文档并不包含 brown 和 fo :
"hits": [
{
"_index": "my_index",
"_id": "1",
"_score": 1.7264473,
"_source": {
"name": "Brown foxes"
}
},
{
"_index": "my_index",
"_id": "2",
"_score": 0.2766258,
"_source": {
"name": "Yellow furballs"
}
}
]
如往常一样, validate-query API 总能提供一些线索:
GET /my_index/_validate/query?explain=true
{
"query": {
"match": {
"name": "brown fo"
}
}
}
explanation 表明查询会查找边界 n-grams 里的每个词:
name:b name:br name:bro name:brow name:brown name:f name:fo
name:f 条件可以满足第二个文档,因为 furballs 是以 f 、 fu 、 fur 形式索引的。回过头看这并不令人惊讶,相同的 autocomplete 分析器同时被应用于索引时和搜索时,这在大多数情况下是正确的,只有在少数场景下才需要改变这种行为。
我们需要保证倒排索引表中包含边界 n-grams 的每个词,但是我们只想匹配用户输入的完整词组( brown 和 fo ),可以通过在索引时使用 autocomplete 分析器,并在搜索时使用 standard 标准分析器来实现这种想法,只要改变查询使用的搜索分析器 analyzer 参数即可:
GET /my_index/_search
{
"query": {
"match": {
"name": {
"query": "brown fo",
"analyzer": "standard"
}
}
}
}
- 覆盖了 name 字段 analyzer 的设置。
换种方式,我们可以在映射中,为 name 字段分别指定 index_analyzer 和 search_analyzer 。因为我们只想改变 search_analyzer ,这里只要更新现有的映射而不用对数据重新创建索引:
PUT /my_index/_mapping
{
"properties": {
"name": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "standard"
}
}
}
- 在索引时,使用 autocomplete 分析器生成边界 n-grams 的每个词。
- 在搜索时,使用 standard 分析器只搜索用户输入的词。
如果再次请求 validate-query API ,当前的解释为:
name:brown name:fo
再次执行查询就能正确返回 Brown foxes 这个文档。
因为大多数工作是在索引时完成的,所有的查询只要查找 brown 和 fo 这两个词,这比使用 match_phrase_prefix 查找所有以 fo 开始的词的方式要高效许多。
补全提示(Completion Suggester)
使用边界 n-grams 进行输入即搜索(search-as-you-type)的查询设置简单、灵活且快速,但有时候它并不够快,特别是当试图立刻获得反馈时,延迟的问题就会凸显,很多时候不搜索才是最快的搜索方式。
Elasticsearch 里的 completion suggester 采用与上面完全不同的方式,需要为搜索条件生成一个所有可能完成的词列表,然后将它们置入一个 有限状态机(finite state transducer) 内,这是个经优化的图结构。为了搜索建议提示,Elasticsearch 从图的开始处顺着匹配路径一个字符一个字符地进行匹配,一旦它处于用户输入的末尾,Elasticsearch 就会查找所有可能结束的当前路径,然后生成一个建议列表。
本数据结构存于内存中,能使前缀查找非常快,比任何一种基于词的查询都要快很多,这对名字或品牌的自动补全非常适用,因为这些词通常是以普通顺序组织的:用 “Johnny Rotten” 而不是 “Rotten Johnny” 。
当词序不是那么容易被预见时,边界 n-grams 比完成建议者(Completion Suggester)更合适。即使说不是所有猫都是一个花色,那这只猫的花色也是相当特殊的。
边界 n-grams 与邮编
边界 n-gram 的方式可以用来查询结构化的数据,比如 本章之前示例 中的邮编(postcode)。当然 postcode 字段需要 analyzed 而不是 not_analyzed ,不过可以用 keyword 分词器来处理它,就好像他们是 not_analyzed 的一样。
keyword 分词器是一个非操作型分词器,这个分词器不做任何事情,它接收的任何字符串都会被原样发出,因此它可以用来处理 not_analyzed 的字段值,但这也需要其他的一些分析转换,如将字母转换成小写。
下面示例使用 keyword 分词器将邮编转换成 token 流,这样就能使用边界 n-gram token 过滤器:
PUT /my_index
{
"settings": {
"number_of_shards": 1,
"analysis": {
"filter": {
"postcode_filter": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 8
}
},
"analyzer": {
"postcode_index": {
"tokenizer": "keyword",
"filter": [
"postcode_filter"
]
},
"postcode_search": {
"tokenizer": "keyword"
}
}
}
}
}
- postcode_index 分析器使用 postcode_filter 将邮编转换成边界 n-gram 形式。
- postcode_search 分析器可以将搜索词看成 not_analyzed 未分析的。
Ngrams 在复合词的应用
最后,来看看 n-gram 是如何应用于搜索复合词的语言中的。德语的特点是它可以将许多小词组合成一个庞大的复合词以表达它准确或复杂的意义。例如:
Aussprachewrterbuch
发音字典(Pronunciation dictionary)
Militrgeschichte
战争史(Military history)
Weikopfseeadler
秃鹰(White-headed sea eagle, or bald eagle)
Weltgesundheitsorganisation
世界卫生组织(World Health Organization)
Rindfleischetikettierungsberwachungsaufgabenbertragungsgesetz
法案考虑代理监管牛和牛肉的标记的职责(The law concerning the delegation of duties for the supervision of cattle marking and the labeling of beef)
有些人希望在搜索 “Wrterbuch”(字典)的时候,能在结果中看到 “Aussprachewrtebuch”(发音字典)。同样,搜索 “Adler”(鹰)的时候,能将 “Weikopfseeadler”(秃鹰)包括在结果中。
处理这种语言的一种方式可以用 组合词 token 过滤器(compound word token filter) 将复合词拆分成各自部分,但这种方式的结果质量依赖于组合词字典的质量。
另一种方式就是将所有的词用 n-gram 进行处理,然后搜索任何匹配的片段——能匹配的片段越多,文档的相关度越大。
假设某个 n-gram 是一个词上的滑动窗口,那么任何长度的 n-gram 都可以遍历这个词。我们既希望选择足够长的值让拆分的词项具有意义,又不至于因为太长而生成过多的唯一词。一个长度为 3 的 trigram 可能是一个不错的开始:
PUT /my_index
{
"settings": {
"analysis": {
"filter": {
"trigrams_filter": {
"type": "ngram",
"min_gram": 3,
"max_gram": 3
}
},
"analyzer": {
"trigrams": {
"tokenizer": "standard",
"filter": [
"lowercase",
"trigrams_filter"
]
}
}
}
},
"mappings": {
"properties": {
"text": {
"type": "text",
"analyzer": "trigrams"
}
}
}
}
- text 字段用 trigrams 分析器索引它的内容,这里 n-gram 的长度是 3 。
使用 analyze API 测试 trigram 分析器:
GET /my_index/_analyze
{
"analyzer": "trigrams",
"text": "Weikopfseeadler"
}
返回以下词项:
wei, ei, ik, ko, kop, opf, pfs, fse, see, eea,ead, adl, dle, ler
索引前述示例中的复合词来测试:
POST /my_index/_bulk
{"index":{"_id":1}}
{"text":"Aussprachewrterbuch"}
{"index":{"_id":2}}
{"text":"Militrgeschichte"}
{"index":{"_id":3}}
{"text":"Weikopfseeadler"}
{"index":{"_id":4}}
{"text":"Weltgesundheitsorganisation"}
{"index":{"_id":5}}
{"text":"Rindfleischetikettierungsberwachungsaufgabenbertragungsgesetz"}
“Adler”(鹰)的搜索转化为查询三个词 adl 、 dle 和 ler :
GET /my_index/_search
{
"query": {
"match": {
"text": "Adler"
}
}
}
正好与 “Weikopfsee-adler” 相匹配:
"hits": [
{
"_index": "my_index",
"_id": "3",
"_score": 2.74445,
"_source": {
"text": "Weikopfseeadler"
}
}
]
类似查询 “Gesundheit”(健康)可以与 “Welt-gesundheit-sorganisation” 匹配,同时也能与 “Militr-ges-chichte” 和 “Rindfleischetikettierungsberwachungsaufgabenbertragungs-ges-etz” 匹配,因为它们同时都有 trigram 生成的 ges :
使用合适的 minimum_should_match 可以将这些奇怪的结果排除,只有当 trigram 最少匹配数满足要求时,文档才能被认为是匹配的:
GET /my_index/_search
{
"query": {
"match": {
"text": {
"query": "Gesundheit",
"minimum_should_match": "80%"
}
}
}
}
这有点像全文搜索中霰弹枪式的策略,可能会导致倒排索引内容变多,尽管如此,在索引具有很多复合词的语言,或词之间没有空格的语言(如:泰语)时,它仍不失为一种通用且有效的方法。
这种技术可以用来提升 召回率 ——搜索结果中相关的文档数。它通常会与其他技术一起使用,例如 shingles(参见 shingles 瓦片词 ),以提高精度和每个文档的相关度评分。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net