在上一篇文章《聊聊来自元宇宙大厂 Meta 的相似度检索技术 Faiss》中,我们有聊到如何快速入门向量检索技术,借助 Meta AI(Facebook Research)出品的 faiss 实现“最基础的文本内容相似度检索工具”,初步接触到了“语义检索”这种对于传统文本检索方式具备“降维打击”的新兴技术手段。

有朋友在聊天中提到,希望能够聊点更具体的,比如基于向量技术实现的语义检索到底比传统文本检索强多少,以及是否有局限性,能不能和市场上大家熟悉的技术产品进行一个简单对比。

那么,本篇文章就试着从这个角度来聊聊。

写在前面

相信有从本文才开始“入坑”、对标题中的 faiss 、向量检索并不熟悉的朋友,简单来说,faiss 是一个非常棒的开源项目,也是目前最流行的、效率比较高的文本相似度检索方案之一。

虽然它和相似度检索这门技术颇受欢迎,在出现在了各种我们所熟知的“大厂”应用的功能中,但毕竟属于小众场景,有着不低的掌握门槛和复杂性。在之前的内容中,我们已经介绍过了 faiss ,所以本文就不再展开赘述了。

所以,如果你实在懒得了解它,但是希望能够和写简单的 Web 项目一样,写几行 CRUD 就能够完成高效的向量检索功能,建议可以试试启动一个 Milvus 实例。或者更“懒一些”的话,可以试着使用 Milvus 的 Cloud 服务,来完成高性能的向量检索。

而传统文本检索方面,我将使用简单的 Golang 来实现一些例子,以及使用我们熟悉的 MySQL来进行功能实现和对比,包含:“LIKE操作符,模式匹配”和“全文检索”两种方式。(Elasticsearch 和 Meilisearch,我们在后续的文章中再展开聊。)

本文中的相关代码,均已上传至 soulteary/text-retrieval-example,有需要的同学可以自行下载。在展开实战之前,我们先来了解下什么是“文本检索”。

无处不在的“文本检索”

“文本检索”这个词大家或许会感到陌生,但它或许是我们每天和数字世界打交道最频繁的交互模式之一:

  • 从在文档中使用 “CTRL+F” 快捷键查找某个关键词(在文本中使用文本字词、短句进行检索);
  • 在微信、微博、知乎等应用里搜索联系人、新鲜事儿,在各种搜索引擎中输入我们要查询到内容(使用文本进行内容检索和匹配);
  • 每天可能会使用的 “AI 音箱”,基本都会使用到的“自动语音识别技术”也是将语音转换为文本,再对文本进行检索和匹配;
  • 甚至,我们从出生到告别世界需要在各种信息系统中登记、查询,也都离不开这个技术…

是不是看上去都“挺简单”的?但如果我们在查询的时候,不能够完全精准进行“关键词”匹配,比如在“小智,今天天气是不是挺好的”这句话里,尝试直接搜索匹配“今天天儿怎么样”,会得到什么样的结果呢?

普通的软件将告诉我们“找不到匹配结果”,有一部分聪明一些的,将帮助我们高亮“今天天”这几个相同的字符内容,但如果我们尝试搜索“天气情况”,那么大概率是查不到任何结果的。

那么,聪明的程序员们是怎么解决这个问题的呢?

文本检索的发展史:如何解决搜不到内容的问题

既然使用完全匹配得不到结果,不妨换个思路:使用某种方式,来实现近似结果的检索、匹配(相似性检索)。为了得到“近似的结果”,我们一般有两条路可以走。

在还没有大规模使用“AI技术”之前(算法和模型应用),为了解决这个问题,聪明的工程师们发明了“文本相似度计算”。比如,根据文本的长短(句子、段落、文章)来切分内容,接着使用简单的算法来完成文本相似度的计算。最常见的算法有:编辑距离算法、统计重复字符出现比例等。

所以,在一些较为“机智”的产品策略里,当我们没有完全匹配的内容时,会呈现给用户部分匹配的查询结果,比如前文中提到的那个例子。但是,这种模式解决不了相同语义不同表述方式内容的查找和匹配。 比如,我们在上一篇文章中尝试搜索“哈利波特猛然睡醒”这种基本在原文中没有的内容。

伴随着人工智能 NLP 领域技术的迅猛发展,聪明的工程师们又折腾出了“新的解决方案”,先将文本内容进行各种维度的切分,接着将它们转换为向量数据,然后实现出基于统计特征(TF/TF-IDF/Simhash)或者基于语义的特征模型(word2vec、doc2vec),最后搭建一套推理服务,就能够解决基于语义的文本匹配啦。其中常见的用于判断相似度的算法包括:欧氏距离、汉明距离、余弦距离等。

随后,NLP 领域中的“文本相似度计算方向”百花齐放,机智的学者们和聪明的程序员们,通过各种方式不断完善“语义文本相似度计算”(Semantic Textual Similarity)技术,实现出了越来越准确、高效的工具,能够对各种内容进行文本的分类和聚类、歧义消除等传统方案解决的非常不好的问题。不仅仅针对前文中提到的使用场景和案例,还有一些我们常常使用的:抄袭检测(论文、专利、文章)、自动文本摘要(新闻)、机器翻译(比如微信消息)、机器人问答系统(各种App里的客服)、多模态检索(比如智能音箱)…

好了,相信聪明的你一定已经看明白了,文本检索的发展历程,以及迫不及待的想要开始实践啦。

使用 Golang 实践传统文本检索

现代编程语言在基础的文本操作方面都差不多,考虑到演示方便,这里选择使用 Golang 来完成 Demo:这里选择一首我很喜欢的诗作为例子,来实现一个简单的程序,针对它进行内容查找(文本检索)。

西风吹老洞庭波,一夜湘君白发多。 醉后不知天在水,满船清梦压星河。

使用 Golang 实现基础的文本检索功能

我们先来实现一个最简单的“文本完全匹配/包含”的例子。新建一个文件,取名为 simple.go,然后实现一个最基础的文本匹配功能 SimpleMatch

package main

import (
	"strings"
)

func SimpleMatch(str1, str2 string) bool {
	if len(str1) >= len(str2) {
		return strings.Contains(str1, str2)
	}
	return strings.Contains(str2, str1)
}

上面的内容非常简单,实现了在比较长的字符串中尝试搜索匹配短字符串的功能。在完成这个“最简单算法”之后,我们编写程序来进行简单例子的验证:

package main

import (
	"fmt"
	"testing"
)

func TestSimpleMatch(t *testing.T) {
	var tests = []struct {
		A string
		B string
	}{
		{"西风吹老洞庭波,一夜湘君白发多。", "洞庭湖"},
		{"醉后不知天在水,满船清梦压星河。", "满船清梦"},
		{"2022", "2023"},
		{"真不错", "还不错"},
	}

	for _, str := range tests {
		testname := fmt.Sprintf("测试内容是否匹配:“%s” vs “%s”", str.A, str.B)
		t.Run(testname, func(t *testing.T) {
			ret := SimpleMatch(str.A, str.B)
			fmt.Println("内容匹配:", ret)
		})
	}
}

这里我们使用 Go 内置的单元测试功能,来完成函数的调用以及结果的展示。将上面的内容保存为 simple_test.go ,然后执行 go test -v,我们能够看到下面的结果:

=== RUN   TestSimpleMatch
=== RUN   TestSimpleMatch/测试内容是否匹配:“西风吹老洞庭波一夜湘君白发多。”_vs_洞庭湖
内容匹配 false
=== RUN   TestSimpleMatch/测试内容是否匹配:“醉后不知天在水满船清梦压星河。”_vs_满船清梦
内容匹配 true
=== RUN   TestSimpleMatch/测试内容是否匹配:“2022_vs_2023
内容匹配 false
=== RUN   TestSimpleMatch/测试内容是否匹配:“真不错_vs_还不错
内容匹配 false

可以看到和我们前文中提到的情况一样,程序只能够识别“完全相同的部分”。接下来,我们来实现一个常见的相似度计算算法:编辑算法。来解决我们要查找的内容和被查找内容“不能完全匹配”场景下的内容检索。

基于字符的相似度计算:编辑距离算法(levenshtein)

在“古老的” PHP 中,内置了一个名为 levenshtein(莱文斯坦,也被称作编辑距离)的函数。我们可以用这个函数来计算两个字符串之间的相似度。这里偷个懒,我们直接使用开源项目 syyongx/php2go 中已经实现好的内容,来帮助我们加速完成这部分基于字符的相似度计算实战。

“编辑距离(Levenshtein)”算法,是一种比较简单的求两个字符串之间相似度的算法。简单来说,就是通过计算一个字符串需要经过多少次对内容“增删改”,才能完全变为另外一个字符串的方式,来实现针对两个字符串内容差异程度的量化。

我们假设将一个文本转换为另一个文本,过程中的添加字符、删除字符、替换字符都记做一次操作的话,算法的 Golang 版本实现类似下面这样:

package main

// Levenshtein levenshtein()
// costIns: Defines the cost of insertion.
// costRep: Defines the cost of replacement.
// costDel: Defines the cost of deletion.
func Levenshtein(str1, str2 string, costIns, costRep, costDel int) int {
	var maxLen = 255
	l1 := len(str1)
	l2 := len(str2)
	if l1 == 0 {
		return l2 * costIns
	}
	if l2 == 0 {
		return l1 * costDel
	}
	if l1 > maxLen || l2 > maxLen {
		return -1
	}

	p1 := make([]int, l2+1)
	p2 := make([]int, l2+1)
	var c0, c1, c2 int
	var i1, i2 int
	for i2 := 0; i2 <= l2; i2++ {
		p1[i2] = i2 * costIns
	}
	for i1 = 0; i1 < l1; i1++ {
		p2[0] = p1[0] + costDel
		for i2 = 0; i2 < l2; i2++ {
			if str1[i1] == str2[i2] {
				c0 = p1[i2]
			} else {
				c0 = p1[i2] + costRep
			}
			c1 = p1[i2+1] + costDel
			if c1 < c0 {
				c0 = c1
			}
			c2 = p2[i2] + costIns
			if c2 < c0 {
				c0 = c2
			}
			p2[i2+1] = c0
		}
		tmp := p1
		p1 = p2
		p2 = tmp
	}
	c0 = p1[l2]

	return c0
}

将上面的内容保存为 levenshtein.go,然后再次使用 Go 内置的单元测试功能,来完成调用和展示功能:

package main

import (
	"fmt"
	"testing"
)

func TestLevenshtein(t *testing.T) {
	var tests = []struct {
		A string
		B string
	}{
		{"西风吹老洞庭波,一夜湘君白发多。", "洞庭湖"},
		{"醉后不知天在水,满船清梦压星河。", "满船清梦"},
		{"2022", "2023"},
		{"真不错", "还不错"},
	}

	for _, str := range tests {
		testname := fmt.Sprintf("%s,%s", str.A, str.B)
		t.Run(testname, func(t *testing.T) {
			ret := Levenshtein(str.A, str.B, 1, 1, 1)
			if ret < 0 {
				t.Errorf("计算或例子错误 %s, %s", str.A, str.B)
			}
			fmt.Println(ret)
		})
	}
}

将上面的内存保存为 levenshtein_test.go ,接着再次执行 go test -v,我们将能够得到下面的结果:

=== RUN   TestLevenshtein
=== RUN   TestLevenshtein/西风吹老洞庭波一夜湘君白发多,洞庭湖
40
=== RUN   TestLevenshtein/醉后不知天在水满船清梦压星河,满船清梦
36
=== RUN   TestLevenshtein/2022,2023
1
=== RUN   TestLevenshtein/真不错,还不错
3

结果中的数字告诉我们两个文本字符串想变成一样的,需要进行多少次操作,数字越大,则说明文本整体的相似度越低。所以,在实际业务使用中,我们只需要将得到的结果进行排序,选择数字最小的结果进行返回即可。

这个数字一般被称作 Levenshtein distance (L氏距离)数值,类似上面的同类算法还有:LCS(最长公共子序列)、Jaro、汉明距离。

基于字符的相似度计算:字符重复出现次数

除了上面基于字符串“距离”进行相似度计算的方式之外,我们还可以基于字符重复出现次数,来对两个字符串进行相似度计算。为了偷懒,还是使用开源项目中已经实现好的函数 similar_text 来作为演示对象:

package main

// SimilarText similar_text()
func SimilarText(first, second string, percent *float64) int {
	var similarText func(string, string, int, int) int
	similarText = func(str1, str2 string, len1, len2 int) int {
		var sum, max int
		pos1, pos2 := 0, 0

		// Find the longest segment of the same section in two strings
		for i := 0; i < len1; i++ {
			for j := 0; j < len2; j++ {
				for l := 0; (i+l < len1) && (j+l < len2) && (str1[i+l] == str2[j+l]); l++ {
					if l+1 > max {
						max = l + 1
						pos1 = i
						pos2 = j
					}
				}
			}
		}

		if sum = max; sum > 0 {
			if pos1 > 0 && pos2 > 0 {
				sum += similarText(str1, str2, pos1, pos2)
			}
			if (pos1+max < len1) && (pos2+max < len2) {
				s1 := []byte(str1)
				s2 := []byte(str2)
				sum += similarText(string(s1[pos1+max:]), string(s2[pos2+max:]), len1-pos1-max, len2-pos2-max)
			}
		}

		return sum
	}

	l1, l2 := len(first), len(second)
	if l1+l2 == 0 {
		return 0
	}
	sim := similarText(first, second, l1, l2)
	if percent != nil {
		*percent = float64(sim*200) / float64(l1+l2)
	}
	return sim
}

我们将上面的内容保存为 similar.go,然后我们来编写调用程序 similar_test.go

package main

import (
	"fmt"
	"testing"
)

func TestSimilarText(t *testing.T) {
	var tests = []struct {
		A string
		B string
	}{
		{"西风吹老洞庭波,一夜湘君白发多。", "洞庭湖"},
		{"醉后不知天在水,满船清梦压星河。", "满船清梦"},
		{"2022", "2023"},
		{"真不错", "还不错"},
	}

	for _, str := range tests {
		testname := fmt.Sprintf("%s,%s", str.A, str.B)
		t.Run(testname, func(t *testing.T) {
			percent := float64(0)
			ret := SimilarText(str.A, str.B, &percent)
			if ret < 0 {
				t.Errorf("计算或例子错误 %s, %s", str.A, str.B)
			}
			fmt.Println(ret, fmt.Sprintf("%.2f", percent)+"%")
		})
	}
}

将文件保存好之后,我们执行 go test -v,将得到下面的结果:

=== RUN   TestSimilarText
=== RUN   TestSimilarText/西风吹老洞庭波一夜湘君白发多,洞庭湖
8 28.07%
=== RUN   TestSimilarText/醉后不知天在水满船清梦压星河,满船清梦
12 40.00%
=== RUN   TestSimilarText/2022,2023
3 75.00%
=== RUN   TestSimilarText/真不错,还不错
6 66.67%

结果中的第一个数字代表了两个字符串中最长的连续重复内容的长度(LCS),第二个数字则表示了两个字符串的相似程度。相比上一小节中的简单的“编辑距离”计算的可用度高了不少,但依旧解决不了上文中的“语义检索”的问题

并且,在实际业务中,我们需要进行需求可能是“某个文本在一大堆数据中的查找”、“许多文本在一大堆数据中的查找”。这个时候,上面朴素的算法显然无法满足我们的需求。

至于关于如何实现语义检索,我们等会聊。先来看看如何使用传统检索技术来解决“一对多”、“多对多”这种场景下的内容查找问题吧。

全文检索

提到全文检索,我们会很自然的想到老牌工具 Elasticsearch 和 Apache Solr 这类基于 Lucene 的全文检索方案。这类使用倒排索引帮助用户实现数据定位查找的工具最明显的特点就是快,以及可能不那么准(一会说为啥)。

倒排索引(Inverted index)为何而生?

简单想象一下,如果我们想要用上文中的程序完成对互联网网页中的文本内容的处理,对其中包含的某个词或者短语进行文本相似度计算,将会有一个非常可怕的结果:我们需要等待程序对每一篇内容进行计算,当所有内容都计算完毕之后,我们才能得到结果。如果我们要查找多个内容,并且我们要查找的互联网内容还在持续、不断的快速增长,这个计算过程将非常难完成,我们也将难以像现在一样,快速的从互联网的搜索引擎中得到想要的结果。

所以聪明的程序员们,就发明了“倒排索引”。通过一些程序计算,先对可以被查询的文档、文本内容进行内容切分(分词),然后针对这些切分好的内容进行编号,做成一张大的表格,当我们需要查询某个内容的时候,如果是词语就直接在表里查询它的编号,然后返回和这个编号绑定的内容的数据,类似我们常用到的 “KV” 查询数值,牺牲一定的存储空间,降低计算复杂度,换取高速的数据吞吐能力,让我们能快速的找到想要的结果。如果我们要查询的内容是句子或者更长的片段的话,则会先进行类似被查询内容的切分操作,分别用不同的词进行并行查找,找到各个词的编号,以及编号背后的数据,然后返回给我们。

虽然看起来一切美好,但是如果我们提供的词不能够被很好的进行分词呢?比如这两本书:《无线电法国别研究》、《物理学家用微分几何》。当遇到这类分词存在歧义的内容时,我们想得到预期内的结果,还是有一些挑战的。 并且,前文提到了,这个方案,也解决不了我们想知道“过几天天天天气不好”这类需要使用语义检索来解决的问题。

但是,为了大家能够更深入的理解概念和原理,我们还是来实践下这种传统的检索技术吧。

基于 MySQL 全文索引来进行文本检索

我们可以选择配置和使用相比较 “ES” 和 Solar 更为简单的 MySQL 的 “全文索引” 来完成对传统文本检索的基础认识。

为了更好的验证全文索引的效果,我们可以加大被查询的文本数据量。这里选择使用开源项目 modood/Administrative-divisions-of-China 中的“国内所有的街道名称”的数据,来实现一个基于 MySQL 全文检索的“地址模糊查询功能”,预期效果类似我们在各种购物软件的收货地址管理功能中,输入部分地名关键词的时候,App 自动补全出最有可能的结果。

为了操作尽可能简单、以及尽可能节约我们的折腾时间,我这里选择使用 MySQL 官方提供的 Docker 镜像,通过容器的方式来完成 MySQL 的“部署”和参数配置。为了让 MySQL 能够实现比较好的短文本场景的检索效果,我写了一个 “docker-compose.yml”:

version: '3'

services:

  mysql:
    image: mysql:5.7.39
    container_name: mysql-instance
    command:
      - "--default-authentication-plugin=mysql_native_password"
      - "--character-set-server=utf8mb4"
      - "--collation-server=utf8mb4_unicode_ci"
      - "--init-connect='SET NAMES utf8mb4;'"
      - "--innodb_buffer_pool_size=1M"
      - "--query_cache_type=0"
      - "--query-cache-size=0"
      - "--innodb-flush-log-at-trx-commit=0"
      - "--innodb_buffer_pool_instances=1"
      - "--ft_min_word_len=1"
      - "--ft_stopword_file=''"
      - "--innodb-ft-min-token-size=1"
      - "--innodb-ft-enable-stopword=off"
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=soulteary
      - LANG=C.UTF-8
    volumes:
      - ./data:/var/lib/mysql:rw
      - ./files:/var/lib/mysql-files:rw

为了避免 MySQL 缓存对我们的简单测试造成影响,我这里通过配置启动参数将相关功能进行了禁用,确保我们在后续查询的时候,始终消耗真实的计算时间,而非缓存后的“虚假数字”。

将上面的内容保存为 docker-compose.yml,然后执行我们熟悉的 docker-compose up,在 MySQL 下载完毕之后,将按照我们的要求自动的进行初始化,当我们看到类似下面的日志的时候,数据库就准备好啦:

mysql_1  | 2022-09-08T16:06:25.692847Z 0 [Note] InnoDB: Buffer pool(s) load completed at 220908 16:06:25
mysql_1  | 2022-09-08T16:06:25.827887Z 0 [Note] Event Scheduler: Loaded 0 events
mysql_1  | 2022-09-08T16:06:25.828324Z 0 [Note] mysqld: ready for connections.
mysql_1  | Version: '5.7.39'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server (GPL)

在完成了对 MySQL 的初始化之后,我们可以使用简单的命令,来确认数据库的参数状况,是否如我们在配置中设置的一样,先执行下面的命令,进入 MySQL CLI 模式:

docker exec -it mysql-instance mysql -uroot -psoulteary

接着,我们输入下面的两条 SQL,来检查配置是否如我们所期望的那样:

show variables like 'innodb_ft%';
show variables like 'ft%';

可以看到 innodb_ft_min_token_sizeft_min_word_len 都被设置成了 1,允许针对最少1个字长度的字符进行分词;innodb_ft_enable_stopwordft_stopword_file 分别设置成 OFF 和“空”,对“内置停用词”功能进行了禁用,避免效果不佳。

mysql> show variables like 'innodb_ft%';
+---------------------------------+------------+
| Variable_name                   | Value      |
+---------------------------------+------------+
| innodb_ft_aux_table             |            |
| innodb_ft_cache_size            | 8000000    |
| innodb_ft_enable_diag_print     | OFF        |
| innodb_ft_enable_stopword       | OFF        |
| innodb_ft_max_token_size        | 84         |
| innodb_ft_min_token_size        | 1          |
| innodb_ft_num_word_optimize     | 2000       |
| innodb_ft_result_cache_limit    | 2000000000 |
| innodb_ft_server_stopword_table |            |
| innodb_ft_sort_pll_degree       | 2          |
| innodb_ft_total_cache_size      | 640000000  |
| innodb_ft_user_stopword_table   |            |
+---------------------------------+------------+
12 rows in set (0.00 sec)

mysql> show variables like 'ft%';
+--------------------------+----------------+
| Variable_name            | Value          |
+--------------------------+----------------+
| ft_boolean_syntax        | + -><()~*:""&| |
| ft_max_word_len          | 84             |
| ft_min_word_len          | 1              |
| ft_query_expansion_limit | 20             |
| ft_stopword_file         | ''             |
+--------------------------+----------------+
5 rows in set (0.00 sec)

在配置确认无误之后,我们就可以准备将数据灌入数据库中了。我们先下载项目中的数据文件 “villages.csv”,将文件复制到 MySQL 容器启动之后,容器在当前目录中自动创建的 “files” 目录中。

通过使用 head files/villages.csv 命令,简单查看文件内容后,我们可以看到这个数据包含了“六个数据列”:

code,name,streetCode,provinceCode,cityCode,areaCode
110101001001,"多福巷社区居委会",110101001,11,1101,110101
110101001002,"银闸社区居委会",110101001,11,1101,110101
110101001005,"东厂社区居委会",110101001,11,1101,110101
110101001006,"智德社区居委会",110101001,11,1101,110101
110101001007,"南池子社区居委会",110101001,11,1101,110101
110101001009,"灯市口社区居委会",110101001,11,1101,110101
110101001010,"正义路社区居委会",110101001,11,1101,110101
110101001013,"台基厂社区居委会",110101001,11,1101,110101
110101001014,"韶九社区居委会",110101001,11,1101,110101

在这个例子中,我们不需要进行复杂的“级联”操作,所以我们可以将除了前两列的内容都剔除掉。数据“ETL”的方式很简单,使用 awk 的经典范式即可:

cat files/villages.csv | awk -F, '{OFS=",";print $1,$2}' > files/data.csv

当命令执行完毕,我们就能够在目录中得到一份简单到只有“序号,名称”的数据了:

head files/data.csv
code,name
110101001001,"多福巷社区居委会"
110101001002,"银闸社区居委会"
110101001005,"东厂社区居委会"
110101001006,"智德社区居委会"
110101001007,"南池子社区居委会"
110101001009,"灯市口社区居委会"
110101001010,"正义路社区居委会"
110101001013,"台基厂社区居委会"
110101001014,"韶九社区居委会"

如果你希望这些数据更像是我们平时使用 App 补全出来的地址,也并不希望文件中第一行数据包含“列头”,可以使用脚本进一步完成对内容中名词的删除:

cat files/data.csv | sed 's/居委会//g' | sed 's/村委会//g' | sed 's/民委员会//g' > files/pure.csv

完整的数据行数大概有 60 多万行,虽然相比生产环境还不够多,但是足以作为我们的演示使用的例子啦。

cat files/pure.csv | wc -l
  618135

在准备好数据之后,我们来将这个数据导入 MySQL 容器中,先在容器中创建相关的“库表结构”,并设置为添加到表中的文本字段进行索引建立:

CREATE DATABASE `test`;

CREATE TABLE `test`.`items` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(100) NOT NULL,
  PRIMARY KEY (`id`),
  FULLTEXT KEY (`name`) WITH PARSER ngram
) ENGINE=InnoDB;

为了对中文检索效果更好,我们需要使用 ngram,来完成索引的建立。接着,将我们处理好的数据导入新建好的数据表中。

LOAD DATA INFILE '/var/lib/mysql-files/pure.csv'
INTO TABLE `test`.`items`
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 ROWS;

因为数据在“灌入”的过程中,会进行分词、建立索引、更新已有索引等计算操作,所以会花费比较长的时间,需要稍微等待一阵,当命令执行完毕,我们将会看到类似下面的输出:

Query OK, 618134 rows affected (20.91 sec)
Records: 618134  Deleted: 0  Skipped: 0  Warnings: 0

当数据导入之后,我们就能够使用 MySQL 的全文索引查询功能,来进行各种内容的检索了,比如我们先来在 60 万条数据中查找所有包含“青龙”的地名:

SELECT * FROM `test`.`items`
WHERE MATCH (name)
AGAINST ('青龙' IN NATURAL LANGUAGE MODE);

执行查询,我们将会得到类似下面的结果:

mysql> SELECT * FROM `test`.`items`
    -> WHERE MATCH (name)
    -> AGAINST ('青龙' IN NATURAL LANGUAGE MODE);
+--------------+--------------------------------+
| id           | name                           |
+--------------+--------------------------------+
| 110101005010 | 青龙社区                       |
| 110108023203 | 青龙桥                         |
| 110109106206 | 青龙涧                         |
| 110111112207 | 青龙头                         |
| 130209452005 | 青龙湖社区                     |
| 130324103230 | 青龙港                         |
| 130324108223 | 青龙河                         |
| 130534105238 | 青龙庄村                       |
| 130723100204 | 青龙村村                       |
...
| 621225101214 | 青龙                           |
| 640324102208 | 青龙山                         |
| 654325100009 | 青龙湖                         |
+--------------+--------------------------------+
396 rows in set (0.03 sec)

在 60 万内容中寻找接近 400 个结果的返回,只用了 0.03s,是不是还蛮惊艳的呢?在完成常规的关键词查询之后,我们来试试“相似文本的查询匹配”,比如查询一个我国街道中不存在的地方“青龙朱雀白虎玄武”:

SELECT * FROM `test`.`items`
WHERE MATCH (name)
AGAINST ('青龙朱雀白虎玄武' IN NATURAL LANGUAGE MODE);

在 SQL 执行完毕之后,我们将得到和上面稍有不同的“答案”:

mysql> SELECT * FROM `test`.`items`
    -> WHERE MATCH (name)
    -> AGAINST ('青龙朱雀白虎玄武' IN NATURAL LANGUAGE MODE);
+--------------+--------------------------------+
| id           | name                           |
+--------------+--------------------------------+
| 370614106234 | 卧龙朱家                       |
| 330212008010 | 朱雀社区                       |
| 371323111295 | 朱雀村村                       |
| 433122105202 | 朱雀洞村                       |
| 610103007003 | 朱雀南社区                     |
| 610103007004 | 朱雀北社区                     |
| 621226111205 | 朱雀村                         |
| 370705002030 | 玄武东街社区居                 |
| 430224105214 | 玄武村                         |
...
| 654325100009 | 青龙湖                         |
+--------------+--------------------------------+
445 rows in set (0.03 sec)

上面输出的结果,就是我们使用 MySQL 的自然语言模式(Natural Language Full-Text Searches),进行文本相似度查询的结果。而这个模式背后的原理,类似我们前文中提到的字符串相似度计算。

通过借助数据库这种“工程艺术结晶”,我们就可以达成前文中提到的“一对多”、“多对多”这种场景下的内容检索需求了,完成内容的批量查找。

除了自然语言模式之外,MySQL 还内置了布尔模式(Boolean Full-Text Searches),感兴趣的话,你可以自行了解,这里就不继续展开啦。

题外话:有一部分同学 “%LIKE%” 的 MySQL 模式匹配

我知道有一部分同学非常热衷于 “%LIKE%” 的方式来“解决问题”。在内容量比较少的时候,或者硬件能力非常强的时候,这个方式都没有太大的问题,但是在数据量非常大,或者业务机器计算资源非常紧张的时候,使用这个方式,会让性能问题加重,而且还有可能引发其他的问题。

我们参考上一小节中的查询内容,进行一个相同例子的查找,并观察性能表现:

SELECT * FROM `test`.`items` WHERE `name` LIKE '%青龙%';

当 SQL 执行完毕,我们将得到类似上文中的查询结果。

mysql> SELECT * FROM `test`.`items` WHERE `name` LIKE '%青龙%';
+--------------+--------------------------------+
| id           | name                           |
+--------------+--------------------------------+
| 110101005010 | 青龙社区                       |
| 110108023203 | 青龙桥                         |
| 110109106206 | 青龙涧                         |
| 110111112207 | 青龙头                         |
| 130209452005 | 青龙湖社区                     |
...
| 640324102208 | 青龙山                         |
| 654325100009 | 青龙湖                         |
+--------------+--------------------------------+
396 rows in set (0.24 sec)

但是相比较之前怎么查都是 “0.0x” 秒而言,使用全表扫描的 “%LIKE%” 的查询时间增长了一个数量级!如果我们查询的数据量不是 60 万,而是生产环境上亿或者更大规模的数据,可能产生的全表扫描行为,除了会带来大量资源消耗、非常慢甚至不一定能够得到查询结果的情况外,还有可能阻塞正常的写入操作,造成业务异常。

MySQL LIKE 模式匹配查询的另外一个问题是,并不能够完成类似上文中“全文索引”提供的相似度计算的能力,如果我们使用相同的例子进行查询的话:

SELECT * FROM `test`.`items` WHERE `name` LIKE '%青龙白虎朱雀玄武%';

得到的结果一定是和下面一样的空结果:

mysql> SELECT * FROM `test`.`items` WHERE `name` LIKE '%青龙白虎朱雀玄武%';
Empty set (0.20 sec)

所以,综上所述,如果不是磁盘特别紧张,对于有大量数据读取操作的场景,我们还是尽量合理的建立索引,减少对 “%LIKE%” 的依赖;以及,在需要查询相似度的时候,选择更合理的方案。

讲到这里,我相信此刻你应该比较清楚“传统文本检索”技术是基于哪些套路来完成“内容匹配”、“内容检索”、“内容相似度计算”,以及如何使用 MySQL 来完成批量内容的“文本检索”,尤其是“相似性检索”啦。

使用 Faiss 来进行语义检索

接下来,我们来聊聊对传统技术具备降维打击的“向量语义检索”技术。依旧是先来准备 faiss 的运行环境,完成 faiss 和相关软件的安装。

为了方便你的使用,我写了一个 “ALL IN ONE” 的 Docker 镜像构建文件。

在 Docker 中完成 Faiss 的安装

在下面的 Dockerfile 中,我们主要做了三件事:安装 miniconda、安装 faiss、安装所需要的软件包以及完成模型的下载。如果你想了解为什么这样做,可以翻阅之前的文章,包含了分步骤的解释,考虑到篇幅,这里就不再展开了。

FROM python:3.8-bullseye
RUN sed -i -E "s/\w+.debian.org/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list

# 安装 miniconda
ARG CONDA_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/anaconda"
ENV CONDA_MIRROR=${CONDA_MIRROR}
ENV CONDA_SRC="https://repo.anaconda.com/miniconda"
RUN CONDA_SRC="${CONDA_MIRROR}/miniconda"; \
    curl -fsSL -v -o ~/miniconda.sh -O  "$CONDA_SRC/Miniconda3-latest-Linux-x86_64.sh" && \
    chmod +x ~/miniconda.sh && \
    ~/miniconda.sh -b -p ~/miniconda && \
    rm ~/miniconda.sh && \
    echo "channels:" > $HOME/.condarc && \
    echo "  - ${CONDA_MIRROR}/pkgs/free/" >> $HOME/.condarc && \
    echo "  - ${CONDA_MIRROR}/pkgs/main/" >> $HOME/.condarc && \
    echo "  - ${CONDA_MIRROR}/cloud/pytorch/" >> $HOME/.condarc && \
    echo "  - defaults" >> $HOME/.condarc && \
    echo "show_channel_urls: true" >> $HOME/.condarc;
SHELL ["/bin/bash", "-c"]
ENV PATH="~/miniconda/bin:${PATH}"
ARG PATH="~/miniconda/bin:${PATH}"

# 安装 faiss
ARG PYTHON_VERSION=3.8
RUN conda install -y pytorch python=${PYTHON_VERSION} faiss-cpu pandas && \
    conda clean -ya && \
    conda init bash
# 安装相关软件包
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
    pip install sentence_transformers

WORKDIR /workspace

为了你能够更快的完成构建,我将除了模型之外的相关软件下载都使用“清华源”进行了替换。我们将上面的内容保存为 Dockerfile,然后执行 docker build -t faiss .,等待镜像的构建完毕。

虽然在例子中我们使用的是 cpu 版本的 faiss,但因为同样依赖大块头 pytorch 等,所以镜像体积会比较大(接近6G),空间紧张的同学需要注意。

当模型构建完毕之后,我们就可以来体验和使用基于“向量相似度检索”的语义检索啦。

数据准备:人民日报新闻数据集

通过之前的实践,我们已经清楚了如何对内容进行完全和部分的匹配,为了更直观的了解“语义检索”,我们换一个数据,让难度提升些,也为了最后的对比效果更明显一些。

我这里选择的是来自 Kaggle 的“People’s Daily News”数据集,包含了2021 年至今的人民日报报道过的四万三千多篇内容,接近 140 万长短句内容,远超我们之前在验证进行批量文本检索时,在传统数据库以及全文索引场景时的数据量。

ls data/RenMin_Daily/
20210101-01-01.txt 20210304-01-05.txt 20210430-02-06.txt 20210708-17-02.txt 20210907-13-05.txt 20211114-08-03.txt 20220117-04-04.txt 20220323-17-01.txt 20220521-02-03.txt 20220714-03-02.txt
20210101-01-02.txt 20210304-01-06.txt 20210430-02-07.txt 20210708-17-03.txt 20210907-13-06.txt 20211115-01-01.txt 20220117-05-01.txt 20220323-18-01.txt 20220521-02-04.txt 20220714-03-03.txt
...

和上一篇文章一样,为了减少不必要的计算,以及让展示效果更好,我们使用类似的方式对所有的内容进行数据整理:

const { join } = require("path");
const { readdirSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require("fs");

const baseDir = "./data";
const rawDir = join(baseDir, "RenMin_Daily");
const outputDir = join(baseDir, "output");

if (!existsSync(outputDir)) mkdirSync(outputDir);

const rawFiles = readdirSync("./data/RenMin_Daily").filter((file) => file.endsWith(".txt"));

let buffer = [];
let concatID = 1;
rawFiles.forEach((file, fileNo) => {
  const lines = readFileSync(join(rawDir, file), "utf-8")
    .split("\n")
    .map((line) => line.replace(/。/g, "。\n").split("\n"))
    .flat()
    .join("\n")
    .replace(/“([\S]+?)”/g, (match) => match.replace(/\n/g, ""))
    .replace(/“([\S\r\n]+?)”/g, (match) => match.replace(/[\r\n]/g, ""))
    .split("\n")
    .map((line) => line.replace(/s/g, "").trim().replace(/s/g, "—"))
    .filter((line) => line);

  buffer = buffer.concat(lines);
  if (buffer.length > 100000) {
    writeFileSync(join(outputDir, `${concatID}.txt`), buffer.slice(0, 100000).join("\n"), "utf8");
    buffer = buffer.slice(100000);
    console.log("存钱罐满啦,换一罐继续存 :D");
    concatID = concatID + 1;
  }
  if (fileNo === rawFiles.length - 1) {
    writeFileSync(join(outputDir, `${concatID}.txt`), buffer.join("\n"), "utf8");
    console.log("所有数据都存储完毕了");
  }
});

使用 Node 执行上面的程序,我们将会在 data/output 目录中看到 14 个文本文件,除最后一个文件之外,每一个文件都包含了 10 万行句子。对比处理前后,我们减少了接近 100MB 的文本量,能节约不少后续处理时间。

du -hs data/RenMin_Daily
282M	data/RenMin_Daily

du -hs data/output
182M	data/output

将文本数据转换为向量数据

因为我们要处理的数据量相对比较大,处理时间会比较长,所以,我将上一篇文章中提到的处理程序,做了一些调整:

import os
from os import walk
import pandas as pd

if not os.path.exists('./data/vector'):
    os.mkdir('./data/vector')

dataDir = "./data/output"
allFiles = next(walk(dataDir), (None, None, []))[2]
# 加载原始数据
frames = []
for i in range(len(allFiles)):
    file = allFiles[i]
    print(file)
    frames.append(pd.read_csv("./data/output/"+file, sep="`",
                              header=None, names=["sentence"]))
df = pd.concat(frames, axis=0, ignore_index=True)

# 加载模型,将数据进行向量化处理
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('uer/sbert-base-chinese-nli')
sentences = df['sentence'].tolist()
sentence_embeddings = model.encode(sentences)

# 将向量处理结果存储
import numpy as np
save_file = "data.npy"
np.save(save_file, sentence_embeddings)

file_size = os.path.getsize(save_file)
print("%7.3f MB" % (file_size/1024/1024))

程序首先会读取目录下所有的文件,接着使用指定的模型,将内容转换为向量数据。当程序将所有数据处理为向量之后,我们将结果存储到本地,方便后续使用。在上一篇文章中,我有详细描述过相关内容,如果你对向量转换还不了解,可以先行阅读之前的文章。将程序保存为 prepare.py,我们继续下一步。

在准备好程序之后,我们选择合适的设备,执行命令从前文中提到的镜像,创建一个运行容器:

docker run --rm -it faiss bash

接着,执行我们刚刚准备好的程序文件:

python prepare.py

接下来就是漫长的等待啦,如果你希望更快的得到结果,可以尝试使用按量付费的云主机(尽量选择核心多一些),或者适当减少我们要进行向量化的数据条目。

在漫长的等待之后,我们将得到类似下面的输出:

3931.465 MB

说明数据处理完毕,终于可以“上菜”了。

使用 Faiss 进行向量检索

我们先来实现一段程序,来解决我们上文中提到的“搜不到内容”的问题,比如口语化的“今天天儿怎么样”:

# 从目录中加载原始数据
from os import walk
import pandas as pd

dataDir = "./data/output"
allFiles = next(walk(dataDir), (None, None, []))[2]
frames = []
for i in range(len(allFiles)):
    file = allFiles[i]
    print(file)
    frames.append(pd.read_csv("./data/output/"+file, sep="`",
                              header=None, names=["sentence"]))
df = pd.concat(frames, axis=0, ignore_index=True)
print("载入原始数据完毕,数据量", len(df))

# 加载预处理数据
import numpy as np
sentences = df['sentence'].tolist()
sentence_embeddings = np.load("data.npy")
print("载入向量数据完毕,数据量", len(sentence_embeddings))

# 使用构建向量时的模型来构建向量索引
import faiss
dimension = sentence_embeddings.shape[1]
quantizer = faiss.IndexFlatL2(dimension)
nlist = 50
index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
index.train(sentence_embeddings)
index.add(sentence_embeddings)
print("建立向量索引完毕,数据量", index.ntotal)

# 尝试进行查询
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('uer/sbert-base-chinese-nli')
print("载入模型完毕")

topK = 10
search = model.encode(["今天天儿怎么样"])
D, I = index.search(search, topK)
ret = df['sentence'].iloc[I[0]]
print(ret)

上面这段程序,包含了加载原始数据和预处理好的向量数据、建立向量索引、加载模型、进行内容检索并输出结果几个向量检索的必要步骤。在上一篇文章中,我们曾提到过建立向量索引的细节:如何选择索引、如何进行索引查询加速等等,所以就不再赘述了,感兴趣可以自行翻阅。

当我们执行程序之后,会得到类似下面的输出结果:

载入原始数据完毕数据量 1341940
载入向量数据完毕数据量 1341940
建立向量索引完毕 1341940

22966                              对预测阴晴冷暖的天气预报我们几乎每天都会接触到
138674                                            一个多月里天天刮风
1207740       沙尘暴来的时候天突然毫无征兆地昏暗下来。”长那么大侯朝茹还是头一回遭遇这么可怕的风暴
1307072                                  天气无常也加大了天气预报工作的难度
849630                                 收到气象灾害预警怎么办?(把自然讲给你听
22899      气候预测主要是对延伸期11天到30天)、季节和年度气候趋势进行预测包括气温降水等气...
788733     在这千里冰封的雪域高原上都是沙土和冻土怎么会有绿树扎根在这干旱多风5月才进春9月就...
290305     终于结束工作我感到一阵轻松安多县交警大队队长才加却眉头紧锁——“这种天气我最担心下雪...
22884                                       从预报天气到预测气候深度观察
783559      今年汛期的天气气候有什么特点今后雨情汛情情况如何防汛减灾应注意哪些问题记者采访了多位专家

是不是很神奇,许多结果中并没有包含“天气”这个关键词,但是从文本描述中,我们可以比较清晰的看到,这些结果确实都在聊“天气相关的事情”。这就是基于向量的文本检索的强大之处。

我们再进行一个更具象问题的检索,比如“五月份的天气怎么样”:

topK = 10
search = model.encode(["五月份的天气怎么样"])
D, I = index.search(search, topK)
ret = df['sentence'].iloc[I[0]]
print(ret)

在程序执行完毕之后,我们将得到类似下面的结果:

725502     本报北京5月4日电  记者李红梅中央气象台预计5月5日8时至8日8时北方地区将迎来大...
12507                      5月份南方地区出现5次区域性暴雨过程5月中旬以后降雨增多增强
860001                  据介绍从气候分布来看5月中下旬华北黄淮出现高温天气属于正常现象
12505      本报北京6月9日电  记者邱超奕记者从应急管理部获悉今年5月份我国自然灾害以风雹...
722412                                   从往年5月的暴雨日看也能验证这句话
411521                         今年5月1日入汛以来安徽全省平均降雨量较常年同期少四成
1300971                                5月的老挝正值热季地面上腾起厚重的雾气
847044                 5月初贵州西北部山区飘起纷纷扬扬的雪花乌蒙高原漫长的冬天似乎还没过去
873455     本报北京5月28日电  记者李红梅5月28日夜间至30日长江中下游及以南地区多降水...
886301                       进入农历五月长江流域是梅雨季雨多溽热潮湿易产生霉变
Name: sentence, dtype: object

对于上面的检索结果,是不是有些意外。想想一下,如果我们使用传统手段来进行检索或者识别,能否还能通过就写这么几行 Python 代码来搞定呢?

当然,除了天气,你也可以尝试通过它来进行一些定义、观点的检索,比如“如何看待人工智能技术”:

topK = 10
search = model.encode(["如何看待人工智能技术"])
D, I = index.search(search, topK)
ret = df['sentence'].iloc[I[0]]
print(ret)

检索结果会类似下面这样:

939548                                     人工智能的出现有望解决这些问题
306768                                      它是谁它就是AI人工智能)。
1095822                                    人工智能服务器市场中国厂商份额居前
765891                                       人脸识别是人工智能的重要应用
354354             高技能人才职称怎么评评定标准怎么定评职称对高技能人才有何影响记者进行了采访
838729     人工客服不能完全缺位智能客服也不能完全取代人工客服。”该负责人表示企业不能只看到智能客...
716819                                      如何看待当前的工业经济运行态势
718423                                      如何看待当前的工业经济运行态势
337290                                    人工智能助力癌症早期筛查创新故事
1034737                               比如人脸识别技术应用的就是计算机视觉算法

如果我们针对上面的内容再次进行深加工,比如关系和实体的分析、歧义的消除,进行一些领域知识的建模,就能够初步的完成一张知识图谱啦。(或许后面有机会,我们可以展开聊聊)

好了,相信机智的你一定可以发现,文本检索出现的许多结果,如果使用上文中的“传统检索”功能,基本是搜不出来的,因为相似度并不高,或者说从字符串匹配度上来看相似度非常低。当然,结果上还存在一些比如内容还并不够精准的问题,需要结合一些传统手段,或者对我们所使用的数据、模型进行进一步的“精耕细作”。

感受 Faiss 的检索性能

解决完“搜不到内容”的问题之后,我们来了解下 Faiss 的令人惊艳的性能,我们对上面的程序进行一些调整,让内容检索循环进行 1000 次,然后计算它的平均值:

import time

topK = 10
search = model.encode(["如何看待人工智能技术"])

costs = []
for x in range(1000):
  t0 = time.time()
  D, I = index.search(search, topK)
  t1 = time.time()
  costs.append(t1 - t0)

print("平均耗时 %7.3f ms" % ((sum(costs) / len(costs)) * 1000.0))

程序执行完毕后的结果:

平均耗时   5.086 ms

在没有做任何缓存、保持对全量数据进行检索的情况下,并使用比较慢的 Python 调用 faiss,从 134 万长短不一的内容中进行相似度计算,每次获取 10 个结果,平均每次请求只用了 5ms 左右。虽然已经达到了几毫秒级别,但是向量检索性能依旧存在比较大的优化空间,至于如何在生产环境中优化,我们后面的文章再慢慢聊。

其他

好啦,写到这里,关于如何入坑向量数据库的第二篇内容也就基本聊完啦。

本文中除了可以使用基于 SBERT(Sentence-BERT)这种基于有监督句向量的模型之外,还可以考虑使用 CoSENT (Cosine Sentence)。相比较前者在 huggingface 上每月只有 1 万出头的下载量,后者的下载量达到了惊人的每月 46 万。

调用方式也非常简单,只需要将本文中提到的模型加载相关代码中的模型名称进行替换即可,比如:

model = SentenceTransformer('uer/sbert-base-chinese-nli')
# 替换为
model = SentenceTransformer('shibing624/text2vec-base-chinese')

虽然向量相似度检索技术在文本检索方面有“奇效”,但是相对于传统检索手段而言,依旧存在着一些问题:模型的创造、使用都存在额外的计算成本,ETL 过程需要花费不少的时间,客户端语言性能也有比较大的提升空间,检索方面的调试目前还比较黑盒,以及想要在生产中“多快好省”,“稳定靠谱”,还需要额外的努力。

毕竟,我们都知道,这个世界上没有银弹。在许多场景下,它也并非是现有手段的完全替代,而是一颗不断进化的增强裂变弹。

最后

希望在看完这篇内容后,你对于传统文本检索与基于向量的语义检索会有一个相对立体的认知,能够熟悉这其中最常见的“套路”,并且了解到相似度检索技术,它到底解决了什么问题,我们在什么场景下需要借助“向量检索的能力”来解决问题。

当然,如果你希望像使用 MySQL 一样,向量索引都由软件自己个儿解决,并且有比较大规模的数据需要处理,那么依旧推荐你直接使用封装了 Faiss 的 Milvus ,或者更简单一些,直接使用 Milvus 的 Cloud 服务咯。

虽然写了这么多,但是关于文本检索还有很多没有聊到的问题,希望在后面的内容中,我们可以继续聊完这个话题。

祝大家,中秋快乐。

–EOF