因為計劃中的專案,會使用 Ollama,所以這次也寫了一版。
完整的程式碼在最下方,以下先逐步介紹各程式區塊。
▌簡介
本次作業探討向量搜尋和嵌入技術,使用 fastembed 和 Qdrant 來實現語義搜尋。
》主要學習目標
- 文字嵌入 (Text Embedding) 的概念
- 餘弦相似度計算
- 實作向量資料庫索引和搜尋
- 比較不同嵌入策略的效果
▌環境設定
Setup
》必要套件安裝
pip install fastembed qdrant-client numpy requests
》核心模組導入
import numpy as np
import requests
from fastembed import TextEmbedding
from qdrant_client import QdrantClient, models
▌Q1: 文字嵌入基礎
Text Embedding Basics
》目標
將查詢文字轉換為向量表示
》程式碼解析
# 初始化嵌入模型
model = TextEmbedding(model_name='jinaai/jina-embeddings-v2-small-en')
# 嵌入查詢
query = 'I just discovered the course. Can I join now?'
# 將文字轉換為 512 維的數值向量,然後將資料型態由 generator 轉換為 list
# 由向量組成的 list(文件規模不大時,用這個方法)
embeddings = list(model.embed([query]))
q = embeddings[0]
文字查詢 → 嵌入模型 → 數值向量
↓ ↓ ↓
"I just..." → embed() → [0.1, -0.2, ...]
》關鍵概念
- Jina AI 模型: 512 維的小型英文嵌入模型
- 向量維度: 512 個數值組成的陣列
- 最小值查找:
np.min(q)找出向量中的最小值
》實際應用
文字嵌入將自然語言轉換為機器可理解的數值表示
▌Q2: 餘弦相似度計算
Cosine Similarity
》目標
測量兩個文字向量之間的語義相似程度
》程式碼解析
doc = 'Can I still join the course after the start date?'
doc_embeddings = list(model.embed([doc]))
doc_vector = doc_embeddings[0]
# 計算前面兩個向量(q & doc_vector)的餘弦相似度
# 'I just discovered the course. Can I join now?'
# 'Can I still join the course after the start date?'
cosine_similarity = np.dot(q, doc_vector)
》數學原理
- 正規化向量: FastEmbed 輸出的向量長度為 1.0
- 點積運算: 正規化向量的點積等於餘弦相似度
- 相似度範圍: -1 到 1,值越大表示越相似
》驗證方法
np.linalg.norm(q) # 應該等於 1.0
q.dot(q) # 向量與自己的相似度為 1.0
▌Q3: 文件排序 - 純文字內容
Document Ranking - Text Only
》目標
在多個文件中,找出與查詢最相似的文件。
》程式碼解析
# 只使用 text 欄位(題目要求使用 text 這個欄位,本欄位的內容是QA的A)
texts = [doc['text'] for doc in documents]
# 批量嵌入
text_embeddings = list(model.embed(texts))
# 計算相似度並排序
similarities = []
for i, doc_vector in enumerate(text_embeddings):
similarity = np.dot(q, doc_vector)
similarities.append((i, similarity))
similarities.sort(key=lambda x: x[1], reverse=True)
這裡老師給了一個提示: 提示:如果將文字欄位的所有嵌入放在一個矩陣V (單一二維 numpy 陣列)中,則計算餘弦就變成了矩陣乘法:V.dot(q)。
老師的意思是:將 5 個文件向量堆疊成一個矩陣,然後一次性計算所有相似度。
老師建議的方法(矩陣):
# 一次矩陣運算完成所有計算
V = np.array(text_embeddings) # 組成矩陣
similarities = V.dot(q) # 批量計算
我們的方法(迴圈):
# 5 次獨立的點積運算
similarities = []
for doc_vector in text_embeddings:
sim = np.dot(q, doc_vector) # 單次運算
similarities.append(sim)
矩陣方法的優勢:
- 更快:利用 NumPy 的優化 BLAS 函數庫
- 更簡潔:一行程式碼取代迴圈
- 更適合大規模:處理數千個文件時差異明顯
》重要概念
- 批量處理: 一次嵌入多個文件提高效率
- 排序機制: 按相似度由高到低排序
- 索引追蹤: 保持文件索引以便後續引用
▌Q4: 文件排序 - 完整內容
Document Ranking - Full Content
》目標
比較使用完整文件內容(問題+答案)的排序效果
》程式碼解析
# 組合 question 和 text
full_texts = [doc['question'] + ' ' + doc['text'] for doc in documents]
# 重新嵌入和排序
full_embeddings = list(model.embed(full_texts))
Q3 只使用 答案 欄位時,相似度最高為 0.818,Q4 則是使用 問題 + 答案,,相似度最高為 0.851,明顯高得多。
Q3 結果 (只使用 text 欄位):
排名 1: 文件 1, 相似度: 0.818
排名 2: 文件 2, 相似度: 0.812
排名 3: 文件 0, 相似度: 0.763
排名 4: 文件 4, 相似度: 0.730
排名 5: 文件 3, 相似度: 0.713
Q3 最高相似度的文件索引: 1
Q4 結果 (使用 question + text):
排名 1: 文件 0, 相似度: 0.851
排名 2: 文件 1, 相似度: 0.844
排名 3: 文件 2, 相似度: 0.843
排名 4: 文件 4, 相似度: 0.809
排名 5: 文件 3, 相似度: 0.776
Q4 最高相似度的文件索引: 0
》策略比較
- 純文字: 只考慮答案內容
- 完整內容: 問題提供額外的語義線索
- 效果差異: 可能改變文件排序結果
》實務意義
在實際應用中,包含問題可以提供更多上下文資訊。
▌Q5: 模型選擇 - 維度最佳化
Model Selection
》目標
找出最小維度的嵌入模型以節省計算資源
》程式碼解析
# 獲取所有支援的模型
models_list = TextEmbedding.list_supported_models()
# 找出最小維度
min_dim = float('inf')
for model_info in models_list:
dim = model_info.get('dim', 0)
if dim > 0 and dim < min_dim:
min_dim = dim
》權衡考量
- 維度大小: 影響計算速度和記憶體使用
- 模型效能: 通常維度越大,語義理解越好
- 實用選擇:
BAAI/bge-small-en(384 維) 是效能和效率的平衡
▌Q6: Qdrant 向量資料庫實作
Qdrant Vector Database
》目標
建立生產級的向量搜尋系統
》資料準備
# 載入機器學習課程的 FAQ
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()
# 篩選特定課程
ml_documents = []
for course in documents_raw:
if course['course'] == 'machine-learning-zoomcamp':
ml_documents.extend(course['documents'])
》Qdrant 配置
# 連接資料庫
client = QdrantClient("http://localhost:6333")
# 建立集合
client.create_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(
size=384, # 小型模型維度
distance=models.Distance.COSINE
)
)
》資料索引 - 使用 Document 物件
# 處理每個文件
for i, doc in enumerate(ml_documents):
# 組合完整文字
text = doc['question'] + ' ' + doc['text']
# 建立資料點(使用 Document 物件讓 Qdrant 自動嵌入)
point = models.PointStruct(
id=i,
vector=models.Document(text=text, model=model_handle),
payload={
"text": doc['text'],
"section": doc['section'],
"question": doc['question'],
"course": doc['course']
}
)
》向量搜尋 - 使用最新 API
# 直接用文字查詢(不需要預先計算向量)
search_result = client.query_points(
collection_name=collection_name,
query=models.Document(
text=query,
model=model_handle
),
limit=5
)
方法優勢
補充:原本使用
lient.search(query_vector=...),但本方法 Qdrant 將在未來棄用,所以還是用老師的方法lient.query_points(query=models.Document(...))。
- 更簡潔: 讓 Qdrant 處理向量計算
- 更穩定: 使用最新 API,避免 deprecation warning
- 更實務: 直接處理文字輸入,符合實際應用場景
- 一致性: 與課程中老師的方法相同
▌關鍵學習成果
Key Learnings
》技術成就
- 嵌入技術: 掌握文字向量化的實作
- 相似度計算: 理解餘弦相似度的數學原理
- 系統架構: 建立完整的向量搜尋系統
- 效能最佳化: 選擇適當的模型維度
》實務應用
- 搜尋引擎: 語義搜尋比關鍵字搜尋更智能
- 推薦系統: 找出相似的內容或用戶
- 問答系統: RAG 架構的核心技術
- 內容分類: 自動歸類相似文件
》最佳實踐
- 模型選擇: 根據需求平衡效能和效率
- 文字處理: 組合多個欄位提供更豐富的語義
- 批量處理: 提高嵌入計算的效率
- 資料庫設計: 使用專業向量資料庫處理大規模資料
▌總結
Conclusion
向量搜尋是現代 AI 應用的基礎技術,通過本次作業我們:
- 實作了從基礎嵌入到生產級向量資料庫的完整流程
- 理解了不同嵌入策略對搜尋結果的影響
- 掌握了向量相似度計算的數學原理
- 建立了可擴展的語義搜尋系統
這些技能為後續的 RAG 系統開發和大型語言模型應用奠定了堅實基礎。
▌完整程式碼
import numpy as np
import pandas as pd
import requests
from fastembed import TextEmbedding
from qdrant_client import QdrantClient, models
# Q1. Embedding the query
print("=== Q1. Embedding the query ===")
# 初始化 TextEmbedding 模型
embedding_model = TextEmbedding(model_name="jinaai/jina-embeddings-v2-small-en")
# 嵌入查詢
query = 'I just discovered the course. Can I join now?'
query_embedding = list(embedding_model.embed([query]))[0]
print(f"嵌入向量大小: {query_embedding.shape}")
print(f"最小值: {np.min(query_embedding):.3f}")
# 驗證向量是否已正規化
print(f"向量長度: {np.linalg.norm(query_embedding):.3f}")
# Q2. Cosine similarity with another vector
print("\n=== Q2. Cosine similarity with another vector ===")
doc = 'Can I still join the course after the start date?'
doc_embedding = list(embedding_model.embed([doc]))[0]
# 計算餘弦相似度
cosine_similarity = np.dot(query_embedding, doc_embedding)
print(f"餘弦相似度: {cosine_similarity:.3f}")
# Q3. Ranking by cosine
print("\n=== Q3. Ranking by cosine ===")
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'}
]
# 計算所有文檔的嵌入
text_embeddings = []
for doc in documents:
text_emb = list(embedding_model.embed([doc['text']]))[0]
text_embeddings.append(text_emb)
# 轉換為矩陣並計算餘弦相似度
V = np.array(text_embeddings)
similarities = V.dot(query_embedding)
print("各文檔與查詢的相似度:")
for i, sim in enumerate(similarities):
print(f"文檔 {i}: {sim:.3f}")
highest_sim_index = np.argmax(similarities)
print(f"最高相似度的文檔索引: {highest_sim_index}")
# Q4. Ranking by cosine, version two
print("\n=== Q4. Ranking by cosine, version two ===")
# 計算 question + text 的嵌入
full_text_embeddings = []
for doc in documents:
full_text = doc['question'] + ' ' + doc['text']
full_text_emb = list(embedding_model.embed([full_text]))[0]
full_text_embeddings.append(full_text_emb)
# 轉換為矩陣並計算餘弦相似度
V_full = np.array(full_text_embeddings)
similarities_full = V_full.dot(query_embedding)
print("各文檔 (question+text) 與查詢的相似度:")
for i, sim in enumerate(similarities_full):
print(f"文檔 {i}: {sim:.3f}")
highest_sim_index_full = np.argmax(similarities_full)
print(f"最高相似度的文檔索引: {highest_sim_index_full}")
if highest_sim_index != highest_sim_index_full:
print(f"結果不同!Q3: {highest_sim_index}, Q4: {highest_sim_index_full}")
print("原因:包含問題文本提供了更多上下文信息,提高了相關性匹配")
else:
print("結果相同")
# Q5. Selecting the embedding model
print("\n=== Q5. Selecting the embedding model ===")
# 使用 TextEmbedding.list_supported_models() 取得支援的「密集嵌入」模型列表
# 這是一個類別方法,不需要初始化 (不用下載模型) 即可呼叫
supported_models = TextEmbedding.list_supported_models()
# 轉換為 pandas DataFrame 以方便閱讀
df = pd.DataFrame(supported_models)
print(f"總共支援 {len(supported_models)} 個模型")
# 為了方便閱讀,只顯示核心欄位:模型名稱(model)、維度(dim)、模型大小(size_in_GB)
print("\n模型列表 (前 15 個):")
print(df[['model', 'dim', 'size_in_GB']].head(15))
# 分析維度
dimensions = sorted(df['dim'].unique())
min_dimension = min(dimensions)
print(f"\n所有維度: {dimensions}")
print(f"最小維度: {min_dimension}")
# 作業選項
options = [128, 256, 384, 512]
print(f"作業選項: {options}")
print(f"Q5 答案: {min_dimension}")
# Q6. Indexing with qdrant
print("\n=== Q6. Indexing with qdrant ===")
# 載入 ML Zoomcamp 文檔
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()
ml_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
ml_documents.append(doc)
print(f"載入了 {len(ml_documents)} 個 ML Zoomcamp 文檔")
# 初始化 Qdrant 客戶端
qd_client = QdrantClient("http://localhost:6333")
# 使用 BAAI/bge-small-en 模型
model_handle = "BAAI/bge-small-en"
# 測試模型維度
small_model = TextEmbedding(model_name=model_handle)
test_emb = list(small_model.embed(["test"]))[0]
EMBEDDING_DIMENSIONALITY = test_emb.shape[0]
print(f"使用模型: {model_handle}")
print(f"模型維度: {EMBEDDING_DIMENSIONALITY}")
collection_name = "ml-zoomcamp-faq"
# 刪除已存在的集合
try:
qd_client.delete_collection(collection_name=collection_name)
print("刪除了舊的集合")
except:
pass
# 創建新集合
qd_client.create_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(
size=EMBEDDING_DIMENSIONALITY,
distance=models.Distance.COSINE
)
)
print("創建了新集合")
# 準備要索引的點
points = []
for i, doc in enumerate(ml_documents):
text = doc['question'] + ' ' + doc['text']
# 使用 Document 物件而不是預先計算向量
vector = models.Document(text=text, model="BAAI/bge-small-en")
point = models.PointStruct(
id=i,
vector=vector,
payload=doc
)
points.append(point)
# 批量插入點
qd_client.upsert(
collection_name=collection_name,
points=points
)
print(f"插入了 {len(points)} 個點")
# 定義 vector_search 函數(針對 ML Zoomcamp)
def vector_search_ml(question):
print('使用 vector_search_ml')
# 使用 query_points 方法,直接傳入文本和模型
search_results = qd_client.query_points(
collection_name=collection_name,
query=models.Document(
text=question,
model="BAAI/bge-small-en"
),
limit=5,
with_payload=True
)
results = []
for point in search_results.points:
results.append(point.payload)
return results, search_results.points
def build_prompt(query, search_results):
prompt_template = """
You're a course teaching assistant. Answer the QUESTION based on the CONTEXT from the FAQ database.
Use only the facts from the CONTEXT when answering the QUESTION.
QUESTION: {question}
CONTEXT:
{context}
""".strip()
context = ""
for doc in search_results:
context = context + f"section: {doc['section']}\nquestion: {doc['question']}\nanswer: {doc['text']}\n\n"
prompt = prompt_template.format(question=query, context=context).strip()
return prompt
# 使用 Ollama 本地模型
def llm(prompt):
try:
response = requests.post(
'http://localhost:11434/api/generate',
json={
'model': 'llama3.2',
'prompt': prompt,
'stream': False
},
timeout=60
)
if response.status_code == 200:
return response.json()['response']
else:
return f"Ollama 錯誤:HTTP {response.status_code}"
except requests.exceptions.RequestException as e:
return f"連線錯誤:{str(e)}"
except Exception as e:
return f"未知錯誤:{str(e)}"
def rag_ml(query):
search_results, raw_results = vector_search_ml(query)
prompt = build_prompt(query, search_results)
answer = llm(prompt)
return answer, raw_results
# 使用 Q1 的問題進行查詢,並使用 llm function
query_text = 'I just discovered the course. Can I join now?'
print(f"查詢問題: {query_text}")
# 使用完整的 RAG 系統(包含 llm function)
answer, raw_results = rag_ml(query_text)
print(f"RAG 系統回答:")
print(answer)
print()
print(f"向量搜尋結果:")
for i, result in enumerate(raw_results):
print(f"結果 {i+1}: 分數 = {result.score:.3f}")
print(f"問題: {result.payload['question']}")
print(f"文本: {result.payload['text'][:100]}...")
print()
highest_score = raw_results[0].score if raw_results else 0
print(f"最高分數: {highest_score:.3f}")
print("\n=== 作業答案總結 ===")
print(f"Q1. 最小值: {np.min(query_embedding):.3f}")
print(f"Q2. 餘弦相似度: {cosine_similarity:.3f}")
print(f"Q3. 最高相似度文檔索引: {highest_sim_index}")
print(f"Q4. 最高相似度文檔索引: {highest_sim_index_full}")
print(f"Q5. 最小維度: {EMBEDDING_DIMENSIONALITY}")
print(f"Q6. 最高分數: {highest_score:.3f}")