PubMed:稠密+稀疏向量
一个关于语义搜索何时有帮助、精确术语何时重要以及为什么结合两者有用的实用Qdrant实验。
梯形图转SCL | 博途AI辅助编程文档 | AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo
当我开始这个项目时,我以为问题很简单:如果有人用完整的句子搜索生物医学研究,语义搜索应该就足够了。
然后我看了看医生和研究人员实际写的查询类型。一个查询可以同时包含自然语言请求、疾病名称、确切的基因符号、像BRAF V600E这样的突变以及日期限制。搜索系统必须理解句子的含义,同时不丢失隐藏在其中的确切符号。
这就是我构建的实验。我比较了三种搜索生物医学论文的方法:
- 稠密搜索,将查询和论文转换为向量以检索相似含义。
- 稀疏BM25搜索,奖励有用的词重叠并保留确切术语。
- 混合搜索,运行两种搜索并组合其排序结果。
这是一个检索基准,不是诊断工具也不是聊天机器人。输入是搜索查询。输出是PubMed论文的排序列表。我特意停在这里,以便在语言模型或生成答案能够隐藏检索错误之前,衡量系统是否找到了有用的证据。
1、为什么PubMed比普通文档搜索更难
PubMed是美国国家医学图书馆的生物医学研究引文和摘要的公共目录。它是构建文献工具的开发者、研究人员和临床医生在需要定位已发表证据时的起点。
困难在于生物医学语言混合了几种信息:
- 疾病,例如黑色素瘤,这是一种皮肤癌。
- 基因,例如BRAF或EGFR。基因是命名的DNA片段,可以影响癌症的生长和对治疗的反应。
- 突变,例如BRAF V600E,这是基因中的精确变化。在这个例子中,蛋白质在600位有一个不同的构建块。
- 临床试验ID,通常以NCT开头。它是已注册研究的标识符,不是自由格式的短语。
如果有人搜索"带有BRAF的胶质瘤治疗证据",一个有用的系统应该理解这个句子。如果他们搜索KIT L576P,它不应该将该精确突变稀释为对癌症治疗的通用搜索。
这种紧张关系就是为什么我不想孤立地测试一种检索方法。
2、三种检索方法
检索器就是搜索系统中接受查询并返回排序文档的部分。这个项目有两个不同的检索器。
2.1 稠密搜索:擅长含义
稠密搜索将文本转换为称为向量或嵌入的数字列表。其思想是具有相关含义的文本最终应在这个数字空间中彼此接近。我使用sentence-transformers/all-MiniLM-L6-v2,一个轻量级通用文本模型来生成稠密向量。
要比较两个稠密向量,系统使用余弦相似度。尽管名字如此,它只是比较两个向量方向的一种方式。更高的分数意味着模型认为这些文本在含义上更相关。
这在措辞变化时很有帮助。例如,用描述性语言拼写突变的查询仍然可以连接到使用较短医学术语的论文。
稠密搜索有一个弱点:短标识符对向量的贡献很小。KIT L576P只有几个字符,但它对搜索的人来说承载着很多含义。
2.2 稀疏BM25搜索:擅长确切措辞
稀疏搜索跟踪术语,而不是将整个句子表示为一个稠密向量。我使用BM25,这是文本搜索中长期使用的排名方法。当文档包含重要查询术语时,它给予更多积分,同时避免过度奖励常见词。
BM25在这里很有用,因为它保持确切符号可见。包含BRAF V600E或NCT01234567的论文在查询使用相同符号时更容易出现。
它的弱点与稠密搜索相反。它不知道"恶性黑色素细胞肿瘤"和"黑色素瘤"指的是同一种疾病。
2.3 混合搜索:让两种方法投票
混合方法分别运行稠密和稀疏搜索,然后使用倒数排名融合(RRF)组合它们的列表。RRF是一个简单的规则:当一篇论文出现在任一列表的顶部时获得更多的积分,当两种方法都对其高度排名时获得更多的积分。
这很重要,因为稠密和BM25分数是不同类型的数字。将余弦分数直接加到BM25分数上会具有误导性。RRF通过组合排序列表中的位置而不是原始分数来避免这个问题。
3、为什么我为实验选择Qdrant
我希望实验专注于检索,而不是从头构建向量化、稀疏索引、融合和过滤基础设施。这就是Qdrant很适合的地方。
Qdrant是一个向量数据库。在这个项目中,每篇论文作为Qdrant点存储一次,具有两个命名表示:用于语义搜索的稠密向量和用于精确术语搜索的稀疏BM25向量。同一个Python客户端可以查询任一表示,或要求Qdrant使用RRF融合两个排序列表。
我还需要在搜索期间运行过滤器。例如,开发者可能想要关于药物或基因的论文,但仅限于2020年之后。Qdrant将这些字段存储为负载,这是附加在论文上的可搜索元数据。这让查询可以说"找到与此请求相似的论文,但仅限于2020年或之后且基因为EGFR的论文。"EGFR是参与细胞生长的基因,通常在癌症研究中用作治疗相关标记。
简而言之,Qdrant让我在一个小型Python管道中保持一个语料库、两种检索方法、元数据过滤器和融合。对于比较实验来说,这使得移动部件更容易推理和重现。
第一个实现选择是Qdrant运行在哪里。我将该决策放在一个函数后面,以便相同的检索代码可以在本地存储中重现,或在配置了QDRANT_URL时使用Qdrant服务器。
def get_client() -> QdrantClient:
url = os.getenv("QDRANT_URL")
if url:
return QdrantClient(
url=url,
api_key=os.getenv("QDRANT_API_KEY"),
local_inference_batch_size=16,
)
QDRANT_PATH.parent.mkdir(parents=True, exist_ok=True)
return QdrantClient(
path=str(QDRANT_PATH),
local_inference_batch_size=16,
)
读者因此可以在不配置服务的情况下运行实验。将同一集合移动到服务器会更改客户端配置,而不是索引或检索逻辑。
client.create_collection(
collection_name="trec_pm_2018_pool",
vectors_config={
"dense": models.VectorParams(size=dense_size, distance=models.Distance.COSINE)
},
sparse_vectors_config={
"sparse": models.SparseVectorParams(modifier=models.Modifier.IDF)
},
)
稠密向量使用余弦相似度。稀疏向量使用BM25风格的术语加权,带有逆文档频率,这给罕见术语比常见术语更大的影响力。

4、数据集:真实相关性标签,受控查询措辞
对于基准测试,我使用了TREC 2018精确医学科学摘要任务。TREC是一个长期运行的信息检索评估计划。在这个任务中,精确肿瘤学家创建了50个合成患者病例,经过医学信息学培训的医师评估了哪些研究记录与每个病例相关。
源文件是公开的:
- TREC 2018主题:50个源患者病例。
- TREC 2018摘要相关性判断:说明哪些文档与哪些病例相关的标签。
- PubMed:项目获取的标题和摘要记录的公共来源。
下载的语料库未提交到Git。仓库通过NCBI API下载TREC判断文件中找到的数字PubMed ID,并在本地构建语料库。这使得数据路径清晰,无需重新分发大型PubMed副本。
原始判断包含14,946个数字PubMed ID。当我获取当前的标题和摘要记录时,有12,868个可用记录。原始TREC任务还包括来自AACR和ASCO的会议摘要。这些不是PubMed记录,所以我排除了它们,而不是混合数据源。
然后我为每个源查询创建了七个确定性版本。例如,同一个病例可以表示为自然语言问题、确切的医学短语、带有同义词的查询或包含确切突变的查询。这些是受控重写,不是新的医生编写的病例。它们重用原始病例的相关性标签,这样我可以问一个狭隘的问题:当措辞改变但所需信息保持不变时,什么会改变?
最终评估使用了来自30个源病例的210个保留查询。来自一个源病例的所有七个重写都在同一划分中,因此测试用例的近似重复版本不会出现在训练或验证中。
准备代码反映上述数据流。它下载官方TREC文件,解析主题和相关性判断,创建受控查询形式,并仅为NCBI获取提取数字PubMed ID。
download_trec_pm_sources()
topics = parse_topics()
source_qrels = parse_source_qrels()
queries = make_all_controlled_queries(topics)
pubmed_ids = ordered_pubmed_ids(source_qrels)
records, _ = _fetch_batches(
pubmed_ids,
batch_size=400,
request_delay=0.4,
)
下载器在解析之前缓存每个XML批处理,因此中断的运行可以重用已完成的下载。生成的清单记录了基准测试使用的源校验和、语料库计数和代码版本。
5、构建索引
每篇论文成为一个Qdrant点。其标题和摘要连接成可搜索文本。负载保留可读字段,如出版年份、疾病、基因、药物和试验ID。
我使用Qdrant客户端的FastEmbed集成,而不是手动编写嵌入代码。传递models.Document对象告诉客户端哪个模型应该为每个命名向量编码文本。
point = models.PointStruct(
id=paper_id,
vector={
"dense": models.Document(text=search_text, model=DENSE_MODEL),
"sparse": models.Document(text=search_text, model=SPARSE_MODEL),
},
payload=paper_metadata,
)
基准测试不会在一个请求中发送所有12,868篇论文。它将准备好的点分组为32个一批,并等待每个更新完成。
points = [make_point(record) for record in records]
for batch in batches(points, size=32):
client.upsert(
collection_name=collection_name,
points=batch,
wait=True,
)
批处理使每个嵌入和上传步骤有界,而完成文档计数器使长时间索引运行更容易监控。
这就是整个索引思想:同一篇论文有两种被发现的方式,外加用于过滤器的元数据。
在融合排名之前,基准测试分别运行稠密和稀疏检索。两个分支使用相同的集合、结果限制、负载处理和可选过滤器。只有模型和命名向量改变。
if mode == "dense":
response = client.query_points(
collection_name=collection_name,
query=models.Document(text=query_text, model=DENSE_MODEL),
using="dense",
query_filter=query_filter,
limit=limit,
with_payload=True,
)
elif mode == "sparse":
response = client.query_points(
collection_name=collection_name,
query=models.Document(text=query_text, model=SPARSE_MODEL),
using="sparse",
query_filter=query_filter,
limit=limit,
with_payload=True,
)
保持周围的查询路径相同对基准测试很重要:比较改变检索表示,而不是改变不相关的应用程序行为。
对于混合查询,我要求每个检索器提供比计划向用户显示的更多候选项。例如,如果最终UI需要5篇论文,从稠密搜索中拉取20个候选和从BM25中拉取20个候选会给RRF一个更广泛的组合集。在两个列表中排名第六的论文在融合后仍然可能变得有用。只要求每个检索器提供最终的5个会过早地丢弃那篇论文。
response = client.query_points(
collection_name=collection_name,
prefetch=[
models.Prefetch(
query=models.Document(text=query_text, model=DENSE_MODEL),
using="dense",
limit=20,
),
models.Prefetch(
query=models.Document(text=query_text, model=SPARSE_MODEL),
using="sparse",
limit=20,
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=5,
with_payload=True,
)
6、我测量了什么
主要指标是Recall@20。它问:在所有被判断为与查询相关的论文中,系统在其前20个结果中放置了多少?我使用20是因为检索层通常将小型证据集交给下一阶段,如重排器或答案撰写模型。如果相关论文在这些20个结果中缺失,后续步骤无法使用它。
实现从两个集合计算每个查询的Recall@20:每个被判断为与该查询相关的文档和前20个位置返回的文档ID。
def query_recall(ranked, judgments, depth=20):
relevant = {
document_id
for document_id, score in judgments.items()
if score > 0
}
retrieved = {
document_id
for document_id, _ in ranked[:depth]
}
if not relevant:
return 0.0
return len(relevant & retrieved) / len(relevant)
这些每个查询的值被平均用于报告的Recall@20。单独的完全未命中计数记录了交集为空的更严重情况。
我还计算了完全未命中:前20个没有被判断为相关论文的查询。这比单一平均值更容易解释。如果一个系统获得高平均值但对许多真实查询完全失败,那很重要。
基准测试还在仓库中记录了其他排名指标和延迟测量。热查询延迟是模型和索引已加载到内存后的时间。它对于比较重复的本地查询很有用,但不是生产延迟声明。

7、结果
当查询混合含义和符号时,混合搜索帮助最大。
这是保留的Recall@20结果。越高越好。

头条不是"混合搜索赢得一切"。它没有。当查询用自然语言表达或使用同义词时,稠密搜索最强。当确切医学术语承载意图时,BM25特别有价值。混合搜索总体上最好,在确切术语、类突变实体和混合查询上最强。
实际结果是未命中计数。稠密搜索有13个保留查询在前20个中没有相关论文。稀疏搜索有14个。混合搜索有5个。
一个例子使权衡具体化。当系统收到一个将KIT L576P扩展为文字的黑色素瘤问题的描述性版本时,稠密搜索在排名一中找到了相关论文。BM25在其前20个中没有找到相关证据,因为确切符号已从查询中消失。
确切突变形式发生了相反的情况。BM25将高度相关的KIT(L576P)论文放在第一位,而稠密搜索在其前20个中没有返回相关论文。没有一种方法是普遍更好的。它们以不同的方式失败。
对于"带有BRAF的胶质瘤有哪些治疗证据可用?",稠密和稀疏搜索在前20个中各找到4篇相关论文,但不是相同的4篇。混合搜索找到6篇。这就是融合的有用案例:当两种方法带回不同的相关材料时,它扩展了证据集。

8、过滤是单独的有用功能
搜索含义和搜索约束是不同的工作。如果用户想要2020年之后关于EGFR相关治疗的论文,"2020年之后"不应该是嵌入模型必须从句子中猜测的东西。它是元数据字段上的硬约束。
该项目在Qdrant的负载中存储结构化字段,并在查询时应用它们。
query_filter = models.Filter(
must=[
models.FieldCondition(
key="genes", match=models.MatchValue(value="EGFR")
),
models.FieldCondition(
key="publication_year", range=models.Range(gte=2020)
),
]
)
创建过滤器只是一半操作。将相同的对象传递到混合搜索中,这样Qdrant在检索和融合候选项时应用硬约束。
points = search(
client,
"treatment evidence for EGFR resistance",
mode="hybrid",
limit=20,
query_filter=query_filter,
collection_name="trec_pm_2018_pool",
)
因此,返回的点在到达任何后续重排或答案生成阶段之前满足两个负载条件。
must意味着两个条件必须匹配。代码逐一检查过滤器,一起检查,使用应该返回空值的不可能值,以及精确大小写匹配。重点不是过滤器在医学上智能。重点是一旦你有了可靠的元数据,你就可以将硬约束排除在模糊语义匹配之外。
9、这个实验没有证明什么
这是在重建的、已判断的PubMed池上对三种检索方法的公平比较。它不是完整的PubMed索引、临床推荐系统,也不是证明MiniLM是肿瘤学文献最佳模型的证据。
有三个重要限制:
- 当前的PubMed记录是在原始2018任务之后获取的。与原始集合相比,某些记录可能缺失或已更新。
- 七种查询形式是受控重写。原始病例和标签经过专家审查,但重写没有经过独立审查的医学问题。
- 稠密模型是轻量级基线。生物医学模型、重排器或更大的语料库可能会改变结果。
这些限制就是为什么我会将此项目作为文献检索管道的起点,而不是临床决策的最终产品。
10、我接下来会构建什么
下一个有用的实验不是立即添加LLM。首先,我会保持这个基准固定,一次交换一个检索组件:生物医学嵌入模型、重排检索论文的重排器,或更大的干扰语料库来测试规模。
只有在分别测量检索和重排之后,我才会在上面放置答案生成层。这种区别很重要:流畅的答案不是搜索系统找到正确论文的证据。
我的主要要点很简单。在生物医学搜索中,用户可以在同一句话中要求含义、确切符号和硬过滤器。稠密搜索和BM25各覆盖不同的故障模式。Qdrant使得针对同一语料库测试两种表示并组合它们变得实用,而无需从头构建搜索基础设施。
原文链接:I Combined Dense and Sparse Vectors to Search Medical Research
汇智网翻译整理,转载请标明出处