网易首页 > 网易号 > 正文 申请入驻

向量搜索升级指南:FAISS 到 Qdrant 迁移方案与代码实现

0
分享至

FAISS 在实验阶段确实好用,速度快、上手容易,notebook 里跑起来很顺手。但把它搬到生产环境还是有很多问题:

首先是元数据的问题,FAISS 索引只认向量,如果想按日期或其他条件筛选还需要自己另外搞一套查找系统。

其次它本质上是个库而不是服务,让如果想对外提供接口还得自己用 Flask 或 FastAPI 包一层。

最后最麻烦的是持久化,pod 一旦挂掉索引就没了,除非提前手动存盘。

Qdrant 的出现解决了这些痛点,它更像是个真正的数据库,提供开箱即用的 API、数据重启后依然在、原生支持元数据过滤。更关键的是混合搜索(Dense + Sparse)和量化这些高级功能都是内置的。

MS MARCO Passages 数据集

这次用的是 MS MARCO Passage Ranking 数据集,信息检索领域的标准测试集。

数据是从网页抓取的约880万条短文本段落,选它的原因很简单:段落短(平均50词),不用处理复杂的文本分块,可以把精力放在迁移工程本身。

实际测试时用了10万条数据的子集,这样速度会很快

嵌入模型用的是 sentence-transformers/all-MiniLM-L6-v2,输出384维的稠密向量。

FAISS 阶段的初始配置

生成嵌入向量

加载原始数据,批量生成嵌入向量。这里关键的一步是把结果存成 .npy 文件,避免后续重复计算。



import pandas as pd
from sentence_transformers import SentenceTransformer
import numpy as np
import os
import csv
DATA_PATH = '../data'
TSV_FILE = f'{DATA_PATH}/collection.tsv'
SAMPLE_SIZE = 100000
MODEL_ID = 'all-MiniLM-L6-v2'
def prepare_data():
print(f"Loading Model '{MODEL_ID}'...")
model = SentenceTransformer(MODEL_ID)
print(f"Reading first {SAMPLE_SIZE} lines from {TSV_FILE}...")
ids = []
passages = []
# Efficiently read line-by-line without loading entire 8GB file to RAM
try:
with open(TSV_FILE, 'r', encoding='utf8') as f:
reader = csv.reader(f, delimiter='\t')
for i, row in enumerate(reader):
if i >= SAMPLE_SIZE:
break
# MS MARCO format is: [pid, text]
if len(row) >= 2:
ids.append(int(row[0]))
passages.append(row[1])
except FileNotFoundError:
print(f"Error: Could not find {TSV_FILE}")
return
print(f"Loaded {len(passages)} passages.")
# Save text metadata (for Qdrant payload)
print("Saving metadata to CSV...")
df = pd.DataFrame({'id': ids, 'text': passages})
df.to_csv(f'{DATA_PATH}/passages.csv', index=False)
# Generate Embeddings
print("Encoding Embeddings (this may take a moment)...")
embeddings = model.encode(passages, show_progress_bar=True)
# Save binary files (for FAISS and Qdrant)
print("5. Saving numpy arrays...")
np.save(f'{DATA_PATH}/embeddings.npy', embeddings)
np.save(f'{DATA_PATH}/ids.npy', np.array(ids))
print(f"Success! Saved {embeddings.shape} embeddings to {DATA_PATH}")
if __name__ == "__main__":
os.makedirs(DATA_PATH, exist_ok=True)
prepare_data()

构建索引

用 IndexFlatL2 做精确搜索,对于百万级别的数据量来说足够了。

import faiss
import numpy as np
import os
DATA_PATH = '../data'
INDEX_OUTPUT_PATH = './my_index.faiss'
def build_index():
print("Loading embeddings...")
# Load the vectors
if not os.path.exists(f'{DATA_PATH}/embeddings.npy'):
print(f"Error: {DATA_PATH}/embeddings.npy not found.")
return
embeddings = np.load(f'{DATA_PATH}/embeddings.npy')
d = embeddings.shape[1] # Dimension (should be 384 for MiniLM)
print(f"Building Index (Dimension={d})...")
# We use IndexFlatL2 for exact search (Simple & Accurate for <1M vectors).
index = faiss.IndexFlatL2(d)
index.add(embeddings)
print(f"Saving index to {INDEX_OUTPUT_PATH}..")
faiss.write_index(index, INDEX_OUTPUT_PATH)
print(f"Success! Index contains {index.ntotal} vectors.")
if __name__ == "__main__":
os.makedirs(os.path.dirname(INDEX_OUTPUT_PATH), exist_ok=True)
build_index()

语义搜索测试

随便跑一个查询就能看出问题了。返回的是 [42, 105] 这种 ID,如果想拿到实际文本还得写一堆代码去 CSV 里查,这种割裂感是迁移的主要原因。

import faiss
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer
INDEX_PATH = './my_index.faiss'
DATA_PATH = '../data'
MODEL_NAME = 'all-MiniLM-L6-v2'
def search_faiss():
print("Loading Index and Metadata...")
index = faiss.read_index(INDEX_PATH)
# LIMITATION: We must manually load the CSV to get text back.
# FAISS only stores vectors, not the text itself.
df = pd.read_csv(f'{DATA_PATH}/passages.csv')
model = SentenceTransformer(MODEL_NAME)
# userquery
query_text = "What is the capital of France?"
print(f"\nQuery: '{query_text}'")
# Encode and Search
query_vector = model.encode([query_text])
D, I = index.search(query_vector, k=3) # Search for top 3 results
print("\n--- Results ---")
for rank, idx in enumerate(I[0]):
# LIMITATION: If we wanted to filter by "text_length > 50",
# we would have to fetch ALL results first, then filter in Python.
# FAISS cannot filter during search.
text = df.iloc[idx]['text'] # Manual lookup
score = D[0][rank]
print(f"[{rank+1}] ID: {idx} | Score: {score:.4f}")
print(f" Text: {text[:100]}...")
if __name__ == "__main__":
search_faiss()

迁移步骤

从 FAISS 导出向量

前面步骤已经有 embeddings.npy 了,直接加载 numpy 数组就行,省去了导出环节。

本地启动 Qdrant 很简单:

docker run -p 6333:6333 qdrant/qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, HnswConfigDiff
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "ms_marco_passages"
def create_collection():
client = QdrantClient(url=QDRANT_URL)
print(f"Creating collection '{COLLECTION_NAME}'...")
client.recreate_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=384,# Dimension (MiniLM)- we should follow the existing dimension from FAISS
distance=Distance.COSINE
),
hnsw_config=HnswConfigDiff(
m=16, # Links per node (default is 16)
ef_construct=100 # Search depth during build (default is 100)
)
)
print(f"Collection '{COLLECTION_NAME}' created with HNSW config.")
if __name__ == "__main__":
create_collection()

批量上传数据

import pandas as pd
import numpy as np
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "ms_marco_passages"
DATA_PATH = '../data'
BATCH_SIZE = 500
def upload_data():
client = QdrantClient(url=QDRANT_URL)
print("Loading local data...")
embeddings = np.load(f'{DATA_PATH}/embeddings.npy')
df_meta = pd.read_csv(f'{DATA_PATH}/passages.csv')
total = len(df_meta)
print(f"Starting upload of {total} vectors...")
points_batch = []
for i, row in df_meta.iterrows():
# Metadata to attach
payload = {
"passage_id": int(row['id']),
"text": row['text'],
"text_length": len(str(row['text'])),
"dataset_source": "msmarco_passages"
}
points_batch.append(PointStruct(
id=int(row['id']),
vector=embeddings[i].tolist(),
payload=payload
))
# Upload batch
if len(points_batch) >= BATCH_SIZE or i == total - 1:
client.upsert(
collection_name=COLLECTION_NAME,
points=points_batch
)
points_batch = []
if i % 1000 == 0:
print(f" Processed {i}/{total}...")
print("Upload Complete.")
if __name__ == "__main__":
upload_data()

验证迁移结果

from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, Range, MatchValue
from sentence_transformers import SentenceTransformer
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "ms_marco_passages"
MODEL_NAME = 'all-MiniLM-L6-v2'
def validate_migration():
client = QdrantClient(url=QDRANT_URL)
model = SentenceTransformer(MODEL_NAME)
# Verify total count
count_result = client.count(COLLECTION_NAME)
print(f"Total Vectors in Qdrant: {count_result.count}")
# Query example
query_text = "What is a GPU?"
print(f"\n--- Query: '{query_text}' ---")
query_vector = model.encode(query_text).tolist()
# Filter Definition
print("Applying filters (Length < 200 AND Source == msmarco)...")
search_filter = Filter(
must=[
FieldCondition(
key="text_length",
range=Range(lt=200) # can be changed as per the requirement
),
FieldCondition(
key="dataset_source",
match=MatchValue(value="msmarco_passages")
)
]
)
results = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
query_filter=search_filter,
limit=3
).points
for hit in results:
print(f"\nID: {hit.id} (Score: {hit.score:.3f})")
print(f"Text: {hit.payload['text']}")
print(f"Metadata: {hit.payload}")
if __name__ == "__main__":
validate_migration()

性能对比

针对10个常见查询做了对比测试。

FAISS(本地 CPU):约 0.5ms,纯数学计算的速度

Qdrant(Docker):约 3ms,包含了网络传输的开销

对 Web 服务来说3ms 的延迟完全可以接受,何况换来的是一堆新功能。

import time
import faiss
import numpy as np
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer
FAISS_INDEX_PATH = './faiss_index/my_index.faiss'
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "ms_marco_passages"
MODEL_NAME = 'all-MiniLM-L6-v2'
QUERIES = [
"What is a GPU?",
"Who is the president of France?",
"How to bake a cake?",
"Symptoms of the flu",
"Python programming language",
"Best places to visit in Italy",
"Define quantum mechanics",
"History of the Roman Empire",
"What is machine learning?",
"Healthy breakfast ideas"
]
def run_comparison():
print("---Loading Resources ---")
# Load Model
model = SentenceTransformer(MODEL_NAME)
# Load FAISS (The "Old Way")
print("Loading FAISS index...")
faiss_index = faiss.read_index(FAISS_INDEX_PATH)
# Connect to Qdrant (The "New Way")
print("Connecting to Qdrant...")
client = QdrantClient(url=QDRANT_URL)
print(f"\n---Running Race ({len(QUERIES)} queries) ---")
print(f"{'Query':<30} | {'FAISS (ms)':<10} | {'Qdrant (ms)':<10}")
print("-" * 60)
faiss_times = []
qdrant_times = []
for query_text in QUERIES:
# Encode once
query_vector = model.encode(query_text).tolist()
# --- MEASURE FAISS ---
start_f = time.perf_counter()
# FAISS expects a numpy array of shape (1, d)
faiss_input = np.array([query_vector], dtype='float32')
_, _ = faiss_index.search(faiss_input, k=3)
end_f = time.perf_counter()
faiss_ms = (end_f - start_f) * 1000
faiss_times.append(faiss_ms)
# --- MEASURE QDRANT ---
start_q = time.perf_counter()
_ = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
limit=3
)
end_q = time.perf_counter()
qdrant_ms = (end_q - start_q) * 1000
qdrant_times.append(qdrant_ms)
print(f"{query_text[:30]:<30} | {faiss_ms:>10.2f} | {qdrant_ms:>10.2f}")
print("-" * 60)
print(f"{'AVERAGE':<30} | {np.mean(faiss_times):>10.2f} | {np.mean(qdrant_times):>10.2f}")
if __name__ == "__main__":
run_comparison()

测试结果:



最大的差异不在速度,在于省心。

用 FAISS 时有次跑了个索引脚本处理大批数据,耗时40分钟,占了12GB内存。快完成时 SSH 连接突然断了,进程被杀,因为 FAISS 只是个跑在内存里的库一切都白费了。

换成 Qdrant 就不一样了:它像真正的数据库,数据推送后会持久化保存,即便突然断开 docker 连接重启后数据还在。

用过 FAISS 就知道为了把向量 ID 映射回文本,还需要额外维护一个 CSV 文件。迁移到 Qdrant 后这些查找逻辑都删掉了,文本和向量存在一起,直接查询 API 就能拿到完整结果,不再需要管理各种文件,就是在用一个微服务。



迁移总结

这次迁移断断续续做了一周但收获很大。最爽的不是写 Qdrant 脚本,是删掉旧代码——提交的 PR 几乎全是红色删除行。CSV 加载工具、手动 ID 映射、各种"代码"全删了,代码量减少了30%,可读性明显提升。

只用 FAISS 时,搜索有时像在碰运气——语义上相似但事实错误的结果时常出现。迁移到 Qdrant拿到的不只是数据库,更是对系统的掌控力。稠密向量配合关键词过滤(混合搜索),终于能回答"显示 GPU 相关的技术文档,但只要官方手册里的"这种精确查询,这在之前根本做不到。

信心的变化最明显,以前不敢加载完整的880万数据怕内存撑不住。现在架构解耦了可以把全部数据推给 Qdrant,它会在磁盘上处理存储和索引,应用层保持轻量。终于有了个在生产环境和 notebook 里都能跑得一样好的系统。



总结

FAISS 适合离线研究和快速实验,但要在生产环境跑起来Qdrant 提供了必需的基础设施。如果还在用额外的 CSV 文件来理解向量含义该考虑迁移了。

https://avoid.overfit.cn/post/ce7c45d8373741f6b8af465bb06bc398

作者:Sai Bhargav Rallapalli

特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。

Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.

相关推荐
热点推荐
陆航临近添丁,075两攻还嗷嗷待哺,德专家:白皮版直-21正在试飞

陆航临近添丁,075两攻还嗷嗷待哺,德专家:白皮版直-21正在试飞

啸鹰评
2025-12-29 23:16:30
研究发现:山楂可以在10小时内溶解50%的血栓,是真的吗?

研究发现:山楂可以在10小时内溶解50%的血栓,是真的吗?

健康科普365
2025-12-26 10:02:53
转会“临门一脚”即将达成:11号前锋告别曼联,又一笔失败引援

转会“临门一脚”即将达成:11号前锋告别曼联,又一笔失败引援

里芃芃体育
2025-12-30 00:10:08
干涉别人因果有啥严重后果?网友:那能给别人介绍对象吗?

干涉别人因果有啥严重后果?网友:那能给别人介绍对象吗?

解读热点事件
2025-12-21 00:05:08
我国将在南海建设最大的军事基地

我国将在南海建设最大的军事基地

孤城落叶
2025-12-29 01:01:15
李冰冰肠子都悔青了,最令她不齿的大概就是当年跟朱孝天的绯闻了

李冰冰肠子都悔青了,最令她不齿的大概就是当年跟朱孝天的绯闻了

凌风的世界观
2025-12-27 09:34:13
特朗普已做好开战准备?王毅警告:中美一旦冲突,结局只有一个!

特朗普已做好开战准备?王毅警告:中美一旦冲突,结局只有一个!

科普100克克
2025-12-28 17:34:23
徐湖平的胆子太大了!

徐湖平的胆子太大了!

仕道
2025-12-29 10:15:03
与赵少康握手言和!郑丽文:未来将择期正式拜会他

与赵少康握手言和!郑丽文:未来将择期正式拜会他

海峡导报社
2025-12-29 15:16:08
雷霆还会看走眼?刚签2.4亿,现在就溢价了,复出后球队更弱了!

雷霆还会看走眼?刚签2.4亿,现在就溢价了,复出后球队更弱了!

你的篮球频道
2025-12-29 07:57:54
现货白银持续拉升涨超5% 报83.16美元/盎司

现货白银持续拉升涨超5% 报83.16美元/盎司

财联社
2025-12-29 07:21:07
果不其然帮庞叔令发声的亚洲周刊还是出事了

果不其然帮庞叔令发声的亚洲周刊还是出事了

好贤观史记
2025-12-29 13:53:35
巴黎那帮毛贼费大劲搬空京东仓库,结果偷回去一堆“电子板砖”!

巴黎那帮毛贼费大劲搬空京东仓库,结果偷回去一堆“电子板砖”!

百态人间
2025-12-26 16:32:24
1971年,刘思齐入狱后向毛主席求救,毛泽东最高指示:娃娃们无罪

1971年,刘思齐入狱后向毛主席求救,毛泽东最高指示:娃娃们无罪

鹤羽说个事
2025-12-29 15:38:35
中国有可能迎来巨大机遇,美国对委内瑞拉出手,就是在给中国机会

中国有可能迎来巨大机遇,美国对委内瑞拉出手,就是在给中国机会

博览历史
2025-12-27 17:00:19
林心如上恋爱综艺节目带火“棕色系”穿搭,高扎马尾,49岁像29岁

林心如上恋爱综艺节目带火“棕色系”穿搭,高扎马尾,49岁像29岁

明星私服穿搭daily
2025-12-29 09:23:44
中国古代单日阵亡最高的战役:香积寺互砍,4个时辰11万人阵亡!

中国古代单日阵亡最高的战役:香积寺互砍,4个时辰11万人阵亡!

小豫讲故事
2025-12-29 06:00:03
北伐开始!快船冲5连胜:哈登威少分高下,小卡再爆发,国王很难

北伐开始!快船冲5连胜:哈登威少分高下,小卡再爆发,国王很难

一登侃球
2025-12-29 22:48:36
赵本山:我拯救了一个恶毒女人的演艺生涯,她却忘恩负义踩我上位

赵本山:我拯救了一个恶毒女人的演艺生涯,她却忘恩负义踩我上位

芳芳历史烩
2025-07-23 17:53:28
国家队后卫集体拉胯!投篮8中1 运球过不了半场 郭士强怎么选的人

国家队后卫集体拉胯!投篮8中1 运球过不了半场 郭士强怎么选的人

篮球专区
2025-12-29 22:57:31
2025-12-30 00:40:49
deephub incentive-icons
deephub
CV NLP和数据挖掘知识
1874文章数 1440关注度
往期回顾 全部

科技要闻

肉搏非洲,传音不想只当个卖手机的

头条要闻

媒体:解放军围台军演 台军演练"集体逃亡"画面滑稽

头条要闻

媒体:解放军围台军演 台军演练"集体逃亡"画面滑稽

体育要闻

“史上最贵”的世界杯,球迷成了韭菜

娱乐要闻

44岁林俊杰官宣恋情 带23岁女友见家长

财经要闻

翁杰明:宏观数据与居民微观感受存在差距

汽车要闻

“路”要越走越深,猛士的智能越野时代来了

态度原创

时尚
游戏
房产
旅游
军事航空

这一抹瑞红,在2025年终

《侍道》?Acquire称公司希望复活旗下老IP

房产要闻

中旅・三亚蓝湾展示中心璀璨绽放,共鉴湾心孤品传奇

旅游要闻

“双节”期间去哪儿玩?揭阳超千场文化活动等你来!

军事要闻

东部战区发布的AI视频 一个细节意味深长

无障碍浏览 进入关怀版