语义搜索是基于自然语言处理(NLP)最新进展的新类别。传统搜索系统使用关键字查找数据。语义搜索对自然语言有了解,并确定具有相同含义的结果,不一定是相同的关键字。
虽然语义搜索增加了惊人的功能,但稀疏关键字索引仍然可以增加值。在某些情况下,找到确切匹配很重要,或者我们只希望快速索引快速进行数据集的初始扫描。
不幸的是,对于本地基于Python的关键字索引库,没有很多很棒的选择。大多数可用的选项都不扩展和/或高效,仅专为简单情况而设计。
鉴于Python是一种解释的语言,从性能的角度来看,它通常会出现不良的说唱。在某些情况下,它是合理的,因为Python可能是饥饿的内存,并且具有迫使单线执行的全局解释器锁(GIL)。但是可以与其他语言相构建表演python。
本文将探讨如何在Python中构建有效的稀疏关键字索引,并将结果与其他方法进行比较。
安装依赖项
安装txtai
和所有依赖项。
# Install txtai
pip install txtai pytrec_eval rank-bm25 elasticsearch==7.10.1
引入问题
在高级别,关键字索引通过将文本纳入每个文档的令牌列表来工作。这些令牌汇总为每个文档频率,并以期限稀疏阵列存储。
术语频率阵列稀疏,因为它们仅在文档中存在令牌时存储频率。例如,如果在1000个文档中的1个中存在一个令牌,则稀疏数组只有一个条目。一个密集的阵列存储1000个条目,除了一个。
一种存储python中的术语频率稀疏阵列的简单方法是每个令牌的{id: frequency}
字典。这种方法的问题在于,Python在开销上都有重要的对象。
让我们检查用于单个数字的大小。
import sys
a = 100
sys.getsizeof(a)
28
28个字节,用于一个整数。与4或8个字节的本地int/long相比,这是非常浪费的。想象一下有成千上万的id: frequency
映射。内存使用率将快速增长。
让我们演示。下面的代码运行一个自我包含的Python进程,该过程创建了1000万个数字的列表。
作为单独的过程运行有助于计算更准确的内存使用统计。
import psutil
results = []
for x in range(int(1e7)):
results.append(x)
print(f"MEMORY USAGE = {psutil.Process().memory_info().rss / (1024 * 1024)} MB")
MEMORY USAGE = 394.640625 MB
该数组使用了大约395 MB的内存。这似乎很高。
Python中有效的数字阵列
幸运的是,Python有一个用于构建efficient arrays of numeric values的模块。该模块启用具有相同本地类型的建筑阵列。
让我们尝试使用long long
类型进行8个字节。
from array import array
import psutil
results = array("q")
for x in range(int(1e7)):
results.append(x)
print(f"MEMORY USAGE = {psutil.Process().memory_info().rss / (1024 * 1024)} MB")
MEMORY USAGE = 88.54296875 MB
正如我们所看到的,内存使用率从395 MB升至89 MB。这是一个降低的4倍,与较早的28个字节/数字vs 8字节/数字相符。
有效处理数字数据
纯Python中的大型计算也可能会慢慢慢。幸运的是,数字处理的选项有强大的景观。最受欢迎的框架是NumPy。还有PyTorch和其他基于GPU的张量处理框架。
下面是一个简单的示例,该示例与python vs numpy中的数组进行了分类。
import random
import time
data = [random.randint(1, 500) for x in range(1000000)]
start = time.time()
sorted(data, reverse=True)
print(time.time() - start)
0.33922290802001953
import numpy as np
data = np.array(data)
start = time.time()
np.sort(data)[::-1]
print(time.time() - start)
0.10296249389648438
正如我们所看到的,在numpy中对数组进行排序的速度明显更快。这似乎不是很多
TXTAI中的稀疏关键字索引
现在我们已经讨论了关键绩效概念,让我们谈谈如何将其应用于构建稀疏关键字索引。
回到原始频率稀疏阵列的原始方法,我们看到使用Python数组软件包更有效。在TXTAI中,此方法用于为每个令牌构建术语频率阵列。这会导致本地速度和内存使用量。
搜索方法使用多种Numpy方法来有效计算查询项匹配。每个查询都是令牌化的,并检索那些令牌项频率阵列以计算查询分数。这些数字方法全部写在C中,通常会丢弃GIL。因此,再次,接近天然速度和使用多线程的能力。
阅读full implementation on GitHub以了解更多信息。
评估性能
首先,对景观的评论。如引言中所述,没有很多好的选择。从速度,性能和功能角度来看,Apache Lucene是迄今为止最好的传统搜索指数。这是Elasticsearch/Opensearch和许多其他项目的基础。但这需要Java。
这是我们将要探索的选项。
-
Rank-BM25项目,搜索
python bm25
时的首要结果。 -
SQLite FTS5扩展。此扩展名在SQLite中构建一个稀疏的关键字索引。
我们将使用Beir数据集。我们还将使用TXTAI项目中的benchmarks script。此基准测试脚本具有与Beir DataSet合作的方法。
基准脚本上的重要警告。
- 对于SQLite FTS实现,每个令牌与
OR
子句一起连接在一起。默认情况下,sqlite fts implicitly joins clauses together带有AND
条款。相比之下,Lucene's default operator是OR
。 - Elasticsearch实现使用7.x,因为在笔记本中实例化更简单。
- 除Elasticsearch以外的所有方法都使用TXTAI的unicode tokenizer来使文本具有一致性
import os
# Get benchmarks script
os.system("wget https://raw.githubusercontent.com/neuml/txtai/master/examples/benchmarks.py")
# Create output directory
os.makedirs("beir", exist_ok=True)
# Download subset of BEIR datasets
datasets = ["trec-covid", "nfcorpus", "webis-touche2020", "scidocs", "scifact"]
for dataset in datasets:
url = f"https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/{dataset}.zip"
os.system(f"wget {url}")
os.system(f"mv {dataset}.zip beir")
os.system(f"unzip -d beir beir/{dataset}.zip")
# Remove existing benchmark data
if os.path.exists("benchmarks.json"):
os.remove("benchmarks.json")
现在让我们运行基准。
# Remove existing benchmark data
if os.path.exists("benchmarks.json"):
os.remove("benchmarks.json")
# Runs benchmark evaluation
def evaluate(method):
for dataset in datasets:
command = f"python benchmarks.py beir {dataset} {method}"
print(command)
os.system(command)
# Calculate benchmarks
for method in ["bm25", "rank", "sqlite"]:
evaluate(method)
import json
import pandas as pd
def benchmarks():
# Read JSON lines data
with open("benchmarks.json") as f:
data = f.read()
df = pd.read_json(data, lines=True).sort_values(by=["source", "search"])
return df[["source", "method", "index", "memory", "search", "ndcg_cut_10", "map_cut_10", "recall_10", "P_10"]].reset_index(drop=True)
# Load benchmarks dataframe
df = benchmarks()
df[df.source == "trec-covid"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
Trec-Covid | BM25 td> | 101.96 | 997 | 0.28 | 0.58119 | 0.01247 | 0.01545 | 0.618 |
Trec-Covid | sqlite | 60.16 | 880 | 23.09 | 0.56778 | 0.01190 | 0.01519 | 0.610 |
Trec-Covid | 等级 | 61.75 | 3245 | 75.49 | 0.57773 | 0.01210 | 0.01550 | 0.632 |
df[df.source == "nfcorpus"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
nfcorpus | BM25 td> | 2.64 | 648 | 1.08 | 0.30639 | 0.11728 | 0.14891 | 0.21734 |
nfcorpus | sqlite | 1.50 | 630 | 12.73 | 0.30695 | 0.11785 | 0.14871 | 0.21641 |
nfcorpus | 等级 | 2.75 | 700 | 23.78 | 0.30692 | 0.11711 | 0.15320 | 0.21889 |
df[df.source == "webis-touche2020"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
Webis-toch2020 td> | BM25 td> | 374.66 | 1137 | 0.37 | 0.36920 | 0.14588 | 0.22736 | 0.34694 |
Webis-toch2020 td> | sqlite | 220.46 | 1416 | 34.61 | 0.37194 | 0.14812 | 0.22890 | 0.35102 |
Webis-toch2020 td> | 等级 | 224.07 | 10347 | 81.22 | 0.39861 | 0.16492 | 0.23770 | 0.36122 |
df[df.source == "scidocs"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
Scidocs | BM25 td> | 17.95 | 717 | 1.64 | 0.15063 | 0.08756 | 0.15637 | 0.0772 |
Scidocs | sqlite | 17.85 | 670 | 56.64 | 0.15156 | 0.08822 | 0.15717 | 0.0776 |
Scidocs | 等级 | 13.11 | 1056 | 162.99 | 0.14932 | 0.08670 | 0.15408 | 0.0761 |
df[df.source == "scifact"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
scifact | BM25 td> | 5.51 | 653 | 1.07 | 0.66324 | 0.61764 | 0.78761 | 0.087 |
scifact | sqlite | 1.85 | 631 | 20.28 | 0.66630 | 0.61966 | 0.79494 | 0.088 |
scifact | 等级 | 1.85 | 724 | 42.22 | 0.65618 | 0.61204 | 0.77400 | 0.085 |
上面的部分显示了每个源和方法的指标。
表标头列出了source (dataset)
,index method
,index time(s)
,memory usage(MB)
,search time(s)
和NDCG@10
/MAP@10
/RECALL@10
/RECALL@10
/P@10
精度指标。表由search time
排序。
正如我们所看到的,TXTAI的实现总体上是最快的搜索时间。但是,在索引时间方面速度较慢。准确度指标略有不同,但每种方法都大致相同。
内存用法脱颖而出。 Sqlite和Txtai的每个源都有相同的用法。 RANK-BM25内存使用情况可能会迅速失控。例如,webis-touch2020
(仅为400K记录)与700 MB
相比,使用10 GB
的其他实现。
与Elasticsearch进行比较
现在,我们已经审查了在Python中构建关键字索引的方法,让我们看看TXTAI的稀疏关键字索引与Elasticsearch的比较。
我们将旋转一个内联实例并运行相同的评估。
# Download and extract elasticsearch
os.system("wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.10.1-linux-x86_64.tar.gz")
os.system("tar -xzf elasticsearch-7.10.1-linux-x86_64.tar.gz")
os.system("chown -R daemon:daemon elasticsearch-7.10.1")
from subprocess import Popen, PIPE, STDOUT
# Start and wait for server
server = Popen(['elasticsearch-7.10.1/bin/elasticsearch'], stdout=PIPE, stderr=STDOUT, preexec_fn=lambda: os.setuid(1))
# Add benchmark evaluations for Elasticsearch
evaluate("es")
# Reload benchmarks dataframe
df = benchmarks()
df[df.source == "trec-covid"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
Trec-Covid | BM25 td> | 101.96 | 997 | 0.28 | 0.58119 | 0.01247 | 0.01545 | 0.618 |
Trec-Covid | ES | 71.24 | 636 | 2.09 | 0.59215 | 0.01261 | 0.01590 | 0.636 |
Trec-Covid | sqlite | 60.16 | 880 | 23.09 | 0.56778 | 0.01190 | 0.01519 | 0.610 |
Trec-Covid | 等级 | 61.75 | 3245 | 75.49 | 0.57773 | 0.01210 | 0.01550 | 0.632 |
df[df.source == "nfcorpus"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
nfcorpus | BM25 td> | 2.64 | 648 | 1.08 | 0.30639 | 0.11728 | 0.14891 | 0.21734 |
nfcorpus | ES | 3.95 | 627 | 11.47 | 0.30676 | 0.11761 | 0.14894 | 0.21610 |
nfcorpus | sqlite | 1.50 | 630 | 12.73 | 0.30695 | 0.11785 | 0.14871 | 0.21641 |
nfcorpus | 等级 | 2.75 | 700 | 23.78 | 0.30692 | 0.11711 | 0.15320 | 0.21889 |
df[df.source == "webis-touche2020"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
Webis-toch2020 td> | BM25 td> | 374.66 | 1137 | 0.37 | 0.36920 | 0.14588 | 0.22736 | 0.34694 |
Webis-toch2020 td> | ES | 168.28 | 629 | 0.62 | 0.37519 | 0.14819 | 0.22889 | 0.35102 |
Webis-toch2020 td> | sqlite | 220.46 | 1416 | 34.61 | 0.37194 | 0.14812 | 0.22890 | 0.35102 |
Webis-toch2020 td> | 等级 | 224.07 | 10347 | 81.22 | 0.39861 | 0.16492 | 0.23770 | 0.36122 |
df[df.source == "scidocs"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
Scidocs | BM25 td> | 17.95 | 717 | 1.64 | 0.15063 | 0.08756 | 0.15637 | 0.0772 |
Scidocs | ES | 11.07 | 632 | 10.25 | 0.14924 | 0.08671 | 0.15497 | 0.0765 |
Scidocs | sqlite | 17.85 | 670 | 56.64 | 0.15156 | 0.08822 | 0.15717 | 0.0776 |
Scidocs | 等级 | 13.11 | 1056 | 162.99 | 0.14932 | 0.08670 | 0.15408 | 0.0761 |
df[df.source == "scifact"].reset_index(drop=True)
来源 | 方法 | 索引 | 记忆 | 搜索 | ndcg_cut_10 | map_cut_10 | recome_10 | p_10 |
---|---|---|---|---|---|---|---|---|
scifact | BM25 td> | 5.51 | 653 | 1.07 | 0.66324 | 0.61764 | 0.78761 | 0.08700 |
scifact | ES | 2.90 | 625 | 9.62 | 0.66058 | 0.61518 | 0.78428 | 0.08667 |
scifact | sqlite | 1.85 | 631 | 20.28 | 0.66630 | 0.61966 | 0.79494 | 0.08800 |
scifact | 等级 | 1.85 | 724 | 42.22 | 0.65618 | 0.61204 | 0.77400 | 0.08500 |
TXTAI的实现再次与Elasticsearch进行了很好的比较。精度指标各不相同,但都相同。
重要的是要注意,在使用固态存储的内部测试中,Elasticsearch和Txtai的速度大致相同。这些时代的Elasticsearch有点慢是在Google Colab环境中运行的产物。
包起来
本笔记本显示了如何在Python中构建有效的稀疏关键字索引。基准测试表明,从准确性和速度的角度,TXTAI与Apache Lucene相当地提供了强大的实现。
此关键字索引可以用作python中的独立索引,也可以与密度向量索引组合形成hybrid
索引。