Agent 应用开发会员账号
知识目录选择核心方向与细分内容

SYSTEMATIC LEARNING / 分层专题

从文档入库到有引用的 RAG 回答

亲自运行 Markdown 解析、真实 Embedding 向量回放、SQL 权限过滤、BM25、向量检索、RRF 和候选验收。

学习目标:能保留并检查 RAG 每段中间产物,实际比较 top-k 对必要证据的影响,并区分检索、生成与语义评测结果。

内容核对 2026-10-04 · 每层有独立讲解、任务与检查

按这个知识点的熟悉程度选择起点。当前层:进阶 · 定位故障。完成任务后可以继续下一层;阅读与自检不自动代表掌握。

本层学习目录

先补齐必要概念

适合:已跑通 RAG,开始处理错误答案、泄漏或拒答。

入库
解析文档、确定切块与元数据,并为内容和来源记录版本及摘要。
Embedding
由指定模型将文本映射到向量;文档和查询需使用兼容模型与处理方式。
检索候选
向量或关键词排名提供可能相关的块,需经过当前权限与版本过滤。
RRF
按各排名的名次融合结果;本实验融合常数为 60,最终上下文数量另由 top_k 控制。
必要证据召回
人工标注的必需来源有多少进入了上下文,按每道可回答问题计算。
向量快照
这批文本的真实本地模型推理产物,带模型、维度、语料摘要与模型文件摘要,支持无模型下载的离线回放。

原理怎样一步步成立?

  1. 解析与保留来源

    五份虚构英文 Markdown 产生七个独立块,保留租户、版本、章节、正文与摘要。

  2. 生成兼容向量

    作者实际运行 bge-small-en-v1.5,记录七个文档块和三个查询的 384 维向量。

  3. 先过滤后排名

    SQLite 参数化过滤当前租户与有效版本,再进行关键词和余弦检索。

  4. 融合与组装

    BM25 与向量排名用 RRF 融合,截取最终上下文并写入有来源 ID 的请求。

  5. 验收候选

    作者候选经过引用成员、权限、版本及有限政策标签检查,自由文本语义单独标示。

进阶 · 定位故障

用必要证据与中间产物找到第一处失败

本层目标:复现上下文截取、旧向量、未知查询和引用错误,并解释具体修复对象。

复现一个真实排名后的证据丢失

在实验包目录执行:

python3 - <<'PY'
from rag_pipeline import run_pipeline
for k in (1, 3):
    report = run_pipeline(top_k=k)
    row = next(r for r in report['rows'] if r['case']=='battery')
    print(k, row['contextIds'], row['evidenceRecall'], row['failedChecks'])
PY

top_k=1 只能保留一个块,而标注要求普通规则和电池例外两个块同时出现。候选即使引用两条来源,其中一条也没有实际进入 context,所以契约会失败。top_k=3 时这批样本保留必要来源。这个差异来自实际排序后的上下文截取。

沿五个位置逐段核对

先检查原文是否含必要内容;再看 chunk 是否切断适用条件;随后看权限过滤与召回是否仍有它;然后看融合及截取结果;最后检查候选如何引用和解释。每段保留 ID 和文本摘要,找到第一处丢失之后再修复。直接扩大上下文可能增加噪声、费用与相互冲突。

修改输入后,向量也要有对应版本

改动文档会使快照摘要不匹配。改成包外新查询时,程序明确报告 query was not recorded,而不会偷用最相似的旧查询向量。先重新生成,再检查模型名、维度、前处理和向量空间是否兼容。加载器也会拒绝维度不一致、非有限数和零向量。

引用 ID 存在仍可能出错

把 v2 引用换成旧版本或另一租户来源,grader 按当前版本和作用域失败;把 ID 换成不存在的块,引用成员检查失败。正确 ID 加上错误自由文本仍需语义复核。对“退货已执行”这种业务效果主张,还要查询独立业务账本。

高分拒答题的解释

海关问题可能得到排名靠前的退货文本,作者候选仍输出 unknown。评测中该题不进入可回答问题的必要证据召回平均值,单独验收拒答。若真正模型编造税费,就增加一条主张与证据标签,明确失败发生在生成或语义支持,保持检索与回答指标的分工。

运行实验,观察反例

实际解析虚构英文 Markdown、运行 SQL 过滤、余弦、BM25、RRF、请求组装与有限契约评测。文档和固定查询向量来自真实本地 Embedding 推理快照;生成候选由作者编写,自由文本语义未评分。

Python 3.10+ · 默认运行只使用标准库 · 在你的电脑运行

  1. 下载本页的 Agent 应用入门实验包,解压后进入 agent-application-lab-v1 目录。
  2. 使用 Python 3.10+ 执行上方命令;默认回放只需标准库与包内数据。
  3. 对照输出与检查点,再运行 python3 -m unittest test_application -v,并完成当前层任务。
下载完整应用实验包(含数据与依赖脚本) ↓
python3 rag_pipeline.py
查看本入口脚本
"""Actual parsing, SQL filtering, vector search, BM25, RRF and contract evaluation.

Embeddings are replayed from an actual local-model inference snapshot. The generation
stage uses explicitly authored fixtures. No network or inference is needed for replay.
"""
from collections import Counter
import json
import math
import re
import sqlite3

from application_data import FIXTURES, corpus_hash, load_chunks, read_cases
from prompt_iteration import CANDIDATES, build_prompt, grade


def load_snapshot(chunks, path=None):
    snapshot = json.loads((path or FIXTURES / "embeddings.json").read_text(encoding="utf-8"))
    if snapshot.get("schemaVersion") != 1 or snapshot.get("corpusSha256") != corpus_hash(chunks):
        raise ValueError("embedding snapshot is stale; rebuild after changing documents")
    if set(snapshot["documents"]) != {chunk["id"] for chunk in chunks}:
        raise ValueError("embedding snapshot has different chunk identities")
    dimension = snapshot["dimension"]
    if type(dimension) is not int or dimension < 1 or not snapshot.get("model"):
        raise ValueError("invalid embedding contract")
    for vector in [*snapshot["documents"].values(), *snapshot["queries"].values()]:
        if (len(vector) != dimension or not all(type(x) in (int, float) and math.isfinite(x) for x in vector)
                or not any(vector)):
            raise ValueError("invalid embedding vector")
    return snapshot


def tokenize(text):
    return re.findall(r"[a-z0-9]+", text.lower())


def cosine(left, right):
    return sum(a * b for a, b in zip(left, right)) / (math.sqrt(sum(a * a for a in left)) * math.sqrt(sum(b * b for b in right)))


def keyword_scores(query, chunks):
    terms = set(tokenize(query))
    bags = [Counter(tokenize(chunk["text"])) for chunk in chunks]
    average = sum(sum(bag.values()) for bag in bags) / len(bags)
    scores = []
    for chunk, bag in zip(chunks, bags):
        score = 0.0
        for term in terms:
            frequency = bag[term]
            if frequency:
                df = sum(term in other for other in bags)
                idf = math.log(1 + (len(bags) - df + 0.5) / (df + 0.5))
                score += idf * frequency * 2.2 / (frequency + 1.2 * (0.25 + 0.75 * sum(bag.values()) / average))
        scores.append((chunk["id"], score))
    return sorted((row for row in scores if row[1] > 0), key=lambda row: (-row[1], row[0]))


def index_chunks(chunks):
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE chunks(id TEXT PRIMARY KEY, tenant TEXT NOT NULL, current INTEGER NOT NULL, body TEXT NOT NULL)")
    db.executemany("INSERT INTO chunks VALUES(?,?,?,?)", [(chunk["id"], chunk["tenant"], int(chunk["current"]), json.dumps(chunk)) for chunk in chunks])
    return db


def retrieve(query, chunks, snapshot, tenant="shop-a", top_k=3):
    if type(top_k) is not int or top_k < 1:
        raise ValueError("top_k must be a positive integer")
    if query not in snapshot["queries"]:
        raise ValueError("query was not recorded; regenerate embeddings for the changed query")
    db = index_chunks(chunks)
    try:
        allowed = [json.loads(row[0]) for row in db.execute("SELECT body FROM chunks WHERE tenant=? AND current=1 ORDER BY id", (tenant,))]
    finally:
        db.close()
    if not allowed:
        return {"allowed": 0, "dense": [], "keyword": [], "context": []}
    query_vector = snapshot["queries"][query]
    dense = sorted([(chunk["id"], cosine(query_vector, snapshot["documents"][chunk["id"]])) for chunk in allowed], key=lambda row: (-row[1], row[0]))
    keyword = keyword_scores(query, allowed)
    fused = Counter()
    for ranking in (dense, keyword):
        for rank, (chunk_id, _) in enumerate(ranking, 1):
            fused[chunk_id] += 1 / (60 + rank)
    selected = [cid for cid, _ in sorted(fused.items(), key=lambda row: (-row[1], row[0]))[:top_k]]
    by_id = {chunk["id"]: chunk for chunk in allowed}
    return {"allowed": len(allowed), "dense": [cid for cid, _ in dense], "keyword": [cid for cid, _ in keyword], "context": [by_id[cid] for cid in selected]}


def run_pipeline(top_k=3):
    chunks = load_chunks()
    snapshot = load_snapshot(chunks)
    rows = []
    for case in read_cases():
        retrieved = retrieve(case["query"], chunks, snapshot, top_k=top_k)
        request = build_prompt(case, retrieved["context"])
        answer = CANDIDATES["v2"][case["id"]]
        context_ids = [chunk["id"] for chunk in retrieved["context"]]
        required = set(case["requiredEvidence"])
        rows.append({"case": case["id"], "contextIds": context_ids,
                     "request": request, "candidate": answer,
                     "evidenceRecall": len(required.intersection(context_ids)) / len(required) if required else None,
                     **grade(case, answer, retrieved["context"])})
    return {"chunks": chunks, "snapshot": snapshot, "rows": rows}


def demo():
    report = run_pipeline()
    answerable = [row["evidenceRecall"] for row in report["rows"] if row["evidenceRecall"] is not None]
    return {"chunks": len(report["chunks"]), "dimension": report["snapshot"]["dimension"],
            "embeddingModel": report["snapshot"]["model"], "queries": len(report["rows"]),
            "contextIds": {row["case"]: row["contextIds"] for row in report["rows"]},
            "contractPasses": sum(row["contractPassed"] for row in report["rows"]),
            "meanNecessaryEvidenceRecallAt3": sum(answerable) / len(answerable),
            "semanticQuality": "not_scored", "generation": "authored_fixtures",
            "embedding": "real_local_inference_snapshot"}


if __name__ == "__main__":
    print(json.dumps(demo(), ensure_ascii=False, sort_keys=True))

本地运行的预期输出

{"chunks": 7, "contextIds": {"battery": ["returns:v2:battery", "returns:v2:general", "warranty:v1:defects"], "ordinary": ["returns:v2:general", "returns:v2:battery", "shipping:v1:contact"], "unknown": ["returns:v2:general", "returns:v2:battery", "warranty:v1:defects"]}, "contractPasses": 3, "dimension": 384, "embedding": "real_local_inference_snapshot", "embeddingModel": "BAAI/bge-small-en-v1.5", "generation": "authored_fixtures", "meanNecessaryEvidenceRecallAt3": 1.0, "queries": 3, "semanticQuality": "not_scored"}
  • chunks=7、dimension=384,模型与产物摘要见 embeddings.json。
  • 三个查询实际运行排名,当前租户与版本过滤发生在排名前。
  • 固定语料的 meanNecessaryEvidenceRecallAt3=1.0,仅计算两道可回答问题。
  • generation=authored_fixtures、semanticQuality=not_scored。
查看运行环境、输出和校验记录 →

继续做进阶研究实验

政策变化后,旧上下文为什么不能继续用?

把检索前过滤的思想迁移到记忆版本与恢复。观察撤销后已有草稿怎样失效。

阅读全文与故障分析 → · 下载可靠性实验 v3 ↓

python3 cli.py memory-put --db memory.sqlite
python3 cli.py submit --db memory.sqlite
python3 cli.py run --db memory.sqlite --lease-seconds 2 --fault after_draft
python3 cli.py memory-forget --db memory.sqlite
# 等待至少 2 秒后分别执行
python3 cli.py run --db memory.sqlite
python3 cli.py inspect --db memory.sqlite

保留证据,逐条核对

  • 首次退出后 draft 检查点已存在。
  • memory-forget 后恢复得到 failed 与 memory_changed_or_expired。
  • 没有 publish 检查点;解释失效阻断与彻底删除的区别。

验证本地作用域、版本与撤销阻断;旧检查点仍保留,不提供日志、备份和检查点的彻底删除。

本层验收任务

复现 top_k=1、修改文档、新查询未录制和未知引用四种故障;交付第一处失败、证据、修复步骤和重跑结果。

完成后逐条核对

  • top_k=1 的电池问题缺少部分必要证据。
  • 文档变化触发过期快照检查。
  • 新查询必须生成对应查询向量。
  • 引用错误和自由文本错误分别处理。
  • 拒答题不被当作有标准来源的召回题计算。

保存自己的过程、代码与结果。这里提供验收要求,暂不自动评分或保存课程掌握状态。

收起答案,检查理解

必要证据都进入 context,模型仍回答电池适用 30 天,下一步查什么?

延伸原理与知识练习

遇到不熟悉的原理,先阅读实现、连续追问和迁移案例,再独立说明前提与边界。作答与笔记保存到原有账号记录。

本专题的全部关联解析与练习(5 道)

依据与验证范围

原理依据来自公开资料;数字、案例和任务是本站教学设计。离线实验验证本页注明的范围,学习效果仍需通过独立任务与反馈判断。