LLM Zoomcamp 2024 向量搜索模块实战:Sentence-BERT + Elasticsearch 语义检索与 Hit Rate / MRR 排序评估
【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp
本文基于 LLM Zoomcamp 2024 届第 3 周「Vector Search」模块(cohorts/2024/03-vector-search/README.md)展开,覆盖该模块的完整技术脉络:向量搜索的基本原理、基于 Elasticsearch 的语义检索(含 dense_vector 映射、kNN 查询与混合检索)、以及基于 ground truth 数据用 Hit Rate 和 MRR 指标评估检索质量。读完后,你可以独立搭建一条「文档 → 嵌入 → 索引 → 向量检索 → 排序评估」的完整检索链路,并能用数据量化比较不同嵌入策略(question / text / question+text)对检索效果的影响。
1. 模块结构与核心概念
模块 README 将内容组织为三大部分:
- 3.1 Introduction to Vector Search:向量数据库与向量搜索的入门概念;
- 3.2 Semantic Search with Elasticsearch:用 Elasticsearch 实现语义搜索,配套 Notebook 为 demo_es.ipynb,后续还包含「Advanced Semantic Search」(混合搜索)小节;
- 3.3 Evaluating Retrieval:检索效果评估,包含为什么需要评估、评估指标、ground truth(金标准)数据、用 LLM 生成 ground truth、以及评估文本搜索与向量搜索的排序质量。
Elasticsearch 中两个最重要的概念是document(文档)和index(索引):文档是字段与取值的集合;索引是以高度优化的格式存储文档集合、用于高效检索的结构。模块中的所有实战都围绕「准备文档 → 生成嵌入 → 建索引 → 执行查询 → 评估结果」这条主线进行。
2. 用 Elasticsearch 实现语义搜索(对应 3.2 节,demo_es.ipynb)
demo_es.ipynb 给出了完整的七步流程,下面逐步拆解并补充关键参数说明。
2.1 Step 1:准备文档
从一个按课程组织的 JSON 文件(外层按 course 分组,内层为 FAQ 文档列表)读取文档,并把外层的course字段平铺到每个文档上:
import json with open('documents.json', 'rt') as f_in: docs_raw = json.load(f_in) documents = [] for course_dict in docs_raw: for doc in course_dict['documents']: doc['course'] = course_dict['course'] documents.append(doc)处理后的每个文档包含text(答案正文)、section(章节)、question(问题)、course(所属课程)四个字段,例如:
{'text': 'GitHub - DataTalksClub>from sentence_transformers import SentenceTransformer model = SentenceTransformer("all-mpnet-base-v2") len(model.encode("This is a simple sentence")) # 768该模型产出的嵌入是 768 维的稠密向量。随后对每个文档的text字段编码,把向量挂到文档的text_vector字段:
operations = [] for doc in documents: doc["text_vector"] = model.encode(doc["text"]).tolist() operations.append(doc)2.3 Step 3:建立 Elasticsearch 连接
from elasticsearch import Elasticsearch es_client = Elasticsearch('http://localhost:9200') es_client.info()Notebook 运行环境为 Elasticsearch 8.4.3(Docker 部署,本地 9200 端口)。
2.4 Step 4:定义 Mapping 并创建索引
Mapping 定义了文档及其字段如何存储和索引,作用类似数据库 schema。语义搜索的关键在于text_vector字段声明为dense_vector类型:
index_settings = { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "text": {"type": "text"}, "section": {"type": "text"}, "question": {"type": "text"}, "course": {"type": "keyword"}, "text_vector": {"type": "dense_vector", "dims": 768, "index": True, "similarity": "cosine"}, } } } index_name = "course-questions" es_client.indices.delete(index=index_name, ignore_unavailable=True) es_client.indices.create(index=index_name, body=index_settings)参数说明(从 Notebook 实际配置看):
dims: 768:向量维度必须与嵌入模型输出维度一致,all-mpnet-base-v2是 768;若换用 384 维模型必须同步改为 384(3.3 节与作业中正是这么做的);index: True:为该向量字段建立 HNSW 近似最近邻索引,使其可参与 kNN 检索;similarity: "cosine":相似度函数用余弦,与 sentence-transformers 模型(输出已归一化向量)的度量方式一致;course用keyword类型而非text,因为只对它做精确过滤,不参与分词匹配。
2.5 Step 5–6:写入文档并执行向量查询
# Step 5: 逐条写入 for doc in operations: es_client.index(index=index_name, document=doc) # Step 6: 用户查询 search_term = "windows or mac?" vector_search_term = model.encode(search_term) query = { "field": "text_vector", "query_vector": vector_search_term, "k": 5, "num_candidates": 10000, } res = es_client.search(index=index_name, knn=query, source=["text", "section", "question", "course"]) res["hits"]["hits"]kNN 查询参数说明:
field:参与向量检索的dense_vector字段名;query_vector:查询文本编码后的向量;k: 5:最终返回的最近邻数量;num_candidates: 10000:近似最近邻(HNSW)在每个分片上考察的候选数量,越大越接近精确结果、代价越高。在本模块量级(千级文档)下,10000 实际上等同于全量精确比较。
实际运行结果中,排名第一的是「Is the course Windows/mac/Linux friendly?」(_score0.7147919),随后是 WSL 安装说明、作业答案不匹配的处理方式等文档——说明向量检索确实捕捉到了与「windows or mac?」语义相关的文档,而非仅仅词面匹配。
2.6 Step 7:混合搜索(关键词 + 向量)
「Advanced Semantic Search」小节演示了在同一请求中组合query(关键词/过滤条件)与knn(向量检索):
knn_query = { "field": "text_vector", "query_vector": vector_search_term, "k": 5, "num_candidates": 10000 } response = es_client.search( index=index_name, query={ "match": {"section": "General course-related questions"}, }, knn=knn_query, size=5 )match条件先把范围限定在「General course-related questions」章节内,再叠加 kNN 语义排序。这种「结构化过滤 + 语义排序」的混合模式正是后续评估小节中给向量检索加term过滤(按 course 限定)的同一思路。
3. 检索评估:ground truth 与 Hit Rate / MRR(对应 3.3 节)
3.3 节回答的核心问题是:检索质量变好还是变差,不能靠看几条结果的主观感受,而要基于 ground truth 数据集计算指标。模块给出的评估计划为:为什么需要评估 → 评估指标 → 准备 ground truth / 金标准数据 → 用 LLM 生成 ground truth → 评估搜索结果。
说明:README 中 3.3 节引用的部分数据文件(如
eval/documents-with-ids.json、eval/ground-truth-data.csv、eval/results.bin)在当前仓库快照中已不存在,eval/目录下现存的是 evaluate-vector.ipynb,本节的代码与结论均以该 Notebook 的实际内容为依据。
evaluate-vector.ipynb 使用 948 条带 id 的文档、4627 条 ground truth 记录(每条形如{'question': 'When does the course begin?', 'course': 'data-engineering-zoomcamp', 'document': 'c02e79ef'},即「查询问题 + 所属课程 + 应命中文档 id」),嵌入模型换成multi-qa-MiniLM-L6-cos-v1(384 维)。
3.1 三种嵌入策略的索引
关键实验设计:对同一文档同时建立三个向量字段,分别对应三种编码策略:
for doc in tqdm(documents): question = doc['question'] text = doc['text'] qt = question + ' ' + text doc['question_vector'] = model.encode(question) # 只编码问题 doc['text_vector'] = model.encode(text) # 只编码答案 doc['question_text_vector'] = model.encode(qt) # 问题+答案拼接对应的索引 mapping 中三个字段均为dense_vector、dims: 384、similarity: "cosine",另有text/section/question为text类型、course与id为keyword类型。
3.2 带课程过滤的 kNN 检索函数
def elastic_search_knn(field, vector, course): knn = { "field": field, "query_vector": vector, "k": 5, "num_candidates": 10000, "filter": { "term": { "course": course } } } search_query = { "knn": knn, "_source": ["text", "section", "question", "course", "id"] } es_results = es_client.search(index=index_name, body=search_query) result_docs = [] for hit in es_results['hits']['hits']: result_docs.append(hit['_source']) return result_docs这里filter用term查询把候选限定在 ground truth 指定课程内——因为「当同一问题在不同课程都有相似文档时,只应在本课程范围内找答案」。
3.3 Hit Rate 与 MRR 的实现
两个指标均以「每个查询对应的 top-5 结果中,各位置是否命中目标文档 id」构成的布尔矩阵relevance_total为输入:
def hit_rate(relevance_total): cnt = 0 for line in relevance_total: if True in line: # top-5 中至少命中一次 cnt = cnt + 1 return cnt / len(relevance_total) def mrr(relevance_total): total_score = 0.0 for line in relevance_total: for rank in range(len(line)): if line[rank] == True: total_score = total_score + 1 / (rank + 1) return total_score / len(relevance_total) def evaluate(ground_truth, search_function): relevance_total = [] for q in tqdm(ground_truth): doc_id = q['document'] results = search_function(q) relevance = [d['id'] == doc_id for d in results] relevance_total.append(relevance) return { 'hit_rate': hit_rate(relevance_total), 'mrr': mrr(relevance_total), }- Hit Rate @5:查询中「top-5 至少命中一次正确文档」的占比,衡量召回能力;
- MRR(Mean Reciprocal Rank):取每个查询第一个命中位置秩倒数
1/rank的均值,衡量命中的「排名靠前程度」——命中位置越靠前得分越高。
这两个指标的更广泛定义(P@k、Recall、NDCG、AUC-ROC 等)可参考仓库中 2025 届课程整理在 evaluation-metrics.md 中的指标清单。
3.4 不同嵌入策略的实测结果
Notebook 实际运行得到(4627 条 ground truth,top-5,Elasticsearch kNN):
| 检索字段 | Hit Rate | MRR |
|---|---|---|
| 仅 ES 文本匹配(对照,Notebook 标注值) | 0.7396 | 0.6032 |
question_vector(只编码问题) | 0.7731 | 0.6667 |
text_vector(只编码答案) | 0.8286 | 0.7062 |
question_text_vector(问题+答案) | 0.9172 | 0.8243 |
script_score三向量余弦相似度求和 | 0.9023 | 0.8045 |
结论可以读出两点:其一,向量检索(最佳 0.9172)全面优于纯文本匹配(0.7396);其二,把 question 与 answer 拼接后编码效果最好——因为用户查询在措辞上更接近「问题」风格,而答案文本携带了完整上下文,两者互补。最后一个组合方案用script_score对三个向量字段计算余弦相似度求和再加 1(cosineSimilarity输出范围约为 [-1, 1],加 1 归一到非负区间):
"script": { "source": """ cosineSimilarity(params.query_vector, 'question_vector') + cosineSimilarity(params.query_vector, 'text_vector') + cosineSimilarity(params.query_vector, 'question_text_vector') + 1 """, "params": {"query_vector": vector} }从源码结构看,该组合方案(0.9023)并没有超过单一最优字段(0.9172),说明简单的等权线性求和并非最优融合方式——这正是「评估驱动的调参」的典型价值:不评估就不知道该优化哪个字段、哪种融合。
4. 作业实战:不用 Elasticsearch 的精确向量搜索与对比(homework)
作业的主题是「有 Elasticsearch 与没有 Elasticsearch 两种情况下做向量搜索」,官方解答完整演示了这条链路。
4.1 编码与精确相似度计算
from sentence_transformers import SentenceTransformer embedding_model = SentenceTransformer('multi-qa-distilbert-cos-v1') # 768 维 user_question = "I just discovered the course. Can I still join it?" v = embedding_model.encode(user_question) v[0] # 0.07822262 → 对应选项 0.07随后加载带 id 的文档并过滤出machine-learning-zoomcamp课程(375 条),对每条文档把 question 与 text 拼成qa_text后编码:
embeddings = [] for d in documents: qa_text = f'{d["question"]} {d["text"]}' embeddings.append(embedding_model.encode(qa_text)) X = np.array(embeddings) X.shape # (375, 768)由于 sentence-transformers 的 cos 系列模型输出已归一化向量(向量自点积接近 1.0),余弦相似度退化为矩阵乘:
scores = X.dot(v) scores.max() # 0.6506575 → 对应选项 0.65 scores.argmax() # 14,即命中文档 id 'ee58a693': # "The course has already started. Can I still join it?"4.2 自实现 VectorSearchEngine
class VectorSearchEngine(): def __init__(self, documents, embeddings): self.documents = documents self.embeddings = embeddings def search(self, v_query, num_results=10): scores = self.embeddings.dot(v_query) idx = np.argsort(-scores)[:num_results] return [self.documents[i] for i in idx] search_engine = VectorSearchEngine(documents=documents, embeddings=X) search_engine.search(v, num_results=5)np.argsort(-scores)对负分排序等价于按分数降序取 top-N(作业中同时提示:可用argpartition替换argsort显著提速,因为只需 top-k 而非全量排序)。
用与 3.3 节相同的evaluate函数评估(1830 条 machine-learning-zoomcamp 的 ground truth,num_results=5):
def numpy_cosine_search(q): v_q = embedding_model.encode(q['question']) return search_engine.search(v_q, num_results=5) evaluate(ground_truth, numpy_cosine_search) # {'hit_rate': 0.9398907103825137, 'mrr': 0.8516484517304189}即 Hit Rate 约0.94(对应选项 0.93)。
4.3 对比 Elasticsearch 的近似检索
作业要求把同样算好的嵌入写进 Elasticsearch 再评估,以量化「精确搜索 → 近似搜索」的精度损失。注意维度要与模型匹配(768):
index_settings = { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "text": {"type": "text"}, "section": {"type": "text"}, "question": {"type": "text"}, "course": {"type": "keyword"}, "id": {"type": "keyword"}, "question_text_vector": { "type": "dense_vector", "dims": 768, "index": True, "similarity": "cosine" }, } } } index_name = "course-questions-homework" es_client.indices.delete(index=index_name, ignore_unavailable=True) es_client.indices.create(index=index_name, body=index_settings) for d, emb in zip(documents, embeddings): d['question_text_vector'] = emb es_client.index(index=index_name, document=d)检索函数与 3.2 节的elastic_search_knn相同(k: 5、num_candidates: 10000、term过滤 course)。评估结果:
evaluate(ground_truth, question_vector_knn) # {'hit_rate': 0.9398907103825137, 'mrr': 0.8516484517304189}与 numpy 精确搜索完全一致(Hit Rate 0.9399、MRR 0.8516,对应选项 0.93)。这印证了作业的设计意图:当数据量只有 375 条时,num_candidates=10000的 HNSW 近似检索覆盖了全部向量,近似与精确结果无异;作业说明中也指出,数据量大时逐向量计算相似度「通常不现实,Elasticsearch 用近似技术换取速度」——本实验等于在小子集上验证了「近似无损」的边界条件。
5. 小结与可迁移的经验
回到 模块 README 的三大主题,本模块给出的可复用经验可以归纳为:
- 索引设计:
dense_vector字段的dims必须与嵌入模型输出维度严格一致,similarity: "cosine"配合归一化向量模型是 FAQ 检索的标准配置(demo_es.ipynb); - 嵌入策略要用数据说话:对同一文档的 question、text、question+text 分别建向量字段,再用统一 ground truth 评估,实测 question+text 拼接显著优于单字段(0.9172 vs 0.8286 Hit Rate,eval/evaluate-vector.ipynb);
- 评估闭环:
hit_rate+mrr两个函数约 20 行即可构成可复用的评估器,任何检索改动(换模型、换字段、换融合方式)都能立刻量化; - 精确 vs 近似:numpy 矩阵乘实现的精确搜索与 Elasticsearch HNSW 近似搜索在小数据集上结果一致,数据规模增大后应切换到索引化近似检索(homework.md、homework_solution.ipynb)。
运行前提与限制:Elasticsearch 部分需要本地 9200 端口有可用实例(Notebook 使用 8.4.3 Docker 镜像);嵌入模型需通过sentence_transformers从 Hugging Face 下载;3.3 节引用的部分评估数据文件在当前仓库快照中缺失,完整复现该小节时需要参考当年 cohort 的原始资料或自行按 evaluation-metrics.md 描述的指标构造 ground truth 数据。
【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考