获取最相似的行并按相似度排序 - 性能改进
Posted
技术标签:
【中文标题】获取最相似的行并按相似度排序 - 性能改进【英文标题】:Get most similar rows and order them by similarity - performance improvement 【发布时间】:2015-05-01 08:14:29 【问题描述】:我有items
表,其结构类似于:
id
user_id
feature_1
feature_2
feature_3
...
feature_20
feature...
的大部分字段是数字,其中 3-4 个包含文本。
现在我需要为给定的项目找到最相似的项目(具有完全相同的字段并具有一定的权重)并按相似度对它们进行排序。
我可以这样做:
select (IF (feature_1 = 'xxx1', 100, 0) +
IF (feature_2 = 'xxx2', 100, 0) +
IF (feature_3 = 'xxx3', 100, 0) +
IF (feature_4 = 'xxx4', 1, 0) +
... +
IF (feature_20 = 'xxx20', 1, 0))
AS score, id from `items` where `id` <> 'yyy'
group by `id` having `score` > '0' order by `score` desc;
当然,代替xxx
,我为我想比较的项目输入了这个字段的有效值,代替yyy
,我输入了我比较的项目的ID(我不想将它包含在结果中)。对于每个字段,我可以指定我想用于相似度的权重(这里为前三个 100,其余为 1)
Getting most similar rows in mysql table and order them by similarity 中使用了完全相同的技术
现在是表演。我已经生成了大约 100000 个项目的表。为一件商品查找相似商品大约需要 0.4 second
。即使我可以减少比较需要包含的 feature_ 字段的数量(而且我可能不会被允许这样做),这样的集合也需要大约 0.16-0.2 second
。
现在情况会更糟。我需要为属于一个用户的所有项目找到类似的项目。假设用户有 100 个项目。我需要将它们全部从数据库中取出,像上面这样运行 100 个查询,然后按分数对所有内容进行排序并删除重复项(在 php 中但这不是问题),然后再次显示整个记录(当然最终结果将分页)。
所以:
我需要运行 100 多个查询才能实现这一点(我不知道是否可以在不明确将值放入xxx
位置的情况下运行此类查询)
实现该目标需要 100 x 0.4 秒 = 40 秒
问题:
是否可以改进上述查询(使用索引或重建它)以使其运行得更快 是否可以重建查询以获取相似的项目,而不是针对一项,而是针对多个项目(一个用户的所有项目)我还需要补充一点,并非所有项目都填写了所有 feature
字段(它们是 nullable
)所以如果我为具有例如 feature_15 字段 null
的项目寻找类似项目我不想要将这个 feature_15
字段包含到 score
中,因为它对于这个项目是未知的。
编辑
我已经按照 @pala 的建议创建了结构(数据库结构如下)。现在,features
表中有 25 条记录,feature_watch
表中有2138959
(是的,超过 200 万条)记录。
当我运行示例查询时:
select if2.watch_id, sum(f.weight) AS `sum` from feature_watch if1
inner join feature_watch if2 on if1.feature_id = if2.feature_id
and if1.feature_value = if2.feature_value
and if1.watch_id <> if2.watch_id
inner join features f on if2.feature_id = f.id
where if1.watch_id = 71 group by if2.watch_id ORDER BY sum DESC
现在需要在1-2 seconds
之间才能获得相同的结果。我错过了什么吗?
CREATE TABLE IF NOT EXISTS `features` (
`id` int(10) unsigned NOT NULL,
`name` varchar(100) COLLATE utf8_unicode_ci NOT NULL,
`weight` tinyint(3) unsigned NOT NULL,
`created_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
`updated_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS `feature_watch` (
`id` int(10) unsigned NOT NULL,
`feature_id` int(10) unsigned NOT NULL,
`watch_id` int(10) unsigned NOT NULL,
`user_id` int(10) unsigned NOT NULL,
`feature_value` varchar(150) COLLATE utf8_unicode_ci DEFAULT NULL
) ENGINE=InnoDB AUTO_INCREMENT=2142999 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
ALTER TABLE `features`
ADD PRIMARY KEY (`id`), ADD UNIQUE KEY `features_name_unique` (`name`), ADD KEY `weight` (`weight`);
ALTER TABLE `feature_watch`
ADD PRIMARY KEY (`id`), ADD KEY `feature_watch_user_id_foreign` (`user_id`), ADD KEY `feature_id` (`feature_id`,`feature_value`), ADD KEY `watch_id` (`watch_id`);
ALTER TABLE `features`
MODIFY `id` int(10) unsigned NOT NULL AUTO_INCREMENT,AUTO_INCREMENT=26;
ALTER TABLE `feature_watch`
MODIFY `id` int(10) unsigned NOT NULL AUTO_INCREMENT,AUTO_INCREMENT=2142999;
ALTER TABLE `feature_watch`
ADD CONSTRAINT `feature_watch_feature_id_foreign` FOREIGN KEY (`feature_id`) REFERENCES `features` (`id`),
ADD CONSTRAINT `feature_watch_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
ADD CONSTRAINT `feature_watch_watch_id_foreign` FOREIGN KEY (`watch_id`) REFERENCES `watches` (`id`) ON DELETE CASCADE;
EDIT2
对于以下查询:
select if2.watch_id, sum(f.weight) AS `sum` from feature_watch if1 inner join feature_watch if2 on if1.feature_id = if2.feature_id and if1.feature_value = if2.feature_value and if1.watch_id <> if2.watch_id inner join features f on if2.feature_id = f.id where if1.watch_id = 71 AND if2.`user_id` in (select `id` from `users` where `is_private` = '0') and if2.`user_id` <> '1' group by if2.watch_id ORDER BY sum DESC
EXPLAIN
给出:
id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE if1 ref watch_id,compound,feature_id watch_id 4 const 22 Using where; Using temporary; Using filesort
1 SIMPLE f eq_ref PRIMARY PRIMARY 4 watches10.if1.feature_id 1 NULL
1 SIMPLE if2 ref watch_id,compound,feature_id,user_id compound 457 watches10.if1.feature_id,watches10.if1.feature_val... 441 Using where; Using index
1 SIMPLE users eq_ref PRIMARY PRIMARY 4 watches10.if2.user_id 1 Using where
上面的查询在0.5s
上执行,如果我想运行它超过记录 id 71(例如 10 个记录 id),它的执行速度会慢大约 x 倍(10 个 id 大约需要 5 秒)
【问题讨论】:
我不认为“修复桌子设计”是一种选择? @pala_ 如果这将是解决方案,我可以考虑它,但以防万一那些feature
字段不包含任何相互连接的值,例如 feature_1 将保存颜色,而 feature_2 可以保存大小和这些feature_
字段的确切名称不同(例如颜色、大小等)。
我真的认为这是个好主意
我认为@pala_ 正在做某事!!
让我们从原始的items
表开始。 id
字段是否唯一?请添加有关“用户有 100 个项目”的更多详细信息。你如何确定用户拥有哪些物品?就凭user_id
?当您处理用户的所有项目时,您是否对所有项目(xxx1,xxx2,...,xxx20)使用相同的一组特征值和权重,或者每个项目都有自己的一组特征和权重进行比较?我认为可以进行 1 个查询而不是 100 个查询。具有 5 个特征、10 个项目、2 个用户和预期结果的简化示例数据真的很有帮助。
【参考方案1】:
我建议您重新组织您的表结构,如下所示:
create table items (id integer primary key auto_increment);
create table features (
id integer primary key auto_increment,
feature_name varchar(25),
feature_weight integer
);
create table item_features (
item_id integer,
feature_id integer,
feature_value varchar(25)
);
这将允许您运行一个相对简单的查询,通过求和它们的权重来根据特征计算相似度。
select if2.item_id, sum(f.feature_weight)
from item_features if1
inner join item_features if2
on if1.feature_id = if2.feature_id
and if1.feature_value = if2.feature_value
and if1.item_id <> if2.item_id
inner join features f
on if2.feature_id = f.id
where if1.item_id = 1
group by if2.item_id
这里有一个演示:http://sqlfiddle.com/#!9/613970/4
我知道它与问题中的表定义不匹配 - 但像表中这样的重复值是通向黑暗面的路径。规范化确实让生活更轻松。
在item_features(feature_id, feature_value)
和features(feature_name)
上都有索引,查询应该很快
【讨论】:
感谢您的回复。我已经测试了您的解决方案,但我的数据库需要更多时间(1-2 秒)。请查看我的问题中的编辑。也许我错过了什么? 您是否创建了必要的索引? 是的,我已将所有这些都包含在我上面的问题的编辑中。似乎问题在于 group by - 没有它,查询几乎不需要时间,但是在添加group by if2.watch_id
(watch_id
上有一个索引)之后,执行时间会增加超过 1 秒
将 item_id 添加到您的复合索引中,这可能会有所帮助。我们要处理多少行?
我刚刚添加了,它使 0.3 s
的速度更快。正如我在编辑的问题中所写,目前表格中有超过 200 万行。我还需要在 item_featueres 表中添加user_id
和条件AND if2.
user_id`(从users
中选择id
where is_private
= '0')和 if2.user_id
'1'` 条件让它和我的一样,现在它需要0,5s
(现在我有一个关于 feature_id、feature_value、watch_id 的索引(在你的 item_id 和 user_id 中)所以它仍然不够快【参考方案2】:
这是我对你想要什么的理解。请告诉我我是否猜对了。 SQLFiddle
user_id
确定有许多项目属于多个用户。在此示例中,我们有 3 个用户:
CREATE TABLE items (
id int,
`user_id` int, `f1` int, `f2` int, `f3` int,
primary key(id),
key(user_id));
INSERT INTO items
(id, `user_id`, `f1`, `f2`, `f3`)
VALUES
(1, 1, 2, 22, 30),
(2, 1, 1, 21, 40),
(3, 1, 9, 25, 50),
(4, 2, 1, 21, 30),
(5, 2, 1, 22, 40),
(6, 2, 2, 22, 35),
(7, 3, 9, 22, 31),
(8, 3, 8, 20, 55),
(9, 3, 7, 20, 55),
(10, 3, 5, 26, 30)
;
user_id
是查询的参数。对于给定的user_id
,您要查找属于该用户的所有项目,然后对于每个找到的项目,您要计算定义该项目与所有其他项目之间“距离”的分数(不仅来自该用户,而是每一个其他项目)。然后你想显示按分数排序的结果的所有行。不仅仅是一个最相似的项目,而是所有项目。
使用这两个项目的特征值计算一对项目的分数。没有一组固定的特征值与所有项目进行比较,每对项目可能有自己的分数。
计算分数时,每个特征都有一个权重。这些权重是预定义的且恒定的(不依赖于项目)。让我们在这个例子中使用这些常量:
weight for f1 is 1
weight for f2 is 3
weight for f3 is 5
这是在一个查询中获取结果的一种方法(对于user_id=1
):
SELECT *
FROM
(
SELECT
UserItems.id AS UserItemID
,AllItems.id AS AllItemID
,IF(AllItems.f1 = UserItems.f1, 1, 0)+
IF(AllItems.f2 = UserItems.f2, 3, 0)+
IF(AllItems.f3 = UserItems.f3, 5, 0) AS Score
FROM
(
SELECT id, f1, f2, f3
FROM items
WHERE items.user_id = 1
) AS UserItems
CROSS JOIN
(
SELECT id, f1, f2, f3
FROM items
) AS AllItems
) AS Scores
WHERE
UserItemID <> AllItemID
AND Score > 0
ORDER BY UserItemID, Score desc
结果集
| UserItemID | AllItemID | Score |
|------------|-----------|-------|
| 1 | 10 | 5 |
| 1 | 4 | 5 |
| 1 | 6 | 4 |
| 1 | 5 | 3 |
| 1 | 7 | 3 |
| 2 | 5 | 6 |
| 2 | 4 | 4 |
| 3 | 7 | 1 |
如果这真的是你想要的,恐怕没有什么神奇的方法可以让它快速运行。对于用户的每个项目,您需要将其与其他项目进行比较以计算分数。因此,如果items
表中有N
行和给定用户的M
项目,则必须计算得分N*M
次。然后你必须过滤掉零分数并对结果进行排序。您无法避免阅读整个items
表M
次。
只有在对数据有一些外部知识的情况下,也许你才能以某种方式“作弊”,而不是每次都读取整个 items
表。
例如,如果您知道特征 K 的值分布非常不均匀:99% 的值是 X,1% 是其他一些值。可以利用这些知识来减少计算量。
另一个例子,如果项目以某种方式聚集在一起(在你的度量/距离/分数的意义上)。如果您可以预先计算这些集群,那么您不必每次都读取整个项目表,而可以使用适当的索引仅读取属于同一集群的那些项目的一小部分。
【讨论】:
以上是关于获取最相似的行并按相似度排序 - 性能改进的主要内容,如果未能解决你的问题,请参考以下文章