解决方案
RAG 评估与回归

RAG 检索评测与回归门禁:hit@k、MRR 与逐题保护实测

改了切分参数、换了向量维度或模型之后,“检索变好了还是变差了”这个问题,靠手工问几个问题是答不准的。本页写给已经有一套 RAG 应用、想给检索环节加上回归保护的开发者:用一个中文小评测集,把 hit@k、MRR 怎么算、门禁怎么写、评测集多小会骗人,都用可复现的脚本跑一遍。

本页只评估检索(正确的段落有没有被找回来)。生成质量、忠实度、LLM 当裁判这些环节不在范围内,它们依赖真实模型,输出不稳定,没法做成确定性实验;官方的 LangSmith 评估 RAG 应用教程 (opens in a new tab)介绍了答案正确性、相关性和依据性(groundedness)等评估方式,本页没有验证其内容。

适用版本与验证信息

项目内容
验证日期2026-10-25
Python3.12.12
包版本langchain-core==1.7.0、langchain-text-splitters==1.1.3、numpy==2.5.4(环境 v1c)
嵌入模型本地确定性的“哈希字符 n-gram 向量”,不是真实的语义嵌入模型,不联网、不需要 API Key
评测数据虚构的网盘产品客服知识库:12 篇文档、20 道题,仅用于演示流程
官方来源构建语义搜索引擎(knowledge-base) (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16)
实验代码站点仓库 labs/rag-evaluation-regression/,依赖锁定在 labs/requirements/v1c.txt

因为嵌入是词面级别的,具体的 hit@k 数值只对这个实验有意义,不能当作任何真实模型或切分参数的排名。本页要传达的是评测方法:指标怎么定义、门禁怎么设计、结论的可信度怎么估计。把 HashingEmbeddings 换成真实的 Embeddings 实现后,其余代码不用动,但输出不再与机器无关,此时应让基线和候选在同一模型版本下比较。

先说结论

  1. 评测集必须包含“改写问法”。 只用和答案段落共用关键词的“直接问法”,基线的 hit@3 是 1.00;加入改写问法后,整体降到 0.80,改写部分只有 0.60。只测直接问法会让指标虚高,且对检索变差不敏感。
  2. 汇总指标会掩盖逐题回归。 把切分长度从 200 降到 40,整体 hit@3 完全不变(0.80),但有 2 道题从“能答”变成“答不出”,另外 2 道恰好反过来。
  3. 门禁至少要有两条: 汇总不下降,并且基线里答对的每一题仍然答对(受保护题目)。只看汇总,上面这次变更会被放行。
  4. 20 道题的噪声很大。 一题的翻转就是 5 个百分点,基线 hit@3 的 95% 区间是 [0.60, 0.95];hit@3 从 0.80 到 0.85 的差异,其配对区间 [-0.10, +0.20] 包含 0。小评测集适合做回归防护,不适合用来证明“哪个更好”。
  5. 基线要绑定数据集指纹。 评测集一变,旧基线就不可比,门禁应当直接拒绝而不是给出一个误导性的结论。

评测集与检索器

先看评测数据。QUESTIONS 里每道题有一个标准答案文档(gold)和类型:direct 复用答案段落的关键词,paraphrase 刻意避开:

dataset.py
"""Synthetic support knowledge base and evaluation questions (fictional product, demo data only)."""
 
DOCS = {
    "d01-refund": "购买后七天内且云端存储使用量不超过 5GB 的个人套餐,可以在“订单”页面申请全额退款。退款会原路退回支付账户,通常在三个工作日内到账。企业套餐不适用七天无理由退款。",
    "d02-invoice": "发票可在“订单详情”中申请,支持电子普通发票和增值税专用发票。开票信息提交后一至三个工作日内开具,电子发票会发送到账号绑定的邮箱。已开具的发票不能修改抬头,需要红冲后重新开具。",
    "d03-upgrade": "个人套餐可随时升级到更大的容量,升级后立即生效,差价按剩余天数折算。容量降级只能在当前订阅到期后进行,且已用空间超过新容量时需要先删除多余文件。",
    "d04-share-link": "分享链接默认有效期为七天,可以设置为一天、七天、三十天或永久,也可以加提取码。链接创建者随时可以在“我的分享”里取消分享,取消后已经发出的链接立即失效。",
    "d05-trash": "删除的文件会先进入回收站并保留三十天,期间可以一键还原。超过三十天后文件被永久清除且无法恢复。清空回收站会立即永久删除其中所有文件。",
    "d06-login": "忘记密码时,可以在登录页点击“找回密码”,通过绑定的手机号或邮箱接收验证码重置。连续输入错误密码五次后,账号会被锁定三十分钟。",
    "d07-sync": "桌面客户端会监听同步文件夹的变动,修改后在几秒内上传到云端,并同步到登录同一账号的其他设备。两台设备同时修改同一个文件时,会保留两个版本并在文件名后加上设备名。",
    "d08-security": "所有文件在传输过程中使用 TLS 加密,存储时使用服务端加密。账号支持开启两步验证,开启后新设备登录需要额外输入动态验证码。",
    "d09-team-roles": "团队空间的成员分为所有者、管理员、编辑者和只读成员四种角色。只有所有者和管理员可以邀请新成员或修改他人角色,只读成员只能查看和下载文件。",
    "d10-export": "需要迁出全部数据时,可以在“设置”里申请数据导出,系统会打包所有文件并生成下载链接,下载链接七天内有效。体积较大时会拆分成多个压缩包。",
    "d11-close-account": "账号注销前需要先清空或转移团队空间的所有权,并确认没有未完成的订单。注销提交后有七天冷静期,期间重新登录即可取消注销,冷静期结束后账号和数据将被永久删除。",
    "d12-versions": "每个文件会保留最近三十个历史版本,可以在文件详情页的“版本历史”里预览并恢复到任意版本。企业套餐的版本保留数量可以由管理员调整。",
}
 
# kind: "direct" reuses the key terms of the answer passage; "paraphrase" avoids them.
QUESTIONS = [
    ("个人套餐七天内可以申请全额退款吗", "d01-refund", "direct"),
    ("增值税专用发票在哪里申请", "d02-invoice", "direct"),
    ("升级容量后差价怎么算", "d03-upgrade", "direct"),
    ("分享链接的默认有效期是多久", "d04-share-link", "direct"),
    ("回收站里的文件保留多少天", "d05-trash", "direct"),
    ("连续输错密码会被锁定多久", "d06-login", "direct"),
    ("桌面客户端修改文件后多久同步到云端", "d07-sync", "direct"),
    ("开启两步验证后新设备登录要做什么", "d08-security", "direct"),
    ("团队空间有哪几种成员角色", "d09-team-roles", "direct"),
    ("数据导出的下载链接多久有效", "d10-export", "direct"),
    ("买了没用上想把钱要回来怎么办", "d01-refund", "paraphrase"),
    ("公司报销需要带税号的单据", "d02-invoice", "paraphrase"),
    ("硬盘空间不够了想买大一点的", "d03-upgrade", "paraphrase"),
    ("不想让别人继续访问我发出去的地址", "d04-share-link", "paraphrase"),
    ("误删了东西还能找回来吗", "d05-trash", "paraphrase"),
    ("我忘了账号口令该怎么办", "d06-login", "paraphrase"),
    ("两个电脑同时改了一份文档会怎样", "d07-sync", "paraphrase"),
    ("想把我所有的资料搬到别的平台", "d10-export", "paraphrase"),
    ("我不想再用这个服务了怎么删号", "d11-close-account", "paraphrase"),
    ("文件被改坏了想退回之前的样子", "d12-versions", "paraphrase"),
]

嵌入用哈希后的字符一元、二元组计数向量,纯本地计算,在任何机器上输出相同:

hashing_embeddings.py
"""Deterministic local embeddings: hashed character 1-gram and 2-gram counts.
 
This is a lexical stand-in for a real embedding model. It needs no network or API key and
returns identical vectors on every machine, which is what a regression experiment requires.
"""
import zlib
 
import numpy as np
from langchain_core.embeddings import Embeddings
 
 
class HashingEmbeddings(Embeddings):
    def __init__(self, dim: int = 512):
        if dim < 8:
            raise ValueError("dim must be at least 8")
        self.dim = dim
 
    def _embed(self, text: str) -> list[float]:
        vec = np.zeros(self.dim, dtype=np.float64)
        chars = [c for c in text if not c.isspace()]
        grams = chars + ["".join(chars[i : i + 2]) for i in range(len(chars) - 1)]
        for gram in grams:
            h = zlib.crc32(gram.encode("utf-8"))
            vec[h % self.dim] += 1.0 if (h >> 16) & 1 else -1.0
        norm = np.linalg.norm(vec)
        return (vec / norm).tolist() if norm else vec.tolist()
 
    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        return [self._embed(t) for t in texts]
 
    def embed_query(self, text: str) -> list[float]:
        return self._embed(text)

检索器用 RecursiveCharacterTextSplitter 切分,放进 InMemoryVectorStore。评测按文档 id 去重后取前 k 个,这样“同一篇文档的多个段落挤满前 3 名”不会被算成多次命中。hit_at 是 top-k 内命中的题目比例,mrr 是标准答案排名倒数的平均值:

retrieval.py
"""Build a retriever over DOCS and evaluate it with hit@k and MRR."""
from dataclasses import dataclass
 
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
from dataset import DOCS, QUESTIONS
from hashing_embeddings import HashingEmbeddings
 
 
@dataclass(frozen=True)
class Config:
    chunk_size: int = 200
    chunk_overlap: int = 0
    dim: int = 512
    k: int = 3
 
 
def build_search(cfg: Config):
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=cfg.chunk_size,
        chunk_overlap=cfg.chunk_overlap,
        separators=["\n\n", "\n", "。", ",", ""],
    )
    chunks = []
    for doc_id, text in DOCS.items():
        for piece in splitter.split_text(text):
            chunks.append(Document(page_content=piece, metadata={"doc_id": doc_id}))
    store = InMemoryVectorStore(HashingEmbeddings(cfg.dim))
    store.add_documents(chunks)
 
    def search(question: str) -> list[str]:
        """Return distinct doc ids in rank order, using up to 4*k chunks."""
        hits = store.similarity_search(question, k=cfg.k * 4)
        ranked: list[str] = []
        for hit in hits:
            doc_id = hit.metadata["doc_id"]
            if doc_id not in ranked:
                ranked.append(doc_id)
        return ranked[: cfg.k]
 
    search.n_chunks = len(chunks)
    return search
 
 
def evaluate(cfg: Config, questions=QUESTIONS) -> list[dict]:
    search = build_search(cfg)
    rows = []
    for question, gold, kind in questions:
        ranked = search(question)
        rank = ranked.index(gold) + 1 if gold in ranked else None
        rows.append({"question": question, "gold": gold, "kind": kind, "rank": rank})
    return rows
 
 
def hit_at(rows: list[dict], k: int) -> float:
    return sum(1 for r in rows if r["rank"] is not None and r["rank"] <= k) / len(rows)
 
 
def mrr(rows: list[dict]) -> float:
    return sum(1 / r["rank"] for r in rows if r["rank"]) / len(rows)

文本切分器来自 langchain_text_splitters。从旧版 langchain.text_splitter 迁移的对应关系见 LangChain v1 迁移 Runbook。

实验 1:基线指标,以及“直接问法”为什么不能单独用

evaluate_baseline.py
"""Evaluate the baseline retriever: overall metrics, split by question kind, and the misses."""
from retrieval import Config, build_search, evaluate, hit_at, mrr
 
cfg = Config()
rows = evaluate(cfg)
print(f"config: {cfg}")
print(f"chunks: {build_search(cfg).n_chunks}, questions: {len(rows)}")
 
 
def summary(label: str, subset: list[dict]) -> None:
    print(f"{label:<11} n={len(subset):>2}  hit@1={hit_at(subset, 1):.2f}  hit@3={hit_at(subset, 3):.2f}  MRR={mrr(subset):.3f}")
 
 
summary("all", rows)
summary("direct", [r for r in rows if r["kind"] == "direct"])
summary("paraphrase", [r for r in rows if r["kind"] == "paraphrase"])
 
print("misses at k=3:")
for r in rows:
    if r["rank"] is None or r["rank"] > 3:
        print(f"  [{r['kind']}] {r['question']} -> expected {r['gold']}")
config: Config(chunk_size=200, chunk_overlap=0, dim=512, k=3)
chunks: 12, questions: 20
all         n=20  hit@1=0.60  hit@3=0.80  MRR=0.683
direct      n=10  hit@1=1.00  hit@3=1.00  MRR=1.000
paraphrase  n=10  hit@1=0.20  hit@3=0.60  MRR=0.367
misses at k=3:
  [paraphrase] 公司报销需要带税号的单据 -> expected d02-invoice
  [paraphrase] 不想让别人继续访问我发出去的地址 -> expected d04-share-link
  [paraphrase] 误删了东西还能找回来吗 -> expected d05-trash
  [paraphrase] 想把我所有的资料搬到别的平台 -> expected d10-export

直接问法 10 题全部排第一(hit@1、hit@3、MRR 都是满分),改写问法只有 hit@1=0.20、hit@3=0.60。如果评测集里只有直接问法,这个检索器会得到满分,任何让改写问法变差的改动都不会被发现。用户真实的提问更接近改写问法,评测集的构成要反映这一点。末尾列出的是 4 道没有进入前 3 名的题,它们是下一轮优化的线索(例如引入同义词扩展、混合检索或更好的嵌入模型),本页不验证这些方案。

实验 2:汇总指标会掩盖逐题回归

先记录基线(每道题的排名都会写进 baseline.json,同时记录评测集的指纹):

record_baseline.py
"""Record the baseline results that regression_gate.py compares against (writes baseline.json)."""
import json
from pathlib import Path
 
from regression_gate import dataset_fingerprint
from retrieval import Config, evaluate, hit_at
 
cfg = Config()
rows = evaluate(cfg)
payload = {
    "config": cfg.__dict__,
    "dataset": dataset_fingerprint(),
    "hit_at_3": hit_at(rows, 3),
    "questions": [{"question": r["question"], "rank": r["rank"]} for r in rows],
}
Path(__file__).with_name("baseline.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"recorded baseline: hit@3={payload['hit_at_3']:.2f} over {len(rows)} questions")

门禁脚本把候选配置和基线逐题对比。规则有两条:整体 hit@3 不能下降;基线里排在前 3 的题,候选里仍必须在前 3。退出码 0 表示通过,1 表示失败,2 表示基线与当前数据集对不上:

regression_gate.py
"""Compare a candidate retriever configuration with the recorded baseline.
 
usage: regression_gate.py <candidate>
Two rules decide the verdict:
  1. aggregate: hit@3 must not drop below the baseline;
  2. protected questions: every question the baseline answered within the top 3 must still be.
Exit code 0 = pass, 1 = fail, 2 = the recorded baseline does not match the current dataset.
"""
import hashlib
import json
import sys
from pathlib import Path
 
from dataset import DOCS, QUESTIONS
from retrieval import Config, evaluate, hit_at, mrr
 
CANDIDATES = {
    "same": Config(),
    "chunk40": Config(chunk_size=40),
    "dim64": Config(dim=64),
    "dim1024": Config(dim=1024),
}
BASELINE_FILE = Path(__file__).with_name("baseline.json")
K = 3
 
 
def dataset_fingerprint() -> str:
    payload = json.dumps([DOCS, QUESTIONS], ensure_ascii=False, sort_keys=True)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
 
 
def main(name: str) -> int:
    if name not in CANDIDATES:
        print(f"unknown candidate {name!r}; choose from {sorted(CANDIDATES)}")
        return 2
    baseline = json.loads(BASELINE_FILE.read_text(encoding="utf-8"))
    if baseline["dataset"] != dataset_fingerprint():
        print("baseline is stale: the dataset changed; re-record it with record_baseline.py")
        return 2
 
    cand_rows = evaluate(CANDIDATES[name])
    base_rank = {q["question"]: q["rank"] for q in baseline["questions"]}
    base_hit3 = baseline["hit_at_3"]
    cand_hit3 = hit_at(cand_rows, K)
 
    print(f"candidate {name}: {CANDIDATES[name]}")
    print(f"baseline hit@3={base_hit3:.2f}  candidate hit@3={cand_hit3:.2f}  candidate MRR={mrr(cand_rows):.3f}")
 
    broken = [r for r in cand_rows if (base_rank[r["question"]] or 99) <= K and (r["rank"] or 99) > K]
    fixed = [r for r in cand_rows if (base_rank[r["question"]] or 99) > K and (r["rank"] or 99) <= K]
    for r in broken:
        print(f"  regressed: {r['question']} (rank {base_rank[r['question']]} -> {r['rank']})")
    for r in fixed:
        print(f"  improved:  {r['question']} (rank {base_rank[r['question']]} -> {r['rank']})")
 
    aggregate_ok = cand_hit3 >= base_hit3
    protected_ok = not broken
    print(f"aggregate gate: {'PASS' if aggregate_ok else 'FAIL'}")
    print(f"protected-question gate: {'PASS' if protected_ok else 'FAIL'}")
    verdict = aggregate_ok and protected_ok
    print(f"verdict: {'PASS' if verdict else 'FAIL'}")
    return 0 if verdict else 1
 
 
if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "same"))

四个候选配置的结果。第一个是“同一配置再跑一次”,用来确认门禁本身是确定的:

candidate same: Config(chunk_size=200, chunk_overlap=0, dim=512, k=3)
baseline hit@3=0.80  candidate hit@3=0.80  candidate MRR=0.683
aggregate gate: PASS
protected-question gate: PASS
verdict: PASS

第二个把 chunk_size 从 200 降到 40。整体 hit@3 不变、MRR 还略有提高,汇总门禁放行;但 2 道基线答对的题答不出了:

candidate chunk40: Config(chunk_size=40, chunk_overlap=0, dim=512, k=3)
baseline hit@3=0.80  candidate hit@3=0.80  candidate MRR=0.708
  regressed: 硬盘空间不够了想买大一点的 (rank 3 -> None)
  regressed: 我忘了账号口令该怎么办 (rank 1 -> None)
  improved:  不想让别人继续访问我发出去的地址 (rank None -> 1)
  improved:  误删了东西还能找回来吗 (rank None -> 3)
aggregate gate: PASS
protected-question gate: FAIL
verdict: FAIL

第三个把向量维度降到 64(模拟换成更小的嵌入)。汇总和逐题两条门禁都失败:

candidate dim64: Config(chunk_size=200, chunk_overlap=0, dim=64, k=3)
baseline hit@3=0.80  candidate hit@3=0.70  candidate MRR=0.625
  regressed: 买了没用上想把钱要回来怎么办 (rank 2 -> None)
  regressed: 硬盘空间不够了想买大一点的 (rank 3 -> None)
  regressed: 我忘了账号口令该怎么办 (rank 1 -> None)
  improved:  公司报销需要带税号的单据 (rank None -> 2)
aggregate gate: FAIL
protected-question gate: FAIL
verdict: FAIL

第四个把维度升到 1024,整体 hit@3 反而升到 0.85,汇总门禁通过;但 硬盘空间不够了想买大一点的 这道基线答对的题答不出了,逐题门禁失败:

candidate dim1024: Config(chunk_size=200, chunk_overlap=0, dim=1024, k=3)
baseline hit@3=0.80  candidate hit@3=0.85  candidate MRR=0.725
  regressed: 硬盘空间不够了想买大一点的 (rank 3 -> None)
  improved:  不想让别人继续访问我发出去的地址 (rank None -> 1)
  improved:  误删了东西还能找回来吗 (rank None -> 3)
aggregate gate: PASS
protected-question gate: FAIL
verdict: FAIL

最后一个例子最值得停下来想想:“整体更好”的变更,也会让某些用户的问题变差。逐题门禁不是要禁止变化,而是把变化变成一次显式的决策:要么修复,要么在评审里写明接受这道题的退化,并重新录制基线。重录会改变 baseline.json,里面每题排名的差异恰好就是评审要看的内容。

不要从 chunk40 的 MRR 略高得出“切得越碎越好”:这是词面级嵌入在这套数据上的表现,换成真实模型可能完全相反。

实验 3:20 道题的结论有多可信

最后用固定种子的自助法(bootstrap)估计噪声。基线 hit@3 取自 20 道题,其置信区间的宽度,决定了你能用这套评测集回答什么问题:

sampling_noise.py
"""How much can a hit@3 difference be trusted with only 20 questions? Paired bootstrap, fixed seed."""
import random
 
from retrieval import Config, evaluate
 
N_RESAMPLES = 5000
rng = random.Random(2026)
 
 
def hits(cfg: Config) -> list[int]:
    return [1 if r["rank"] is not None and r["rank"] <= 3 else 0 for r in evaluate(cfg)]
 
 
def interval(values: list[float]) -> tuple[float, float]:
    values = sorted(values)
    return values[int(0.025 * len(values))], values[int(0.975 * len(values)) - 1]
 
 
base = hits(Config())
n = len(base)
print(f"questions: {n}; one question flips hit@3 by {100 / n:.1f} points")
 
boot = [sum(base[rng.randrange(n)] for _ in range(n)) / n for _ in range(N_RESAMPLES)]
low, high = interval(boot)
print(f"baseline hit@3 = {sum(base) / n:.2f}, 95% bootstrap interval [{low:.2f}, {high:.2f}]")
 
for label, cfg in [("dim1024", Config(dim=1024)), ("dim64", Config(dim=64))]:
    cand = hits(cfg)
    diffs = []
    for _ in range(N_RESAMPLES):
        idx = [rng.randrange(n) for _ in range(n)]
        diffs.append(sum(cand[i] - base[i] for i in idx) / n)
    low, high = interval(diffs)
    print(f"{label}: hit@3 {sum(cand) / n:.2f} vs {sum(base) / n:.2f}, paired difference interval [{low:+.2f}, {high:+.2f}]")
questions: 20; one question flips hit@3 by 5.0 points
baseline hit@3 = 0.80, 95% bootstrap interval [0.60, 0.95]
dim1024: hit@3 0.85 vs 0.80, paired difference interval [-0.10, +0.20]
dim64: hit@3 0.70 vs 0.80, paired difference interval [-0.30, +0.10]

读法:一题翻转 5 个百分点;基线的 95% 区间 [0.60, 0.95] 几乎覆盖了从“一半以上”到“接近满分”的整个范围。dim1024 看起来提高了 5 个点,但配对差异的区间 [-0.10, +0.20] 包含 0,数据并不支持“更好”这个说法;dim64 的下降同样如此。所以:

  • 评测集要尽量大,并且持续把线上发现的失败问题补进去;
  • 小评测集用来挡住退化(逐题保护),不要拿它宣布胜利;
  • 对比两个方案时报告配对差异的区间,而不是两个孤立的数字。

把门禁接入 CI

门禁通过退出码表达结果,CI 里只需要执行脚本:

python regression_gate.py candidate   # exit 0 = pass, 1 = regression, 2 = stale baseline

建议的流程是:检索相关的改动(切分、嵌入、k、重排)都要求通过门禁;需要接受退化时,在同一个提交里重录基线并在说明里解释,让评审看到 baseline.json 的差异。

失败与回滚

  • 门禁失败(退出码 1):先看输出里列出的 regressed 题目,判断是真实退化还是评测集本身的问题。真实退化就修复或回退改动;确认可以接受时,在同一提交里重录基线;
  • 基线过期(退出码 2):评测集的文档或题目变了,旧基线不再可比。脚本直接拒绝,提示用 record_baseline.py 重录。不要手改 baseline.json 里的指纹绕过;
  • 回滚检索改动:把切分参数、嵌入模型名和 k 放进版本控制的配置里,回滚就是回退这次提交。更换嵌入模型需要对整个语料重新计算向量,否则查询向量和文档向量不在同一个空间,这类改动应当和索引重建作为一个整体上线或回退;
  • 局限:本页的检索器是词面级的,评测集只有 20 题,输出里的数值不代表任何真实系统。

相关阅读

来源

以上来源均于 2026-10-25 核对。hit@k、MRR 与自助法是通用的统计方法,本页的实现与数值均来自站点仓库 labs/rag-evaluation-regression/ 在上表版本下的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-25;
  • 复现:先执行 labs/setup-envs.sh v1c 重建环境,再执行 python3 labs/run.py --lab rag-evaluation-regression;
  • 发现错误请按联系我们反馈。