LLM Zoomcamp 2025 向量搜索作业详解:用 fastembed 与 Qdrant 从零实现嵌入、余弦排序与向量检索
【免费下载链接】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 2025 版第 2 周作业(Vector Search)为主体,完整讲解如何用 fastembed 生成文本嵌入、用点积计算余弦相似度、在本地实现基于余弦相似度的文档排序,并进一步把文档索引进 Qdrant 完成端到端的向量检索。读完后你将能够独立完成从"文本向量化"到"向量库检索"的全链路操作,并理解嵌入模型维度、归一化向量与距离度量选择之间的内在关系。
1. 作业背景与整体技术栈
该作业位于 2025 年课程的第 2 周模块(Vector Search)中,完整题目见 homework.md。它与模块主线保持一致:使用 Qdrant 作为向量数据库,使用 fastembed 作为本地嵌入库。官方解答 notebook 位于 homework_solution.ipynb,模块主 notebook 为 sematic_search.ipynb(文件名中的拼写沿用仓库原始命名)。
作业原文中特别提示:
It's possible that your answers won't match exactly. If it's the case, select the closest one.
也就是说,由于浮点精度、模型版本等差异,你的运行结果可能和标准答案有微小偏差,选最接近的选项即可。原文还建议:如果想深入了解向量搜索的底层原理(手动实现检索引擎、hit-rate 评估、Elasticsearch 近似检索等),可以对照 2024 年队列第 3 周的作业(Q1-Q4)。
与 2024 版作业相比,2025 版有一个明显的技术栈变化:2024 版使用sentence_transformers的SentenceTransformer(模型multi-qa-distilbert-cos-v1,768 维),而 2025 版改用轻量的fastembed(ONNX Runtime 推理,CPU 友好)。模块 README 中给出的安装与启动方式如下(见 README.md):
pip install -q "qdrant-client[fastembed]>=1.14.2"docker pull qdrant/qdrant docker run -p 6333:6333 -p 6334:6334 \ -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \ qdrant/qdrant其中 6333 是 REST API 端口,6334 是 gRPC 端口,-v挂载用于数据持久化。fastembed 版本要求>= 1.14.2是支持 Qdrant"本地推理"(local inference)的前提——即可以直接把文本传给 Qdrant 客户端,由 fastembed 在本机完成向量化。
安装 fastembed 库本身只需:
pip install fastembedfrom fastembed import TextEmbedding2. Q1:嵌入一条查询(Embedding the Query)
Q1 的要求是:使用jinaai/jina-embeddings-v2-small-en模型嵌入查询语句'I just discovered the course. Can I join now?',得到一个 512 维的 numpy 数组,然后回答"该数组的最小值是多少"。选项为:-0.51 / -0.11 / 0 / 0.51。
结合解答 notebook 的实际运行代码,标准做法如下:
from fastembed import TextEmbedding import numpy as np embedder = TextEmbedding(model_name='jinaai/jina-embeddings-v2-small-en') query = 'I just discovered the course. Can I join now?' q, = list(embedder.embed(query)) # embed 返回迭代器,解包出第一个向量几个值得注意的实现细节:
embedder.embed()返回的是一个可迭代的向量生成器,所以用q, = list(...)解包单条查询的向量;如果一次嵌入多条文本,list(embedder.embed([...]))会返回与输入等长的向量列表。解答 notebook 中 Q3/Q4 批量嵌入时正是利用这一特性,直接把 pandas 的 Series 传入:list(embedder.embed(df.text))。- 解答中
q.min()的实际输出为np.float64(-0.11726373885183883),因此正确选项是-0.11。
模型参数方面,jinaai/jina-embeddings-v2-small-en是英文单模态模型,512 维,约 0.12 GB,支持 8192 token 截断(这些元数据可通过TextEmbedding.list_supported_models()查到,模块 notebook sematic_search.ipynb 中即用此方法筛选了 512 维的候选模型)。
3. 余弦相似度:为什么可以直接用点积
作业文档在 Q1 之后专门插入了一个"Cosine similarity"小节,这是理解整个作业的关键前提:
The vectors that our embedding model returns are already normalized: their length is 1.0.
嵌入模型输出的向量已经是归一化的(范数为 1.0),因此两个向量之间的余弦相似度可以直接用点积计算,无需再除以模长。作业给出了验证方法:
import numpy as np np.linalg.norm(q) # 解答中输出 np.float64(1.0)q.dot(q) # 向量与自身的余弦相似度 = 1.0解答 notebook 中q.dot(q)的实际输出是np.float64(1.0000000000000002)——浮点误差使其略大于 1,这属于正常现象。
Q2:与另一个向量的余弦相似度。嵌入文档:
doc = 'Can I still join the course after the start date?' d, = list(embedder.embed(doc))计算q.dot(d),选项为 0.3 / 0.5 / 0.7 / 0.9。解答中实际输出为np.float64(0.9008528895674547),因此选0.9。这正体现了向量检索的价值:查询与文档几乎没有共同的关键词("just discovered" vs "still join after the start date"),但语义高度一致,余弦相似度接近 1.0。
4. Q3 与 Q4:基于余弦相似度的文档排序
Q3/Q4 使用同一组 5 条 FAQ 文档(均为data-engineering-zoomcamp的课程相关问答,字段结构为text/section/question/course)。完整数据直接摘自作业原文(也出现在 homework_solution.ipynb 中):
documents = [{'text': "Yes, even if you don't register, you're still eligible to submit the homeworks.\nBe aware, however, that there will be deadlines for turning in the final projects. So don't leave everything for the last minute.", 'section': 'General course-related questions', 'question': 'Course - Can I still join the course after the start date?', 'course': 'data-engineering-zoomcamp'}, {'text': 'Yes, we will keep all the materials after the course finishes, so you can follow the course at your own pace after it finishes.\nYou can also continue looking at the homeworks and continue preparing for the next cohort. I guess you can also start working on your final capstone project.', 'section': 'General course-related questions', 'question': 'Course - Can I follow the course after it finishes?', 'course': 'data-engineering-zoomcamp'}, {'text': "The purpose of this document is to capture frequently asked technical questions\nThe exact day and hour of the course will be 15th Jan 2024 at 17h00. The course will start with the first “Office Hours'' live.1\nSubscribe to course public Google Calendar (it works from Desktop only).\nRegister before the course starts using this link.\nJoin the course Telegram channel with announcements.\nDon’t forget to register in DataTalks.Club's Slack and join the channel.", 'section': 'General course-related questions', 'question': 'Course - When will the course start?', 'course': 'data-engineering-zoomcamp'}, {'text': 'You can start by installing and setting up all the dependencies and requirements:\nGoogle cloud account\nGoogle Cloud SDK\nPython 3 (installed with Anaconda)\nTerraform\nGit\nLook over the prerequisites and syllabus to see if you are comfortable with these subjects.', 'section': 'General course-related questions', 'question': 'Course - What can I do before the course starts?', 'course': 'data-engineering-zoomcamp'}, {'text': 'Star the repo! Share it with friends if you find it useful ❣️\nCreate a PR if you see you can improve the text or the structure of the repository.', 'section': 'General course-related questions', 'question': 'How can we contribute to the course?', 'course': 'data-engineering-zoomcamp'}]4.1 Q3:仅对text字段嵌入排序
要求计算text字段的嵌入,并求查询向量与所有文档的余弦相似度,回答相似度最高的文档下标(从 0 开始)。作业给出的提示是:把 5 个向量放进一个二维矩阵V后,相似度计算就是一次矩阵乘法:
V = np.array(list(embedder.embed([d['text'] for d in documents]))) similarity = V.dot(q) similarity.argmax() # 相似度最高者的下标解答 notebook 中这一步的实际输出:
V_text = list(embedder.embed(df.text)) V_text = np.array(V_text) similarity = V_text.dot(q) # array([0.76296845, 0.81823782, 0.80853974, 0.71330788, 0.73044992]) similarity.argmax() # np.int64(1)可见 5 个相似度分别为约 0.76、0.82、0.81、0.71、0.73,Q3 答案是下标 1("Can I follow the course after it finishes?" 那条文档)。
4.2 Q4:改用question + ' ' + text拼接字段
Q4 要求计算一个新字段——question与text的拼接:
full_text = doc['question'] + ' ' + doc['text']再嵌入并计算与查询向量的余弦相似度,回答得分最高的文档。解答 notebook 的实现:
V_text = list(embedder.embed(df.question + ' ' + df.text)) V_text = np.array(V_text) similarity = V_text.dot(q) similarity.argmax() # np.int64(0)Q4 答案是下标 0。作业原文追问"是否与 Q3 不同?为什么?"——结合输出可以回答:不同,Q3 是 1,Q4 是 0。原因是文档 0 的question字段本身("Can I still join the course after the start date?")与查询句("Can I join now?")语义几乎完全重合,把问题文本并入嵌入内容后,它的相似度被显著拉高并超过了原本仅靠答案文本胜出的文档 1。这个对比恰好说明了**"嵌入哪个字段"是影响检索效果的核心设计决策**:在 Q6 的 Qdrant 索引 以及模块的混合搜索实践(见 rag.ipynb 中对question字段使用 3.0 权重 boost)中,"question + answer 联合嵌入 / 加权"正是课程反复使用的套路。
5. Q5:选择嵌入模型——fastembed 的最小维度
Q5 要求选出 fastembed 支持模型中最小的维度,选项为 128 / 256 / 384 / 512,并使用其中之一BAAI/bge-small-en。
从源码结构看,fastembed 把模型目录暴露为类方法TextEmbedding.list_supported_models(),返回每个模型的model、sources、model_file、license、size_in_GB、dim等字段。作业解答 notebook 中的实际用法:
import pandas as pd models = TextEmbedding.list_supported_models() df_models = pd.DataFrame(models) df_models[df_models.dim == df_models.dim.min()]筛选出最小维度后的结果包含BAAI/bge-small-en、BAAI/bge-small-en-v1.5、snowflake/snowflake-arctic-embed-xs等,dim均为384(模型文件约 0.13 GB,MIT 许可,model_optimized.onnx量化权重)。因此Q5 答案是 384,后续 Q6 使用BAAI/bge-small-en。
这里可以归纳出嵌入模型选择的一般权衡(模块 notebook 中也有相应讨论):维度越高通常语义表达越强但存储与内存开销越大;本地 CPU 推理场景下,384/512 维的小模型是课程推荐的档位。
6. Q6:把 FAQ 索引进 Qdrant 并检索(2 分)
Q6 是整份作业的压轴题,也是唯一需要使用 Qdrant 的部分。分为三步:加载数据、建集索引进、查询取分。
6.1 加载 machine-learning-zoomcamp 的 FAQ 数据
作业原文给定的加载代码:
import requests docs_url = 'https://github.com/alexeygrigorev/llm-rag-workshop/raw/main/notebooks/documents.json' docs_response = requests.get(docs_url) documents_raw = docs_response.json() documents = [] for course in documents_raw: course_name = course['course'] if course_name != 'machine-learning-zoomcamp': continue for doc in course['documents']: doc['course'] = course_name documents.append(doc)注意两个细节:documents_raw是按课程分组的外层列表,每条doc上手动写入了course字段,这与 sematic_search.ipynb 中把course存为 payload 元数据、用于后续过滤的设计一脉相承。
6.2 创建集合与写入点
按照 Q5 选定的模型,解答 notebook 中的关键配置:
from qdrant_client import QdrantClient, models qd_client = QdrantClient("http://localhost:6333") EMBEDDING_DIMENSIONALITY = 384 model_handle = "BAAI/bge-small-en" collection_name = "llmzoomcamp-homework" qd_client.delete_collection(collection_name=collection_name) qd_client.create_collection( collection_name=collection_name, vectors_config=models.VectorParams( size=EMBEDDING_DIMENSIONALITY, distance=models.Distance.COSINE ) )三个配置项的对应关系值得记住:size必须与模型输出维度一致(384);distance选择COSINE与嵌入模型按余弦相似度训练的假设一致(与第 3 节中"点积即余弦"的原理呼应)。
写入点时使用 Qdrant 客户端的本地嵌入能力——vector参数不传向量本身,而是传一个models.Document(text=..., model=...)描述符,客户端会在本机调用 fastembed 完成向量化:
points = [] for i, doc in enumerate(documents): text = doc['question'] + ' ' + doc['text'] vector = models.Document(text=text, model=model_handle) point = models.PointStruct( id=i, vector=vector, payload=doc ) points.append(point) qd_client.upsert( collection_name=collection_name, points=points )这里再次体现了 Q4 的结论:索引用的是question + ' ' + text拼接文本(作业明确要求 "use both question and answer fields"),而不是单独的答案文本。payload=doc则把整条 FAQ 记录作为元数据存储,检索时可随结果一并返回。首次运行时 fastembed 会自动下载模型文件到本地缓存目录(解答 notebook 的输出中可见 "Fetching 5 files" 与model_optimized.onnx约 133 MB 的下载进度)。
6.3 查询并读取得分
用 Q1 的查询句对集合发起检索:
question = 'I just discovered the course. Can I join now?' query_points = qd_client.query_points( collection_name=collection_name, query=models.Document( text=question, model=model_handle ), limit=5, with_payload=True )with_payload=True让结果携带 payload 元数据。解答 notebook 中第一条结果(query_points.points[0])的实际输出:
ScoredPoint(id=14, score=0.87031734, payload={'question': 'The course has already started. Can I still join it?', 'text': 'Yes, you can. You won’t be able to submit some of the homeworks, ...', 'course': 'machine-learning-zoomcamp', ...})首条结果的score为 0.87031734,因此Q6 答案是 0.87。命中的文档(id=14,"The course has already started. Can I still join it?")与查询语义完全对应,验证了"question + text 联合嵌入"对课程 FAQ 类数据的检索效果。
7. 答案速查与扩展学习
| 题目 | 考察点 | 答案 |
|---|---|---|
| Q1 | jina-v2-small-en 嵌入的最小值 | -0.11(实测 -0.11726) |
| Q2 | 查询与文档的余弦相似度(点积) | 0.9(实测 0.90085) |
| Q3 | 仅嵌入text字段的最高相似度下标 | 1 |
| Q4 | 嵌入question + text拼接后的最高下标 | 0(因 question 字段与查询高度同义,排名变化) |
| Q5 | fastembed 支持模型的最小维度 | 384(如 BAAI/bge-small-en) |
| Q6 | Qdrant 检索首条结果得分 | 0.87(实测 0.87031734) |
结果可能因模型文件版本与浮点精度略有出入,作业时选取最接近的选项即可。
进一步学习路径(均可在本仓库内找到对应材料):
- 完整模块主线:Docker 启动 Qdrant、fastembed 选模型、建集合、本地嵌入写入、相似度检索与
must过滤的完整演示,见 sematic_search.ipynb; - 混合检索(关键词 + 向量)与字段 boost 的实践,见 hybrid_search.ipynb 与 rag.ipynb;
- 想手动实现检索引擎并理解 hit-rate、精确检索与近似检索(ANN)的差异,见 2024 年队列第 3 周作业及其解答 homework_solution.ipynb,其中给出了
VectorSearchEngine的embeddings.dot(v_query)+np.argsort(-scores)极简实现; - 课程整体结构与后续作业入口见 2025 队列 README。
完成本作业后,你应该已经掌握了向量检索的完整闭环:选择与数据规模匹配的嵌入模型、验证向量归一化特性、用矩阵乘法批量计算余弦排序、理解"嵌入哪个字段"对结果的影响,以及把这套逻辑落到 Qdrant 这类生产级向量库上的标准操作(建集合、本地嵌入 upsert、query_points检索取分)。这正是后续 RAG、混合搜索与评估模块(2025 队列 Module 3)的直接基础。
【免费下载链接】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),仅供参考