前面的文章里我们提到,计算相似度,要抽取特征,对特征进行向量化,但是每用一次都要现抽取吗?那岂不是很麻烦,如果想要把已经处理好的向量保存起来怎么办?这就要提到向量数据库
什么是向量数据库
向量数据库是专门用来存放高维向量数据的数据库,可以通过嵌入模型将数据转换为向量的形式,并且能通过高效的索引和搜索算法快速检索。
向量数据库的核心作用是实现相似性搜索,即通过计 算向量之间的距离(如欧几里得距离、余弦相似度等) 来找到与目标向量最相似的其他向量。它特别适合处 理非结构化数据,支持语义搜索、内容推荐等场景。
存储的时候向量数据库将嵌入向量存储为高维空间中的点, 并为每个向量分配唯一标识符(ID),同时支持存储元数据。
检索的时候通过近似最近邻(ANN)算法(如PQ等)对向 量进行索引和快速搜索。比如,FAISS和Milvus等数据 库通过高效的索引结构加速检索。
常见的向量数据库
FAISS
facebook开发的一款数据库,适合大规模的静态数据集,优势是检索速度快,支持多种索引,缺点是主要用于静态数据,这个东西一开始是给搜索引擎用的,更新和删除操作比较复杂
Milvus
支持分布式架构和动态更新数据,扩展性强,数据管理功能比较灵活
Pinecone
云原生向量数据库,支持高性能向量搜索,完全托管在云厂商,适合大规模生产环境,对数据安全性要求非常高的场景可能不适合
向量数据库和传统数据库的区别
作为程序员用过不少数据库,关系型的非关系型的,内存的,磁盘的,那么向量数据库和过去的那些数据库有什么区别?
数据类型
传统数据库大多存的是结构化数据,向量数据库存的是高维向量数据,适合非结构化数据
查询方式
传统数据库依赖精确查询,模糊匹配性能较差,向量数据库基于相似度和距离查询(如欧几里得距离、余弦相似度)
应用场景
传统数据库适合机构化信息管理,向量数据库适合语义搜索,内容推荐等需要相似性计算的场景
向量数据库的使用:以FAISS为例
faiss是facebook开发的向量数据库,不过这个东西只能放在内存里。
bash
pip install faiss-cpu
pip install faiss-gpu
常用功能
Faiss的常用功能包括:索引,PCA降维,PQ乘积量化 Faiss有两个基础的索引类Index,IndexBinary
具体用什么索引可以根据实际情况进行选择
- 要求精度,选择IndexFlatL2,能返回精确结果
- 要速度快,选择IndexIVFFlat,简单来说,这个索引是基于近邻算法的,将数据库中的向量进行聚类,然后用近邻算法计算离哪一类更近,最终选择哪一类,但问题是,如果想要的结果被分到了相对远的那一类,结果会不准,但是吧。。你就说快不快就完了。
- 要内存小,使用IndexVFPQ,可以在聚类的基础上使用PQ乘积量化进行处理
PQ乘积量化
至于什么是PQ乘积量化,简单来说,就是你的身份证号,全国现在有14亿人,如果我们每个人的身份证上的编码都是xx省xx市xx区xxxx年xx月xx日xxxx号男/女,那么存储起来是不是特别的费空间,查找起来也很费劲,所以这个时候我们可以先分类比如北京的分一类,给编号110,河北的分一类,给编号131,然后再按区,县,生日,等级顺序,性别分类,这样最终的结果就变成了,1101051999030322331,
所谓PQ乘积就是这样,假如我们有一堆128维的向量 第一步,分割向量,比如把128维分为6份,一份18维
第二步,聚类,建立码本,对每个子向量集合,使用聚类算法(比如K-means)生成一个"码本",也就是一些代表性的中心点。每个子向量可以用最近的中心点编号来表示。
最终,这一堆128维的向量可以用8个编号来表示
等需要查找的时候,我只需要找到最相似的那个向量的编号就可以快速的找到想要的向量,当然,就和110105编号下的人不一定都一样是一回事,选出来的向量也未必很精确,但是够用了
下面上代码
首先我们创建2000个512维的向量,定义随机种子为0,定义均值为3,标准差为0.1,使每个向量符合正态分布
python
import numpy as np
import matplotlib.pyplot as plt
d = 512
n_data = 2000
np.random.seed(0)
data = []
mu = 3 # 定义正态分布的均值为3
sigma=0.1 # 定义正态分布的标准差为0.1。
# 创建2000个512维的向量
for i in range(n_data):
data.append(np.random.normal(mu,sigma,d))
data = np.array(data).astype('float32')
plt.hist(data[5])
plt.show()
IndexFlatL2索引
python
import faiss
index = faiss.IndexFlatL2(d) # 创建了一个基于 L2 距离(欧氏距离)的索引。是一种简单的索引,不需要训练。
print(index.is_trained) #IndexFlat2 索引不需要训练
index.add(data)
print(index.ntotal) # 输出向量总量
k=10 # 查10个向量
query_self = data[:5] # 取5个向量用于查询
print("================query_self==============")
print(query_self)
dis,ind = index.search(query_self,k)
print("================dis.shape==============")
print(dis.shape)
print("================ind.shape==============")
print(ind.shape)
print("================dis==============")
print(dis) # 一个二维数组,表示查询向量与最近邻向量之间的距离(L2 距离)。
print("================ind==============")
print(ind) # 一个二维数组,表示最近邻向量在索引中的索引(位置)。
# 取出第一个查询的最近邻向量
nearest_neighbors = data[ind]
print("================nearest_neighbors==============")
print("nearest_neighbors:", nearest_neighbors.shape)
print("nearest_neighbors:", nearest_neighbors[0])
这段代码从我们创建的模拟数据集中取出了5个向量,然后基于L2索引,查找到最近邻的k个向量,最后输出和每个最近邻向量的举例以及每个最近邻向量的索引,最终打印最近邻向量的张量为(5, 10, 128) 并且输出第一个最近邻向量 输出:
yaml
False
True
================dis==============
[[0. 8.007045 8.313329 8.53525 8.560174 8.561642 8.6241665
8.628233 8.709977 8.770039 ]
[0. 8.27809 8.355577 8.42606 8.462017 8.468869 8.487028
8.549964 8.562822 8.599199 ]
[0. 8.152369 8.156568 8.223303 8.276015 8.376868 8.379269
8.406122 8.418619 8.443282 ]
[0. 8.260519 8.336826 8.339299 8.402878 8.46439 8.474662
8.479043 8.485247 8.5266 ]
[0. 8.346273 8.407202 8.462828 8.497231 8.5208 8.597084
8.600385 8.605134 8.630593 ]]
================ind==============
[[ 0 798 879 223 981 1401 1458 1174 919 26]
[ 1 981 1524 1639 1949 1472 1162 923 840 300]
[ 2 1886 375 1351 518 1735 1551 1958 390 1695]
[ 3 1459 331 389 655 1943 1483 1723 1672 1859]
[ 4 13 715 1470 608 459 888 850 1080 1654]]
================nearest_neighbors==============
nearest_neighbors: (5, 10, 512)
nearest_neighbors: [[3.1764052 3.0400157 3.0978737 ... 3.1301427 3.089526 3.1374965]
[3.111482 2.8542 3.2902675 ... 2.9402976 3.0721276 3.029501 ]
[3.0331905 2.981797 2.9820464 ... 3.24617 2.9453766 2.889931 ]
...
[2.9135046 2.8860621 2.9794722 ... 2.9276145 2.8511794 2.9924355]
[2.932487 2.974919 2.903022 ... 2.9555287 3.0711067 2.8046498]
[2.8823245 3.0119574 2.9755175 ... 2.8666506 2.9604957 3.0176444]]
IndexIVFFlat
python
nlist = 50 # 将数据集划分为 50 个聚类(Voronoi 单元)。
k = 10 # 搜索时返回的最近邻数量。
quantizer = faiss.IndexFlatL2(d) # 使用 L2 距离(欧氏距离)作为距离度量方式。
# METRIC_L2计算L2距离, 或faiss.METRIC_INNER_PRODUCT计算内积
# 使用 **倒排文件(IVF)** 结构来加速搜索
# `quantizer`:用于聚类向量的量化器。
# `d`:向量的维度。
# `nlist`:聚类数量。
# `faiss.METRIC_L2`:使用 L2 距离作为距离度量。
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2)
# 检查索引是否已经训练 IndexIVFFlat索引需要进行训练
print(index.is_trained)
index.train(data)
print(index.is_trained)
query = data[:5]
index.add(data)
index.nprobe = 50 # 搜索时检查的聚类数量。这里设置为 50,表示搜索时会检查所有 50 个聚类。
dis, ind = index.search(query, k)
print("================dis==============")
print(dis) # 每个查询向量的最近邻距离
print("================ind==============")
print(ind) # 每个查询向量的最近邻索引
nearest_neighbors = data[ind] # 根据索引 `ind` 从数据集中提取最近邻向量
print("================nearest_neighbors==============")
print("nearest_neighbors:", nearest_neighbors.shape)
print("nearest_neighbors:", nearest_neighbors[0])
这段代码中,我们使用了倒排文件索引,并且将数据集分为50个聚类,最后在50个聚类中搜索用于查询的向量的最近邻向量,输出:
yaml
befor train: False
after train: True
================dis==============
[[0. 8.007045 8.313329 8.53525 8.560174 8.561642 8.6241665
8.628233 8.709977 8.770039 ]
[0. 8.27809 8.355577 8.42606 8.462017 8.468869 8.487028
8.549964 8.562822 8.599199 ]
[0. 8.152369 8.156568 8.223303 8.276015 8.376868 8.379269
8.406122 8.418619 8.443282 ]
[0. 8.260519 8.336826 8.339299 8.402878 8.46439 8.474662
8.479043 8.485247 8.5266 ]
[0. 8.346273 8.407202 8.462828 8.497231 8.5208 8.597084
8.600385 8.605134 8.630593 ]]
================ind==============
[[ 0 798 879 223 981 1401 1458 1174 919 26]
[ 1 981 1524 1639 1949 1472 1162 923 840 300]
[ 2 1886 375 1351 518 1735 1551 1958 390 1695]
[ 3 1459 331 389 655 1943 1483 1723 1672 1859]
[ 4 13 715 1470 608 459 888 850 1080 1654]]
================nearest_neighbors==============
nearest_neighbors: (5, 10, 512)
nearest_neighbors: [[3.1764052 3.0400157 3.0978737 ... 3.1301427 3.089526 3.1374965]
[3.111482 2.8542 3.2902675 ... 2.9402976 3.0721276 3.029501 ]
[3.0331905 2.981797 2.9820464 ... 3.24617 2.9453766 2.889931 ]
...
[2.9135046 2.8860621 2.9794722 ... 2.9276145 2.8511794 2.9924355]
[2.932487 2.974919 2.903022 ... 2.9555287 3.0711067 2.8046498]
[2.8823245 3.0119574 2.9755175 ... 2.8666506 2.9604957 3.0176444]]
IndexIVFFlat虽然查询上比IndexFlatL2效率要高,但是这两种索引都会保存完整的数据集,会占用大量内存,如果希望控制内存占用,可以使用IndexIVFPQ,但PQ乘积是一种有损压缩方式,所以结果是近似的
IndexIVFPQ
python
nlist = 50 # 将数据集划分为 50 个聚类(Voronoi 单元)
m=8 # 将向量划分为 8 个子向量。
k=10 # 搜索时返回的最近邻数量。
quantizer = faiss.IndexFlatL2(d) # 使用 **L2 距离(欧氏距离)** 作为距离度量方式。
`IndexIVFPQ`:结合了 **倒排文件(IVF)** 和 **乘积量化(PQ)** 的索引结构。
`quantizer`:用于聚类向量的量化器。
`d`:向量的维度。
`nlist`:聚类数量。
`m`:PQ 中子向量的数量。
`8`:每个子向量的量化比特数(通常为 8)。
index= faiss.IndexIVFPQ(quantizer,d,nlist,m,8)
index.train(data)
index.add(data)
index.nprobe =50 # 搜索时会检查所有 50 个聚类。
dis,ind = index.search(query_self,k)
print(dis)
print(ind)
输出:
yaml
[[4.372558 5.0895953 5.104769 5.111246 5.186148 5.19653 5.209956
5.221727 5.227094 5.2308445]
[3.9376266 4.6762385 4.757644 4.8403053 4.853147 4.8878355 4.9303913
4.9305844 4.954421 4.956273 ]
[4.0593576 4.599588 4.7909384 4.8031087 4.843738 4.844117 4.863806
4.8646784 4.867581 4.870366 ]
[4.2857723 4.9091744 4.9118447 4.9528413 4.9682527 4.9728827 4.9738464
4.976169 4.981165 4.9832325]
[4.287761 4.865623 4.915735 4.9202313 4.932449 4.9421916 4.9499846
4.950701 4.9576845 4.967565 ]]
[[ 0 2280 2478 377 157 975 2495 615 180 1499]
[ 1 1113 1405 586 285 951 1088 1570 2179 1315]
[ 2 1021 2299 2313 1972 1545 118 789 375 1695]
[ 3 2067 1215 2151 1928 737 2045 2242 389 763]
[ 4 2361 1396 1274 873 888 13 573 2209 166]]
以上是faiss的基本使用方法,下面是喜闻乐见的实际应用环节
场景示例
现在有一个检测抄袭文章的需求,我们用分类+聚类的方式和faiss方式分别实现一下
普通版本:基于分类+聚类
这个版本基于分类算法来预测文章是否为抄袭,
python
import re
import numpy as np
import pandas as pd
import jieba
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
from sklearn.model_selection import train_test_split, cross_validate
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.naive_bayes import MultinomialNB
from sklearn.metrics.pairwise import cosine_similarity
from concurrent.futures import ThreadPoolExecutor
import pickle
from tqdm import tqdm
from pprint import pprint
import os
# 加载停用词
with open('./data/chinese_stopwords.txt','r', encoding='utf-8') as file:
stopwords=[i[:-1] for i in file.readlines()]
#stopwords = [line.strip() for line in open('chinsesstoptxt.txt',encoding='UTF-8').readlines()]
# 数据加载
news = pd.read_csv('./data/sqlResult.csv',encoding='gb18030')
print(news.shape)
print(news.head(5))
# 处理缺失值
print(news[news.content.isna()].head(5))
news=news.dropna(subset=['content'])
print(news.shape)
# 分词
def split_text(text):
#return ' '.join([w for w in list(jieba.cut(re.sub('\s|[%s]' % (punctuation),'',text))) if w not in stopwords])
text = text.replace(' ', '')
text = text.replace('\n', '')
text2 = jieba.cut(text.strip())
result = ' '.join([w for w in text2 if w not in stopwords])
return result
print(news.iloc[0].content)
print(split_text(news.iloc[0].content))
if not os.path.exists("./data/corpus.pkl"):
# 对所有文本进行分词
corpus=list(map(split_text,[str(i) for i in news.content]))
task_list = []
corpus = []
print(len(news.content))
print(corpus[0])
print(len(corpus))
print(corpus[1])
# 保存到文件,方便下次调用
with open('./data/corpus.pkl','wb') as file:
pickle.dump(corpus, file)
else:
# 调用上次处理的结果
with open('./data/corpus.pkl','rb') as file:
corpus = pickle.load(file)
# 得到corpus的TF-IDF矩阵
countvectorizer = CountVectorizer(encoding='gb18030',min_df=0.015)
tfidftransformer = TfidfTransformer()
countvector = countvectorizer.fit_transform(corpus)
print(countvector.shape)
tfidf = tfidftransformer.fit_transform(countvector)
print(tfidf.shape)
# 标记是否为自己的新闻
label=list(map(lambda source: 1 if '新华' in str(source) else 0,news.source))
#print(label)
# 数据集切分
X_train, X_test, y_train, y_test = train_test_split(tfidf.toarray(), label, test_size = 0.3, random_state=42)
clf = MultinomialNB()
clf.fit(X=X_train, y=y_train)
"""
# 进行CV=3折交叉验证
scores=cross_validate(clf, X_train, y_train, scoring=('accuracy','precision','recall','f1'), cv=3, return_train_score=True)
pprint(scores)
"""
y_predict = clf.predict(X_test)
def show_test_reslt(y_true,y_pred):
print('accuracy:',accuracy_score(y_true,y_pred))
print('precison:',precision_score(y_true,y_pred))
print('recall:',recall_score(y_true,y_pred))
print('f1_score:',f1_score(y_true,y_pred))
show_test_reslt(y_test, y_predict)
# 使用模型检测抄袭新闻
prediction = clf.predict(tfidf.toarray())
labels = np.array(label)
# compare_news_index中有两列:prediction为预测,labels为真实值
compare_news_index = pd.DataFrame({'prediction':prediction,'labels':labels})
# copy_news_index:可能是Copy的新闻(即找到预测为1,但是实际不是"新华社")
copy_news_index=compare_news_index[(compare_news_index['prediction'] == 1) & (compare_news_index['labels'] == 0)].index
# 实际为新华社的新闻
xinhuashe_news_index=compare_news_index[(compare_news_index['labels'] == 1)].index
print('可能为Copy的新闻条数:', len(copy_news_index))
if not os.path.exists("label.pkl"):
# 使用k-means对文章进行聚类
from sklearn.preprocessing import Normalizer
from sklearn.cluster import KMeans
normalizer = Normalizer()
scaled_array = normalizer.fit_transform(tfidf.toarray())
# 使用K-Means, 对全量文档进行聚类
kmeans = KMeans(n_clusters=25,random_state=42)
k_labels = kmeans.fit_predict(scaled_array)
# 保存到文件,方便下次调用
with open('label.pkl','wb') as file:
pickle.dump(k_labels, file)
print(k_labels.shape)
print(k_labels[0])
else:
# 调用上次处理的结果
with open('label.pkl','rb') as file:
k_labels = pickle.load(file)
if not os.path.exists("id_class.pkl"):
# 创建id_class
id_class = {index:class_ for index, class_ in enumerate(k_labels)}
# 保存到文件,方便下次调用
with open('id_class.pkl','wb') as file:
pickle.dump(id_class, file)
else:
# 调用上次处理的结果
with open('id_class.pkl','rb') as file:
id_class = pickle.load(file)
if not os.path.exists("class_id.pkl"):
from collections import defaultdict
# 创建你class_id字段,key为classId,value为文档index
class_id = defaultdict(set)
for index,class_ in id_class.items():
# 只统计新华社发布的class_id
if index in xinhuashe_news_index.tolist():
class_id[class_].add(index)
# 保存到文件,方便下次调用
with open('class_id.pkl','wb') as file:
pickle.dump(class_id, file)
else:
# 调用上次处理的结果
with open('class_id.pkl','rb') as file:
class_id = pickle.load(file)
# 输出每个类别的 文档个数
count=0
for k in class_id:
print(count, len(class_id[k]))
count +=1
# 查找相似文本(使用聚类结果进行filter)
def find_similar_text(cpindex, top=10):
# 只在新华社发布的文章中查找
dist_dict={i:cosine_similarity(tfidf[cpindex],tfidf[i]) for i in class_id[id_class[cpindex]]}
# 从大到小进行排序
return sorted(dist_dict.items(),key=lambda x:x[1][0], reverse=True)[:top]
import editdistance
# 指定某篇文章的相似度
#print(copy_news_index)
cpindex = 3352 # 在copy_news_index
#print('是否在新华社', cpindex in xinhuashe_news_index)
#print('是否在copy_news', cpindex in copy_news_index)
#print('3134是否在新华社', 3134 in xinhuashe_news_index)
#print('3134是否在copy_news', 3134 in copy_news_index)
#print(cpindex)
similar_list = find_similar_text(cpindex)
print(similar_list)
print('怀疑抄袭:\n', news.iloc[cpindex].content)
# 找一篇相似的原文
similar2 = similar_list[0][0]
print('相似原文:\n', news.iloc[similar2].content)
# 求任意两篇文章的编辑距离
print('编辑距离:',editdistance.eval(corpus[cpindex], corpus[similar2]))
def find_similar_sentence(candidate, raw):
similist = []
cl = candidate.strip().split('。')
ra = raw.strip().split('。')
for c in cl:
for r in ra:
similist.append([c,r,editdistance.eval(c,r)])
# 最相似的5个句子
sort=sorted(similist,key=lambda x:x[2])[:5]
for c,r,ed in sort:
if c!='' and r!='':
print('怀疑抄袭句:{0}\n相似原句:{1}\n 编辑距离:{2}\n'.format(c,r,ed))
# 查找copy文章 和第一相似的原文的比对
find_similar_sentence(news.iloc[cpindex].content, news.iloc[similar2].content)
faiss版本
python
import numpy as np
import pandas as pd
import pickle
import os
import re
import jieba
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
"""
from sklearn.model_selection import train_test_split, cross_validate
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.naive_bayes import MultinomialNB
from sklearn.metrics.pairwise import cosine_similarity
from tqdm import tqdm
"""
# 定义分词函数
def split_text(text):
"""
对文本进行分词处理
:param text: 输入文本
:return: 分词后的文本,以空格分隔
"""
# 使用正则表达式去除特殊字符和数字
text = re.sub(r'[^\u4e00-\u9fa5]', '', text)
# 使用jieba进行分词
words = jieba.cut(text)
# 返回分词结果,以空格分隔
return ' '.join(words)
path = './'
# 数据加载
news = pd.read_csv(path+'sqlResult.csv',encoding='gb18030')
# 处理缺失值
print(news[news.content.isna()].head(5))
news=news.dropna(subset=['content'])
# 加载清洗后的corpus
if not os.path.exists(path+"corpus.pkl"):
# 对所有文本进行分词
corpus=list(map(split_text,[str(i) for i in news.content]))
print(corpus[0])
print(len(corpus))
print(corpus[1])
# 保存到文件,方便下次调用
with open(path+'corpus.pkl','wb') as file:
pickle.dump(corpus, file)
else:
# 调用上次处理的结果
with open(path+'corpus.pkl','rb') as file:
corpus = pickle.load(file)
# 得到corpus的TF-IDF矩阵
if not os.path.exists(path+"tfidf.pkl"):
countvectorizer = CountVectorizer(encoding='gb18030',min_df=0.015)
tfidftransformer = TfidfTransformer()
countvector = countvectorizer.fit_transform(corpus)
print(countvector.shape)
tfidf = tfidftransformer.fit_transform(countvector)
print(tfidf.shape)
# 保存到文件,方便下次调用
with open(path+'tfidf.pkl','wb') as file:
pickle.dump(tfidf, file)
else:
# 调用上次处理的结果
with open(path+'tfidf.pkl','rb') as file:
tfidf = pickle.load(file)
#print(type(tfidf))
# 将csr_matrix 转换为 numpy.ndarray类型, 同时将原来float64类型转换为float32类型
tfidf = tfidf.toarray().astype(np.float32)
# embedding的维度
d = tfidf.shape[1]
print(d)
print(tfidf.shape)
print(type(tfidf))
#print(tfidf[1])
print(type(tfidf[1][1]))
# 精确索引
import faiss
index = faiss.IndexFlatL2(d) # 构建 IndexFlatL2
print(index.is_trained) # False时需要train
index.add(tfidf) #添加数据
print(index.ntotal) #index中向量的个数
#精确索引无需训练便可直接查询
k = 10 # 返回结果个数
cpindex = 3352
query_self = tfidf[cpindex:cpindex+1] # 查询本身
dis, ind = index.search(query_self, k)
print(dis.shape) # 打印张量 (5, 10)
print(ind.shape) # 打印张量 (5, 10)
print(dis) # 升序返回每个查询向量的距离
print(ind) # 升序返回每个查询向量
print('怀疑抄袭:\n', news.iloc[cpindex].content)
# 找一篇相似的原文
similar2 = ind[0][1]
print(similar2)
print('相似原文:\n', news.iloc[similar2].content)