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
246 lines
9.5 KiB
Python
246 lines
9.5 KiB
Python
"""离线对比:上下文化记忆块 vs 原始记忆块对『用户事实召回』的影响(实验 3-11)。
|
||
|
||
本模块是一个**完全离线、无需 API/外部服务**的对照实验,用于量化本章核心论点:
|
||
在把对话记忆块送入索引/嵌入之前,先为每个块生成一段『上下文前缀』(情境锚定),
|
||
能显著提升脱离上下文的孤立片段(如『好的,就订这个吧』)被正确召回的概率。
|
||
|
||
方法说明(诚实边界):
|
||
- 生产管线用 LLM 逐块生成 context、并用神经嵌入 + 检索服务做稠密/混合检索(需 API Key)。
|
||
- 这里用**确定性的 BM25 词法检索**作为无需 API 的代理:对同一份 context,
|
||
分别度量『不拼接(plain)』与『拼接后再索引(contextual)』两种方式的召回。
|
||
变量只有『索引文本是否含 context 前缀』,因此结果直接反映上下文化本身的贡献。
|
||
- 指标:Recall@1、Recall@3、MRR(口径同书中 recall@k:前 k 个结果命中即算召回)。
|
||
|
||
数据集见同目录 memory_qa_eval.json(受控教学集,可自行替换 --dataset)。
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import math
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Dict, List, Tuple
|
||
|
||
DEFAULT_DATASET = str(Path(__file__).parent / "memory_qa_eval.json")
|
||
|
||
_CJK = r"一-鿿"
|
||
|
||
|
||
def tokenize(text: str) -> List[str]:
|
||
"""CJK 感知的轻量分词:英文/数字按词,中文按『单字 + 相邻双字』。
|
||
|
||
对相邻双字(bigram)保留位置邻接关系,避免跨片段产生虚假二元组。
|
||
"""
|
||
text = text.lower()
|
||
tokens: List[str] = []
|
||
tokens.extend(re.findall(r"[a-z0-9]+", text))
|
||
for run in re.findall(f"[{_CJK}]+", text):
|
||
chars = list(run)
|
||
tokens.extend(chars)
|
||
for i in range(len(chars) - 1):
|
||
tokens.append(chars[i] + chars[i + 1])
|
||
return tokens
|
||
|
||
|
||
class BM25:
|
||
"""标准 BM25,纯 Python 实现,无第三方依赖。"""
|
||
|
||
def __init__(self, corpus_tokens: List[List[str]], k1: float = 1.5, b: float = 0.75):
|
||
self.k1 = k1
|
||
self.b = b
|
||
self.corpus = corpus_tokens
|
||
self.N = len(corpus_tokens)
|
||
self.doc_len = [len(d) for d in corpus_tokens]
|
||
self.avgdl = sum(self.doc_len) / self.N if self.N else 0.0
|
||
self.tf: List[Dict[str, int]] = []
|
||
df: Dict[str, int] = {}
|
||
for doc in corpus_tokens:
|
||
counts: Dict[str, int] = {}
|
||
for t in doc:
|
||
counts[t] = counts.get(t, 0) + 1
|
||
self.tf.append(counts)
|
||
for t in counts:
|
||
df[t] = df.get(t, 0) + 1
|
||
self.idf = {
|
||
t: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for t, n in df.items()
|
||
}
|
||
|
||
def score(self, query_tokens: List[str], idx: int) -> float:
|
||
counts = self.tf[idx]
|
||
dl = self.doc_len[idx]
|
||
s = 0.0
|
||
for t in query_tokens:
|
||
if t not in counts:
|
||
continue
|
||
idf = self.idf.get(t, 0.0)
|
||
freq = counts[t]
|
||
denom = freq + self.k1 * (1 - self.b + self.b * dl / self.avgdl)
|
||
s += idf * (freq * (self.k1 + 1)) / denom
|
||
return s
|
||
|
||
def rank(self, query_tokens: List[str]) -> List[int]:
|
||
scored = [(self.score(query_tokens, i), i) for i in range(self.N)]
|
||
# 稳定排序:分数降序,平局按原始下标升序
|
||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||
return [i for _, i in scored]
|
||
|
||
|
||
def _gold_rank(ranked_ids: List[str], gold_id: str) -> int:
|
||
"""返回 gold 在排序结果中的名次(1-based);未找到返回 0。"""
|
||
for pos, cid in enumerate(ranked_ids, start=1):
|
||
if cid == gold_id:
|
||
return pos
|
||
return 0
|
||
|
||
|
||
def evaluate(chunks: List[dict], queries: List[dict], mode: str) -> Tuple[dict, List[dict]]:
|
||
"""按指定模式索引并检索,返回聚合指标与逐条明细。
|
||
|
||
mode='plain' 索引文本 = chunk['text']
|
||
mode='contextual' 索引文本 = chunk['context'] + '\n' + chunk['text']
|
||
"""
|
||
ids = [c["id"] for c in chunks]
|
||
if mode == "plain":
|
||
docs = [c["text"] for c in chunks]
|
||
elif mode == "contextual":
|
||
docs = [f"{c.get('context','')}\n{c['text']}" for c in chunks]
|
||
else:
|
||
raise ValueError(f"unknown mode: {mode}")
|
||
|
||
bm25 = BM25([tokenize(d) for d in docs])
|
||
|
||
r1 = r3 = 0
|
||
mrr = 0.0
|
||
details = []
|
||
for q in queries:
|
||
ranked = [ids[i] for i in bm25.rank(tokenize(q["q"]))]
|
||
rank = _gold_rank(ranked, q["gold"])
|
||
hit1 = 1 if rank == 1 else 0
|
||
hit3 = 1 if 1 <= rank <= 3 else 0
|
||
r1 += hit1
|
||
r3 += hit3
|
||
mrr += (1.0 / rank) if rank else 0.0
|
||
details.append({"q": q["q"], "gold": q["gold"], "rank": rank,
|
||
"hit@1": hit1, "hit@3": hit3, "top": ranked[:3]})
|
||
|
||
n = len(queries)
|
||
metrics = {
|
||
"recall@1": r1 / n,
|
||
"recall@3": r3 / n,
|
||
"mrr": mrr / n,
|
||
"n_queries": n,
|
||
}
|
||
return metrics, details
|
||
|
||
|
||
def run_comparison(dataset_path: str, output_path: str = None, verbose: bool = True) -> dict:
|
||
data = json.loads(Path(dataset_path).read_text(encoding="utf-8"))
|
||
chunks, queries = data["chunks"], data["queries"]
|
||
|
||
plain_m, plain_d = evaluate(chunks, queries, "plain")
|
||
ctx_m, ctx_d = evaluate(chunks, queries, "contextual")
|
||
|
||
if verbose:
|
||
print("=" * 68)
|
||
print("实验 3-11 · 上下文化记忆块对用户事实召回的影响(离线 BM25 代理)")
|
||
print(f"数据集: {dataset_path}")
|
||
print(f"记忆块: {len(chunks)} 查询: {len(queries)}")
|
||
print("=" * 68)
|
||
print(f"{'方法':<28}{'Recall@1':>10}{'Recall@3':>10}{'MRR':>10}")
|
||
print("-" * 68)
|
||
print(f"{'Plain(直接索引原始块)':<24}{plain_m['recall@1']:>10.3f}"
|
||
f"{plain_m['recall@3']:>10.3f}{plain_m['mrr']:>10.3f}")
|
||
print(f"{'Contextual(上下文化后索引)':<22}{ctx_m['recall@1']:>10.3f}"
|
||
f"{ctx_m['recall@3']:>10.3f}{ctx_m['mrr']:>10.3f}")
|
||
print("-" * 68)
|
||
d1 = ctx_m["recall@1"] - plain_m["recall@1"]
|
||
d3 = ctx_m["recall@3"] - plain_m["recall@3"]
|
||
dm = ctx_m["mrr"] - plain_m["mrr"]
|
||
print(f"{'提升(Δ)':<26}{d1:>+10.3f}{d3:>+10.3f}{dm:>+10.3f}")
|
||
print("=" * 68)
|
||
print("\n逐查询名次(gold 在检索结果中的位次,越小越好;0 表示未召回):")
|
||
print(f"{'查询':<38}{'Plain':>8}{'Ctx':>8}")
|
||
print("-" * 68)
|
||
pd = {x["q"]: x["rank"] for x in plain_d}
|
||
for x in ctx_d:
|
||
q = x["q"][:36]
|
||
print(f"{q:<38}{pd[x['q']]:>8}{x['rank']:>8}")
|
||
print("=" * 68)
|
||
print("说明:孤立片段(如『好的,就订这个吧』)在 Plain 下缺乏可检索信号,")
|
||
print("上下文化后被『锚定』回其情境,因而召回名次明显提升。")
|
||
|
||
result = {
|
||
"dataset": dataset_path,
|
||
"n_chunks": len(chunks),
|
||
"plain": plain_m,
|
||
"contextual": ctx_m,
|
||
"delta": {
|
||
"recall@1": ctx_m["recall@1"] - plain_m["recall@1"],
|
||
"recall@3": ctx_m["recall@3"] - plain_m["recall@3"],
|
||
"mrr": ctx_m["mrr"] - plain_m["mrr"],
|
||
},
|
||
"plain_details": plain_d,
|
||
"contextual_details": ctx_d,
|
||
}
|
||
|
||
if output_path:
|
||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(output_path).write_text(json.dumps(result, ensure_ascii=False, indent=2),
|
||
encoding="utf-8")
|
||
if verbose:
|
||
print(f"\n结果已保存至: {output_path}")
|
||
|
||
return result
|
||
|
||
|
||
def single_query(dataset_path: str, query: str, top_k: int = 3, verbose: bool = True) -> dict:
|
||
"""针对单条查询,离线对比 plain 与 contextual 两种索引下的 Top-K 检索结果。"""
|
||
data = json.loads(Path(dataset_path).read_text(encoding="utf-8"))
|
||
chunks = data["chunks"]
|
||
ids = [c["id"] for c in chunks]
|
||
qt = tokenize(query)
|
||
|
||
out = {"query": query}
|
||
for mode in ("plain", "contextual"):
|
||
if mode == "plain":
|
||
docs = [c["text"] for c in chunks]
|
||
else:
|
||
docs = [f"{c.get('context','')}\n{c['text']}" for c in chunks]
|
||
bm25 = BM25([tokenize(d) for d in docs])
|
||
ranked = bm25.rank(qt)
|
||
out[mode] = [{"id": ids[i], "score": round(bm25.score(qt, i), 4)}
|
||
for i in ranked[:top_k]]
|
||
|
||
if verbose:
|
||
print("=" * 60)
|
||
print(f"查询: {query}")
|
||
print("=" * 60)
|
||
for mode in ("plain", "contextual"):
|
||
label = "Plain(原始块)" if mode == "plain" else "Contextual(上下文化)"
|
||
print(f"\n[{label}] Top-{top_k}:")
|
||
for rank, item in enumerate(out[mode], 1):
|
||
print(f" {rank}. {item['id']:<18} score={item['score']}")
|
||
print("=" * 60)
|
||
return out
|
||
|
||
|
||
def build_arg_parser() -> argparse.ArgumentParser:
|
||
p = argparse.ArgumentParser(
|
||
description="离线对比上下文化记忆块 vs 原始记忆块的用户事实召回(实验 3-11,无需 API)",
|
||
)
|
||
p.add_argument("--dataset", default=DEFAULT_DATASET,
|
||
help="记忆问答对照集 JSON 路径(默认:memory_qa_eval.json)")
|
||
p.add_argument("--output", default=None,
|
||
help="将对比结果(含逐查询明细)保存为 JSON 的路径(默认不保存)")
|
||
p.add_argument("--quiet", action="store_true", help="仅输出结果 JSON,不打印表格")
|
||
return p
|
||
|
||
|
||
def main():
|
||
args = build_arg_parser().parse_args()
|
||
run_comparison(args.dataset, output_path=args.output, verbose=not args.quiet)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|