ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,771 @@
|
||||
"""混合检索流水线离线评测 CLI。
|
||||
|
||||
本脚本把整条检索流水线——分块(chunk) → 嵌入(embed) → 检索(retrieve) →
|
||||
融合(fuse) → 重排(rerank)——完整地跑在**单进程、可离线**的环境里,并在一个带
|
||||
标注答案的小型评测集上,逐阶段对比各方法的检索质量。它不依赖 dense/sparse 微服务
|
||||
(4240/4241/4242 端口),因此可以脱离服务、直接用本地模型复现「每加一个阶段、指标如何
|
||||
提升」这一核心结论。
|
||||
|
||||
各阶段使用的本地组件:
|
||||
- 稀疏检索(sparse) : BM25(纯 Python,rank_bm25,无需下载模型)
|
||||
- 稠密检索(dense) : 本地句向量模型(默认 Qwen3-Embedding-0.6B,多语言,
|
||||
通过 transformers 加载;也可换成 BGE-M3 等)
|
||||
- 融合(fuse) : 见 fusion.py,RRF 与加权归一化两种策略
|
||||
- 重排(rerank) : 交叉编码器(默认 cross-encoder/ms-marco-MiniLM-L-6-v2)
|
||||
|
||||
默认行为(不带任何参数):在内置评测集上评测
|
||||
BM25 / Dense / Hybrid-RRF / Hybrid-Weighted / Hybrid-RRF+Rerank 五种配置,
|
||||
打印 Recall@k、MRR、nDCG@k 对比表。
|
||||
|
||||
示例:
|
||||
python evaluate.py # 内置评测集,完整对比表
|
||||
python evaluate.py --top-k 10 --rerank-top-k 5
|
||||
python evaluate.py --no-rerank # 跳过重排阶段
|
||||
python evaluate.py --embed-model BAAI/bge-m3 --pooling cls
|
||||
python evaluate.py --query "怎样提升检索精度" # 单条查询、逐阶段排名追踪
|
||||
python evaluate.py --corpus my_corpus.json --queries my_queries.json --output result.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from fusion import fuse
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内置评测集:直接复用 test_client.py 中的教育性测试案例(语义相似 / 精确名称 /
|
||||
# 多语言 / 技术代码四类),其 expected 字段即人工标注的相关文档,作为评测金标准。
|
||||
# 另加两篇较长文档用于演示分块(chunk)阶段。
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_CORPUS: List[Dict[str, Any]] = [
|
||||
# --- 近似重复的代码簇(稀疏占优、稠密翻车)---
|
||||
# 各条文本几乎完全相同,只有型号代码不同;稠密向量几乎无法区分同一簇内的成员,
|
||||
# 稀疏检索靠精确词项匹配却能一击命中。簇越大,稠密选错的概率越高。
|
||||
{"doc_id": "xr_7001", "text": "Product model XR-7001 is a smartphone available now."},
|
||||
{"doc_id": "xr_7002", "text": "Product model XR-7002 is a smartphone available now."},
|
||||
{"doc_id": "xr_7003", "text": "Product model XR-7003 is a smartphone available now."},
|
||||
{"doc_id": "xr_7004", "text": "Product model XR-7004 is a smartphone available now."},
|
||||
{"doc_id": "xr_7005", "text": "Product model XR-7005 is a smartphone available now."},
|
||||
{"doc_id": "xr_7006", "text": "Product model XR-7006 is a smartphone available now."},
|
||||
# 近似重复的 HTTP 错误码簇(稀疏占优、稠密翻车)
|
||||
{"doc_id": "http_400", "text": "The HTTP-400 response is a client error status code."},
|
||||
{"doc_id": "http_401", "text": "The HTTP-401 response is a client error status code."},
|
||||
{"doc_id": "http_403", "text": "The HTTP-403 response is a client error status code."},
|
||||
{"doc_id": "http_404", "text": "The HTTP-404 response is a client error status code."},
|
||||
{"doc_id": "http_500", "text": "The HTTP-500 response is a server error status code."},
|
||||
# --- 语义改写簇(稠密占优、稀疏翻车)---
|
||||
# 查询与文档几乎没有共同词,稀疏 BM25 无从匹配,稠密靠语义命中。
|
||||
{"doc_id": "sem_readable", "text": "The language emphasizes clean, readable code that newcomers can pick up quickly."},
|
||||
{"doc_id": "sem_gc", "text": "Automatic memory management frees developers from manually releasing objects."},
|
||||
{"doc_id": "sem_photo", "text": "Green plants convert sunlight into chemical energy stored as sugars."},
|
||||
{"doc_id": "sem_crypto", "text": "Encryption scrambles a message so that only the intended recipient can read it."},
|
||||
# Exact proper-name cluster: sparse matching should preserve the complete
|
||||
# name while dense retrieval sees several near-duplicates.
|
||||
{"doc_id": "name_alexander_humphrey", "text": "Alexander Humphrey designed the Aurora scheduling protocol in 2019."},
|
||||
{"doc_id": "name_alexander_hughes", "text": "Alexander Hughes designed the Borealis scheduling protocol in 2019."},
|
||||
{"doc_id": "name_amelia_humphrey", "text": "Amelia Humphrey designed the Celeste scheduling protocol in 2020."},
|
||||
# 较长文档:话题彼此独立,用于演示分块阶段(会被切成多个 chunk 后再检索)
|
||||
{"doc_id": "doc_watercycle", "text": (
|
||||
"The water cycle describes how water moves continuously between the ocean, the atmosphere and the land. "
|
||||
"Heat from the sun evaporates water from the sea surface into vapor that rises high into the sky. "
|
||||
"As the vapor cools it condenses into tiny droplets that gather to form clouds. "
|
||||
"When the droplets grow heavy enough they fall back to the ground as rain or snow, "
|
||||
"and rivers eventually carry that water back to the ocean, closing the loop."
|
||||
)},
|
||||
{"doc_id": "doc_volcano", "text": (
|
||||
"A volcano forms where molten rock called magma rises from deep inside the planet toward the surface. "
|
||||
"Magma collects in a chamber beneath the crust, and mounting pressure forces it upward through cracks. "
|
||||
"During an eruption the magma bursts out as lava, ash and gas, which pile up around the vent. "
|
||||
"Layer after layer of cooled lava slowly builds the cone-shaped mountain we recognize as a volcano."
|
||||
)},
|
||||
]
|
||||
|
||||
DEFAULT_QUERIES: List[Dict[str, Any]] = [
|
||||
# 精确代码查询:稀疏一击命中,稠密难辨近似型号(expected 为唯一正确答案)
|
||||
{"query": "XR-7003", "expected": ["xr_7003"], "category": "technical-code"},
|
||||
{"query": "XR-7005", "expected": ["xr_7005"], "category": "technical-code"},
|
||||
{"query": "HTTP-403", "expected": ["http_403"], "category": "technical-code"},
|
||||
{"query": "HTTP-400", "expected": ["http_400"], "category": "technical-code"},
|
||||
{"query": "Alexander Humphrey", "expected": ["name_alexander_humphrey"], "category": "exact-name"},
|
||||
# 语义改写查询:与文档几乎无共同词,稠密靠语义命中,稀疏无从匹配
|
||||
{"query": "a beginner friendly language with tidy syntax", "expected": ["sem_readable"], "category": "semantic"},
|
||||
{"query": "reclaiming unused heap space without programmer effort", "expected": ["sem_gc"], "category": "semantic"},
|
||||
{"query": "how vegetation turns light into food", "expected": ["sem_photo"], "category": "semantic"},
|
||||
{"query": "hiding a note so eavesdroppers cannot understand it", "expected": ["sem_crypto"], "category": "semantic"},
|
||||
# Cross-lingual query with no shared lexical terms; the answer remains the
|
||||
# same English photosynthesis passage used by the semantic query above.
|
||||
{"query": "植物如何把阳光转化为食物", "expected": ["sem_photo"], "category": "multilingual"},
|
||||
# 长文档语义查询:命中的长文档会先被分块,再由某个 chunk 召回、重排
|
||||
{"query": "how does water move between the ocean and the sky", "expected": ["doc_watercycle"], "category": "semantic"},
|
||||
{"query": "how are volcanoes formed from molten rock", "expected": ["doc_volcano"], "category": "semantic"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分块(chunk)
|
||||
# ---------------------------------------------------------------------------
|
||||
def chunk_text(text: str, chunk_size: int, overlap: int) -> List[str]:
|
||||
"""按字符窗口把文档切成带重叠的 chunk。
|
||||
|
||||
短文档(长度 <= chunk_size)原样返回单个 chunk。真实场景中 chunk 是检索的最小
|
||||
单元;这里用字符级滑窗保持实现简单、语言无关。
|
||||
|
||||
Args:
|
||||
text: 原始文档文本。
|
||||
chunk_size: 每个 chunk 的最大字符数。
|
||||
overlap: 相邻 chunk 的重叠字符数。
|
||||
|
||||
Returns:
|
||||
chunk 文本列表(至少一个)。
|
||||
"""
|
||||
text = text.strip()
|
||||
if chunk_size <= 0 or len(text) <= chunk_size:
|
||||
return [text]
|
||||
|
||||
step = max(1, chunk_size - overlap)
|
||||
chunks = []
|
||||
for start in range(0, len(text), step):
|
||||
piece = text[start:start + chunk_size].strip()
|
||||
if piece:
|
||||
chunks.append(piece)
|
||||
if start + chunk_size >= len(text):
|
||||
break
|
||||
return chunks or [text]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分词(BM25 用):保留英文词/数字/带连字符或下划线的代码,CJK 走 jieba + 单字
|
||||
# ---------------------------------------------------------------------------
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:[-_][a-z0-9]+)*|[一-鿿]+")
|
||||
|
||||
|
||||
def tokenize(text: str) -> List[str]:
|
||||
"""把文本切成 BM25 词项。
|
||||
|
||||
- 英文单词、纯数字、以及像 ``http-403`` / ``max_buffer_size`` / ``xr-7000``
|
||||
这样的技术代码会被整体保留(连字符、下划线不切开),保证精确匹配。
|
||||
- 连续 CJK 片段同时产出 jieba 分词结果与单字,增强中文召回鲁棒性。
|
||||
"""
|
||||
tokens: List[str] = []
|
||||
for match in _TOKEN_RE.finditer(text.lower()):
|
||||
span = match.group()
|
||||
if "一" <= span[0] <= "鿿":
|
||||
try:
|
||||
import jieba
|
||||
tokens.extend(w for w in jieba.cut(span) if w.strip())
|
||||
except Exception:
|
||||
pass
|
||||
tokens.extend(list(span))
|
||||
else:
|
||||
tokens.append(span)
|
||||
return tokens
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 稀疏检索:BM25
|
||||
# ---------------------------------------------------------------------------
|
||||
class BM25Retriever:
|
||||
"""基于 rank_bm25 的 BM25 检索器(chunk 级)。"""
|
||||
|
||||
def __init__(self, chunk_ids: List[str], chunk_texts: List[str]):
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
self.chunk_ids = chunk_ids
|
||||
self.tokenized = [tokenize(t) for t in chunk_texts]
|
||||
self.bm25 = BM25Okapi(self.tokenized)
|
||||
|
||||
def search(self, query: str, top_k: int) -> List[Tuple[str, float]]:
|
||||
"""返回 (chunk_id, score) 列表,按分数降序,只保留正分。"""
|
||||
scores = self.bm25.get_scores(tokenize(query))
|
||||
ranked = sorted(zip(self.chunk_ids, scores), key=lambda kv: kv[1], reverse=True)
|
||||
return [(cid, float(s)) for cid, s in ranked[:top_k] if s > 0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 稠密检索:本地句向量模型(transformers)
|
||||
# ---------------------------------------------------------------------------
|
||||
class DenseEncoder:
|
||||
"""用 transformers 加载本地句向量模型,做稠密检索。"""
|
||||
|
||||
def __init__(self, model_name: str, pooling: str, device: str,
|
||||
query_instruct: str = "", max_length: int = 256):
|
||||
import torch
|
||||
from transformers import AutoModel, AutoTokenizer
|
||||
|
||||
self.torch = torch
|
||||
self.device = device
|
||||
self.max_length = max_length
|
||||
self.pooling = self._resolve_pooling(pooling, model_name)
|
||||
# 指令式检索模型(如 Qwen3-Embedding,last-token 池化)要求查询侧带任务指令;
|
||||
# mean/cls 池化的模型(MiniLM / BGE-M3)不需要,自动关闭。
|
||||
self.query_instruct = query_instruct if (query_instruct and self.pooling == "last") else ""
|
||||
# last-token pooling 需要左侧 padding,才能让最后一个位置对齐真实末词
|
||||
padding_side = "left" if self.pooling == "last" else "right"
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(model_name, padding_side=padding_side)
|
||||
self.model = AutoModel.from_pretrained(model_name).to(device).eval()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_pooling(pooling: str, model_name: str) -> str:
|
||||
if pooling != "auto":
|
||||
return pooling
|
||||
name = model_name.lower()
|
||||
if "qwen" in name:
|
||||
return "last"
|
||||
if "bge-m3" in name or "bge-large" in name or "bge-base" in name:
|
||||
return "cls"
|
||||
return "mean"
|
||||
|
||||
def _pool(self, last_hidden, attention_mask):
|
||||
torch = self.torch
|
||||
if self.pooling == "cls":
|
||||
return last_hidden[:, 0]
|
||||
if self.pooling == "last":
|
||||
return last_hidden[:, -1]
|
||||
# mean pooling
|
||||
mask = attention_mask.unsqueeze(-1).float()
|
||||
return (last_hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
|
||||
|
||||
def encode(self, texts: Sequence[str], is_query: bool = False, batch_size: int = 16):
|
||||
torch = self.torch
|
||||
if is_query and self.query_instruct:
|
||||
texts = [f"Instruct: {self.query_instruct}\nQuery:{t}" for t in texts]
|
||||
vectors = []
|
||||
for start in range(0, len(texts), batch_size):
|
||||
batch = list(texts[start:start + batch_size])
|
||||
pooled = self._forward(batch)
|
||||
# 某些模型在 mps 上前向会出 NaN(transformers 5.x + 某些权重);
|
||||
# 检测到后永久退回 CPU 重算,保证向量有限、结果可复现。
|
||||
if self.device != "cpu" and torch.isnan(pooled).any():
|
||||
self.device = "cpu"
|
||||
self.model = self.model.to("cpu")
|
||||
pooled = self._forward(batch)
|
||||
pooled = torch.nn.functional.normalize(pooled.float(), p=2, dim=1)
|
||||
vectors.append(pooled.cpu())
|
||||
return torch.cat(vectors, dim=0)
|
||||
|
||||
def _forward(self, batch: List[str]):
|
||||
torch = self.torch
|
||||
enc = self.tokenizer(
|
||||
batch, padding=True, truncation=True,
|
||||
max_length=self.max_length, return_tensors="pt",
|
||||
).to(self.device)
|
||||
with torch.no_grad():
|
||||
out = self.model(**enc)
|
||||
return self._pool(out.last_hidden_state, enc["attention_mask"])
|
||||
|
||||
|
||||
class DenseRetriever:
|
||||
"""基于稠密向量余弦相似度的 chunk 级检索器。"""
|
||||
|
||||
def __init__(self, encoder: DenseEncoder, chunk_ids: List[str], chunk_texts: List[str]):
|
||||
self.encoder = encoder
|
||||
self.chunk_ids = chunk_ids
|
||||
self.matrix = encoder.encode(chunk_texts) # [N, D], 已归一化
|
||||
|
||||
def search(self, query: str, top_k: int) -> List[Tuple[str, float]]:
|
||||
q = self.encoder.encode([query], is_query=True)[0]
|
||||
sims = (self.matrix @ q).tolist()
|
||||
ranked = sorted(zip(self.chunk_ids, sims), key=lambda kv: kv[1], reverse=True)
|
||||
return [(cid, float(s)) for cid, s in ranked[:top_k]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 重排:交叉编码器(cross-encoder)
|
||||
# ---------------------------------------------------------------------------
|
||||
class CrossEncoderReranker:
|
||||
"""用交叉编码器对候选做精排。
|
||||
|
||||
在 transformers 5.x + 部分 BERT 权重上,fp32 前向可能出现 NaN;本类检测到 NaN 后
|
||||
自动回退到 CPU + float64 重算,保证输出有限、可复现。
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str, device: str, max_length: int = 512):
|
||||
import torch
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
|
||||
self.torch = torch
|
||||
self.device = device
|
||||
self.max_length = max_length
|
||||
self.model_name = model_name
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
||||
self.model = AutoModelForSequenceClassification.from_pretrained(model_name).to(device).eval()
|
||||
|
||||
def score(self, query: str, docs: Sequence[str]) -> List[float]:
|
||||
torch = self.torch
|
||||
if not docs:
|
||||
return []
|
||||
enc = self.tokenizer(
|
||||
[query] * len(docs), list(docs),
|
||||
padding=True, truncation=True, max_length=self.max_length, return_tensors="pt",
|
||||
).to(self.device)
|
||||
with torch.no_grad():
|
||||
logits = self.model(**enc).logits.squeeze(-1).float()
|
||||
if torch.isnan(logits).any():
|
||||
# 回退:CPU + float64 重算
|
||||
enc_cpu = {k: v.to("cpu") for k, v in enc.items()}
|
||||
model64 = self.model.to("cpu").double()
|
||||
with torch.no_grad():
|
||||
logits = model64(**enc_cpu).logits.squeeze(-1)
|
||||
self.model = self.model.to(self.device).float()
|
||||
return [float(x) for x in logits.reshape(-1).tolist()]
|
||||
|
||||
def rerank(self, query: str, candidates: List[Tuple[str, str]], top_k: int) -> List[Tuple[str, float]]:
|
||||
"""candidates: [(doc_id, text)] -> [(doc_id, rerank_score)] 降序,取 top_k。"""
|
||||
scores = self.score(query, [text for _, text in candidates])
|
||||
ranked = sorted(
|
||||
((doc_id, s) for (doc_id, _), s in zip(candidates, scores)),
|
||||
key=lambda kv: kv[1], reverse=True,
|
||||
)
|
||||
return ranked[:top_k]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chunk 级结果 -> doc 级结果(同一文档取最高分的 chunk)
|
||||
# ---------------------------------------------------------------------------
|
||||
def chunks_to_docs(ranked_chunks: List[Tuple[str, float]], chunk_to_doc: Dict[str, str]) -> List[Tuple[str, float]]:
|
||||
best: Dict[str, float] = {}
|
||||
for chunk_id, score in ranked_chunks:
|
||||
doc_id = chunk_to_doc[chunk_id]
|
||||
if doc_id not in best or score > best[doc_id]:
|
||||
best[doc_id] = score
|
||||
return sorted(best.items(), key=lambda kv: kv[1], reverse=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 评测指标
|
||||
# ---------------------------------------------------------------------------
|
||||
def recall_at_k(ranked_ids: List[str], gold: Sequence[str], k: int) -> float:
|
||||
if not gold:
|
||||
return 0.0
|
||||
topk = set(ranked_ids[:k])
|
||||
return len(topk & set(gold)) / len(gold)
|
||||
|
||||
|
||||
def reciprocal_rank(ranked_ids: List[str], gold: Sequence[str]) -> float:
|
||||
gold_set = set(gold)
|
||||
for idx, doc_id in enumerate(ranked_ids, start=1):
|
||||
if doc_id in gold_set:
|
||||
return 1.0 / idx
|
||||
return 0.0
|
||||
|
||||
|
||||
def ndcg_at_k(ranked_ids: List[str], gold: Sequence[str], k: int) -> float:
|
||||
gold_set = set(gold)
|
||||
dcg = 0.0
|
||||
for idx, doc_id in enumerate(ranked_ids[:k], start=1):
|
||||
if doc_id in gold_set:
|
||||
dcg += 1.0 / math.log2(idx + 1)
|
||||
ideal_hits = min(len(gold_set), k)
|
||||
idcg = sum(1.0 / math.log2(i + 1) for i in range(1, ideal_hits + 1))
|
||||
return dcg / idcg if idcg > 0 else 0.0
|
||||
|
||||
|
||||
def aggregate_metrics(per_query_ranked: List[Tuple[List[str], Sequence[str]]], k: int) -> Dict[str, float]:
|
||||
n = len(per_query_ranked)
|
||||
if n == 0:
|
||||
return {"recall@k": 0.0, "mrr": 0.0, "ndcg@k": 0.0}
|
||||
recall = sum(recall_at_k(r, g, k) for r, g in per_query_ranked) / n
|
||||
mrr = sum(reciprocal_rank(r, g) for r, g in per_query_ranked) / n
|
||||
ndcg = sum(ndcg_at_k(r, g, k) for r, g in per_query_ranked) / n
|
||||
return {"recall@k": recall, "mrr": mrr, "ndcg@k": ndcg}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 流水线:为一条查询产出各方法的文档级排名
|
||||
# ---------------------------------------------------------------------------
|
||||
class Pipeline:
|
||||
def __init__(self, corpus, args):
|
||||
self.args = args
|
||||
self.chunk_ids: List[str] = []
|
||||
self.chunk_texts: List[str] = []
|
||||
self.chunk_to_doc: Dict[str, str] = {}
|
||||
self.doc_text: Dict[str, str] = {}
|
||||
|
||||
# 分块
|
||||
for doc in corpus:
|
||||
self.doc_text[doc["doc_id"]] = doc["text"]
|
||||
chunks = chunk_text(doc["text"], args.chunk_size, args.chunk_overlap)
|
||||
for i, chunk in enumerate(chunks):
|
||||
cid = f"{doc['doc_id']}::c{i}" if len(chunks) > 1 else doc["doc_id"]
|
||||
self.chunk_ids.append(cid)
|
||||
self.chunk_texts.append(chunk)
|
||||
self.chunk_to_doc[cid] = doc["doc_id"]
|
||||
|
||||
self.n_docs = len(corpus)
|
||||
self.n_chunks = len(self.chunk_ids)
|
||||
|
||||
# 稀疏索引
|
||||
self.bm25 = BM25Retriever(self.chunk_ids, self.chunk_texts)
|
||||
|
||||
# 稠密索引(可选)
|
||||
self.dense: Optional[DenseRetriever] = None
|
||||
if args.use_dense:
|
||||
encoder = DenseEncoder(args.embed_model, args.pooling, args.device,
|
||||
query_instruct=args.query_instruct)
|
||||
self.dense = DenseRetriever(encoder, self.chunk_ids, self.chunk_texts)
|
||||
|
||||
# 重排器(可选)
|
||||
self.reranker: Optional[CrossEncoderReranker] = None
|
||||
if args.use_rerank:
|
||||
self.reranker = CrossEncoderReranker(args.reranker_model, args.device)
|
||||
|
||||
def run_query(self, query: str) -> Dict[str, List[Tuple[str, float]]]:
|
||||
"""返回各方法的 doc 级排名 {method: [(doc_id, score)]}。"""
|
||||
top_k = self.args.top_k
|
||||
sparse_started = time.perf_counter()
|
||||
sparse_chunks = self.bm25.search(query, top_k)
|
||||
sparse_docs = chunks_to_docs(sparse_chunks, self.chunk_to_doc)
|
||||
sparse_ms = (time.perf_counter() - sparse_started) * 1000
|
||||
|
||||
out: Dict[str, List[Tuple[str, float]]] = {"sparse": sparse_docs}
|
||||
component_ms = {"sparse": sparse_ms}
|
||||
|
||||
if self.dense is not None:
|
||||
dense_started = time.perf_counter()
|
||||
dense_chunks = self.dense.search(query, top_k)
|
||||
dense_docs = chunks_to_docs(dense_chunks, self.chunk_to_doc)
|
||||
dense_ms = (time.perf_counter() - dense_started) * 1000
|
||||
component_ms["dense"] = dense_ms
|
||||
out["dense"] = dense_docs
|
||||
|
||||
ranked_lists = {"dense": dense_docs, "sparse": sparse_docs}
|
||||
weights = {"dense": self.args.dense_weight, "sparse": self.args.sparse_weight}
|
||||
rrf_started = time.perf_counter()
|
||||
rrf = fuse(ranked_lists, method="rrf", k=self.args.k_rrf, weights=weights)
|
||||
rrf_ms = (time.perf_counter() - rrf_started) * 1000
|
||||
weighted_started = time.perf_counter()
|
||||
weighted = fuse(ranked_lists, method="weighted", weights=weights)
|
||||
weighted_ms = (time.perf_counter() - weighted_started) * 1000
|
||||
component_ms.update({"rrf_fusion": rrf_ms, "weighted_fusion": weighted_ms})
|
||||
out["rrf"] = rrf
|
||||
out["weighted"] = weighted
|
||||
|
||||
if self.reranker is not None:
|
||||
# 对 RRF 融合的候选池 top-N 精排
|
||||
pool = [doc_id for doc_id, _ in rrf[: self.args.rerank_pool]]
|
||||
candidates = [(doc_id, self.doc_text[doc_id]) for doc_id in pool]
|
||||
rerank_started = time.perf_counter()
|
||||
reranked = self.reranker.rerank(query, candidates, self.args.rerank_top_k)
|
||||
component_ms["rerank"] = (time.perf_counter() - rerank_started) * 1000
|
||||
out["rerank"] = reranked
|
||||
|
||||
end_to_end_ms = {"sparse": sparse_ms}
|
||||
if "dense" in component_ms:
|
||||
retrieval_ms = sparse_ms + component_ms["dense"]
|
||||
end_to_end_ms.update(
|
||||
{
|
||||
"dense": component_ms["dense"],
|
||||
"rrf": retrieval_ms + component_ms["rrf_fusion"],
|
||||
"weighted": retrieval_ms + component_ms["weighted_fusion"],
|
||||
}
|
||||
)
|
||||
if "rerank" in component_ms:
|
||||
end_to_end_ms["rerank"] = (
|
||||
retrieval_ms + component_ms["rrf_fusion"] + component_ms["rerank"]
|
||||
)
|
||||
rank_changes = []
|
||||
if "rerank" in out:
|
||||
before = {doc_id: rank for rank, (doc_id, _) in enumerate(out["rrf"], 1)}
|
||||
after = {doc_id: rank for rank, (doc_id, _) in enumerate(out["rerank"], 1)}
|
||||
for doc_id in sorted(set(before) | set(after)):
|
||||
rank_changes.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"rrf_rank": before.get(doc_id),
|
||||
"rerank_rank": after.get(doc_id),
|
||||
"delta": (
|
||||
before[doc_id] - after[doc_id]
|
||||
if doc_id in before and doc_id in after
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
self.last_trace = {
|
||||
"component_latency_ms": component_ms,
|
||||
"end_to_end_latency_ms": end_to_end_ms,
|
||||
"rank_changes": rank_changes,
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 输出:对比表 / 单条查询追踪
|
||||
# ---------------------------------------------------------------------------
|
||||
METHOD_LABELS = [
|
||||
("sparse", "BM25 (sparse)"),
|
||||
("dense", "Dense"),
|
||||
("rrf", "Hybrid-RRF"),
|
||||
("weighted", "Hybrid-Weighted"),
|
||||
("rerank", "Hybrid-RRF+Rerank"),
|
||||
]
|
||||
|
||||
|
||||
def run_evaluation(pipeline: Pipeline, queries, args) -> Dict[str, Any]:
|
||||
k = args.eval_k
|
||||
per_method: Dict[str, List[Tuple[List[str], Sequence[str]]]] = {m: [] for m, _ in METHOD_LABELS}
|
||||
per_query_records = []
|
||||
latency_by_method: Dict[str, List[float]] = {m: [] for m, _ in METHOD_LABELS}
|
||||
|
||||
t0 = time.time()
|
||||
for spec in queries:
|
||||
query = spec["query"]
|
||||
gold = spec.get("expected", [])
|
||||
results = pipeline.run_query(query)
|
||||
record = {
|
||||
"query": query,
|
||||
"expected": gold,
|
||||
"category": spec.get("category", "unspecified"),
|
||||
"methods": {},
|
||||
"trace": pipeline.last_trace,
|
||||
}
|
||||
for method, _ in METHOD_LABELS:
|
||||
if method not in results:
|
||||
continue
|
||||
ranked_ids = [doc_id for doc_id, _ in results[method]]
|
||||
per_method[method].append((ranked_ids, gold))
|
||||
latency_by_method[method].append(pipeline.last_trace["end_to_end_latency_ms"][method])
|
||||
record["methods"][method] = {
|
||||
"top": [{"doc_id": d, "score": round(s, 4)} for d, s in results[method][:5]],
|
||||
"recall@k": round(recall_at_k(ranked_ids, gold, k), 4),
|
||||
"mrr": round(reciprocal_rank(ranked_ids, gold), 4),
|
||||
"ndcg@k": round(ndcg_at_k(ranked_ids, gold, k), 4),
|
||||
}
|
||||
per_query_records.append(record)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
summary = {}
|
||||
for method, _ in METHOD_LABELS:
|
||||
if per_method[method]:
|
||||
summary[method] = aggregate_metrics(per_method[method], k)
|
||||
values = sorted(latency_by_method[method])
|
||||
p95_index = min(len(values) - 1, math.ceil(0.95 * len(values)) - 1)
|
||||
summary[method]["latency_ms"] = {
|
||||
"mean": sum(values) / len(values),
|
||||
"p50": values[len(values) // 2],
|
||||
"p95": values[p95_index],
|
||||
}
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"per_query": per_query_records,
|
||||
"elapsed_sec": round(elapsed, 2),
|
||||
"eval_k": k,
|
||||
}
|
||||
|
||||
|
||||
def print_table(report: Dict[str, Any], pipeline: Pipeline, args) -> None:
|
||||
k = report["eval_k"]
|
||||
print("=" * 78)
|
||||
print("混合检索流水线 · 逐阶段评测对比")
|
||||
print("=" * 78)
|
||||
print(f"语料: {pipeline.n_docs} 篇文档 → {pipeline.n_chunks} 个 chunk "
|
||||
f"(chunk_size={args.chunk_size}, overlap={args.chunk_overlap})")
|
||||
print(f"查询: {len(report['per_query'])} 条 "
|
||||
f"稠密模型: {args.embed_model if args.use_dense else '(禁用)'} "
|
||||
f"重排模型: {args.reranker_model if args.use_rerank else '(禁用)'}")
|
||||
print(f"检索 top_k={args.top_k} 融合 k(RRF)={args.k_rrf} "
|
||||
f"重排候选池={args.rerank_pool} 评测截断 k={k} 设备={args.device}")
|
||||
print(f"耗时: {report['elapsed_sec']}s")
|
||||
print("-" * 78)
|
||||
header = f"{'Stage / Method':<22}{'Recall@'+str(k):>12}{'MRR':>12}{'nDCG@'+str(k):>12}"
|
||||
print(header)
|
||||
print("-" * 78)
|
||||
for method, label in METHOD_LABELS:
|
||||
if method not in report["summary"]:
|
||||
continue
|
||||
m = report["summary"][method]
|
||||
print(f"{label:<22}{m['recall@k']:>12.4f}{m['mrr']:>12.4f}{m['ndcg@k']:>12.4f}")
|
||||
print("-" * 78)
|
||||
print("读表:从上到下逐步加入 稠密检索 / 融合 / 重排 阶段,观察指标的变化。")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
def print_per_query(report: Dict[str, Any]) -> None:
|
||||
"""逐条查询打印各方法的 MRR,直观展示「单路会翻车、融合来兜底」。"""
|
||||
methods = [m for m, _ in METHOD_LABELS]
|
||||
short = {"sparse": "BM25", "dense": "Dense", "rrf": "RRF",
|
||||
"weighted": "Wgt", "rerank": "Rerank"}
|
||||
print("\n逐条查询 MRR 明细(1.00=正确文档排在第 1 位;粗看哪一路在哪类查询上翻车)")
|
||||
print("-" * 78)
|
||||
header = f"{'Query':<42}" + "".join(f"{short[m]:>7}" for m in methods)
|
||||
print(header)
|
||||
print("-" * 78)
|
||||
for rec in report["per_query"]:
|
||||
cells = ""
|
||||
for m in methods:
|
||||
if m in rec["methods"]:
|
||||
cells += f"{rec['methods'][m]['mrr']:>7.2f}"
|
||||
else:
|
||||
cells += f"{'-':>7}"
|
||||
q = rec["query"]
|
||||
q = q if len(q) <= 41 else q[:38] + "..."
|
||||
print(f"{q:<42}{cells}")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
def print_query_trace(pipeline: Pipeline, query: str, args) -> None:
|
||||
results = pipeline.run_query(query)
|
||||
print("=" * 78)
|
||||
print(f"单条查询逐阶段排名追踪 query = {query!r}")
|
||||
print(f"语料 {pipeline.n_docs} 篇 → {pipeline.n_chunks} chunk 设备={args.device}")
|
||||
print("=" * 78)
|
||||
for method, label in METHOD_LABELS:
|
||||
if method not in results:
|
||||
continue
|
||||
print(f"\n[{label}]")
|
||||
for rank, (doc_id, score) in enumerate(results[method][:5], start=1):
|
||||
snippet = pipeline.doc_text.get(doc_id, "")[:60].replace("\n", " ")
|
||||
print(f" {rank}. {doc_id:<14} score={score:8.4f} {snippet}")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
def detect_device(requested: str) -> str:
|
||||
if requested != "auto":
|
||||
return requested
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def load_json(path: str):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="混合检索流水线离线评测 CLI(chunk→embed→retrieve→fuse→rerank,逐阶段对比)。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python evaluate.py # 内置评测集,完整对比表\n"
|
||||
" python evaluate.py --no-rerank # 跳过重排阶段\n"
|
||||
" python evaluate.py --no-dense # 仅 BM25(纯离线、无需模型)\n"
|
||||
" python evaluate.py --query '怎样提升检索精度' # 单条查询逐阶段排名\n"
|
||||
" python evaluate.py --embed-model BAAI/bge-m3 --pooling cls\n"
|
||||
" python evaluate.py --output result.json # 结果同时写入 JSON\n"
|
||||
),
|
||||
)
|
||||
data = parser.add_argument_group("数据")
|
||||
data.add_argument("--corpus", help="语料 JSON 文件,格式 [{'doc_id','text'}...];缺省用内置语料")
|
||||
data.add_argument("--queries", help="查询 JSON 文件,格式 [{'query','expected':[...]}...];缺省用内置查询")
|
||||
data.add_argument("--query", help="单条查询模式:只对该查询做逐阶段排名追踪,不跑评测")
|
||||
data.add_argument("--limit-queries", type=int, default=0, help="只评测前 N 条查询(0=全部)")
|
||||
|
||||
stages = parser.add_argument_group("流水线阶段")
|
||||
stages.add_argument("--no-dense", dest="use_dense", action="store_false",
|
||||
help="禁用稠密检索(连带禁用融合与重排;退化为纯 BM25,完全离线无需模型)")
|
||||
stages.add_argument("--no-rerank", dest="use_rerank", action="store_false",
|
||||
help="禁用神经重排阶段")
|
||||
stages.set_defaults(use_dense=True, use_rerank=True)
|
||||
|
||||
chunk = parser.add_argument_group("分块")
|
||||
chunk.add_argument("--chunk-size", type=int, default=280, help="每个 chunk 的最大字符数(默认 280)")
|
||||
chunk.add_argument("--chunk-overlap", type=int, default=40, help="相邻 chunk 的重叠字符数(默认 40)")
|
||||
|
||||
retr = parser.add_argument_group("检索与融合")
|
||||
retr.add_argument("--top-k", type=int, default=10, help="每路检索召回的候选数(默认 10)")
|
||||
retr.add_argument("--k-rrf", type=int, default=60, help="RRF 平滑常数 k(默认 60)")
|
||||
retr.add_argument("--dense-weight", type=float, default=1.0, help="融合时稠密路权重(默认 1.0)")
|
||||
retr.add_argument("--sparse-weight", type=float, default=1.0, help="融合时稀疏路权重(默认 1.0)")
|
||||
|
||||
rer = parser.add_argument_group("重排")
|
||||
rer.add_argument("--rerank-pool", type=int, default=10, help="送入重排的候选池大小(取 RRF 融合的 top-N,默认 10)")
|
||||
rer.add_argument("--rerank-top-k", type=int, default=10, help="重排后返回的结果数(默认 10)")
|
||||
|
||||
model = parser.add_argument_group("模型")
|
||||
model.add_argument("--embed-model", default="sentence-transformers/all-MiniLM-L6-v2",
|
||||
help="稠密句向量模型(默认 sentence-transformers/all-MiniLM-L6-v2,约 90MB、英文为主;"
|
||||
"多语言语料请换 Qwen/Qwen3-Embedding-0.6B 或 BAAI/bge-m3)")
|
||||
model.add_argument("--pooling", default="auto", choices=["auto", "mean", "cls", "last"],
|
||||
help="句向量池化方式(auto 会按模型名自动选择:qwen→last, bge-m3→cls, 其余→mean)")
|
||||
model.add_argument("--query-instruct",
|
||||
default="Given a search query, retrieve relevant passages that answer the query",
|
||||
help="指令式检索模型的查询侧任务指令(仅对 last-token 池化的模型如 Qwen3-Embedding 生效)")
|
||||
model.add_argument("--reranker-model", default="BAAI/bge-reranker-base",
|
||||
help="交叉编码器重排模型(默认 BAAI/bge-reranker-base,多语言、首次运行约 1.1GB;"
|
||||
"生产可换更强的 BAAI/bge-reranker-v2-m3,轻量可换 cross-encoder/ms-marco-MiniLM-L-6-v2)")
|
||||
model.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda", "mps"],
|
||||
help="推理设备(默认 auto)")
|
||||
|
||||
out = parser.add_argument_group("评测与输出")
|
||||
out.add_argument("--eval-k", type=int, default=3, help="指标截断位置 k(Recall@k / nDCG@k,默认 3)")
|
||||
out.add_argument("--no-per-query", dest="show_per_query", action="store_false",
|
||||
help="不打印逐条查询的 MRR 明细矩阵")
|
||||
out.set_defaults(show_per_query=True)
|
||||
out.add_argument("--output", help="把完整结果(含每条查询明细)写入该 JSON 文件")
|
||||
out.add_argument("--offline", action="store_true", help="设置 HF_HUB_OFFLINE=1,强制只用本地缓存模型")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
|
||||
if args.offline:
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
|
||||
args.device = detect_device(args.device)
|
||||
# 单查询追踪模式不影响 use_dense/use_rerank 语义,但重排依赖稠密融合池
|
||||
if not args.use_dense:
|
||||
args.use_rerank = False
|
||||
|
||||
corpus = load_json(args.corpus) if args.corpus else DEFAULT_CORPUS
|
||||
queries = load_json(args.queries) if args.queries else DEFAULT_QUERIES
|
||||
|
||||
try:
|
||||
pipeline = Pipeline(corpus, args)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[错误] 流水线初始化失败: {exc}", file=sys.stderr)
|
||||
print("提示:稠密/重排阶段需要本地句向量与交叉编码器模型;"
|
||||
"可用 --no-dense 退化为纯 BM25(完全离线),或用 --embed-model 指定已缓存模型。",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.query:
|
||||
print_query_trace(pipeline, args.query, args)
|
||||
return 0
|
||||
|
||||
if args.limit_queries > 0:
|
||||
queries = queries[: args.limit_queries]
|
||||
|
||||
report = run_evaluation(pipeline, queries, args)
|
||||
print_table(report, pipeline, args)
|
||||
if args.show_per_query:
|
||||
print_per_query(report)
|
||||
|
||||
if args.output:
|
||||
payload = {
|
||||
"config": {
|
||||
"embed_model": args.embed_model if args.use_dense else None,
|
||||
"reranker_model": args.reranker_model if args.use_rerank else None,
|
||||
"top_k": args.top_k, "k_rrf": args.k_rrf, "eval_k": args.eval_k,
|
||||
"chunk_size": args.chunk_size, "chunk_overlap": args.chunk_overlap,
|
||||
"device": args.device,
|
||||
},
|
||||
**report,
|
||||
}
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n结果已写入 {args.output}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user