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 rag_pipeline.py
python3 application_project.py > application-report.json
python3 -m unittest test_application -v

第一条打印七个 chunk、384 维模型、三道查询的实际 contextIds、契约检查和必要证据召回。第二条保存模型响应、提示与 RAG 的共同项目产物,包含每道问题的请求、候选和错误类别。

按文件阅读实现:application_data.py 解析文档;embeddings.json 保存实际推理向量;rag_pipeline.py 建内存 SQLite 索引并过滤,再计算余弦和 BM25,用 RRF 融合;prompt_iteration.py 组装请求并运行 grader。期待标签只进入评测,模型输入只有问题和选出的正文。

保留检索的中间证据

每个 chunk 有稳定 ID、租户、current、正文和摘要。程序用参数化 SQL 取得允许集合,向量和关键词都只在该集合内排名。另一租户 VIP 规则与旧版本不进入排名。这里假设元数据由可信入库流程维护;生产的用户权限必须从真实会话与授权数据取得。

dense 与 keyword 保存两种排名。RRF 累加 1/(60+rank),再选最终 top_k。该实现是融合排序基线,额外的模型重排器可作为后续可比较步骤。本课用内存表和精确余弦扫描解释机制,大语料可进一步比较倒排索引、ANN 与过滤策略。

亲自重建 Embedding

默认回放使用已经生成的向量。若要修改文档或查询,在自己的环境安装已验证版本并重建:

python3 -m venv .venv
.venv/bin/python -m pip install fastembed==0.8.1
HF_HUB_DISABLE_IMPLICIT_TOKEN=1 .venv/bin/python rebuild_embeddings.py --cache-dir .model-cache
.venv/bin/python rag_pipeline.py

首次需要联网下载开源模型并使用本地 CPU。脚本按同一模型的 passage 与 query 路径生成向量,记录模型文件 SHA-256、运行库版本和语料摘要。后续离线回放不依赖推理库。不同平台可能出现浮点差异,保留各自的模型产物与运行条件。

如何接真实生成

build_prompt 交付实际检索到的证据,把该请求接到模型响应课的真实接口步骤,再保存候选、usage 和版本。先分类完整、拒绝与不完整响应,然后复用 grader。当前报告的候选来自作者样本,报告中标为 authored_fixtures;自然语言支持仍显示 not_scored。

运行实验,观察反例

实际解析虚构英文 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。
查看运行环境、输出和校验记录 →

本层验收任务

交付 application-report.json,逐条指出原文、chunk、向量模型、两种排名、实际上下文、模型输入与引用。将 top_k 改为 1 后另存一份失败报告。

完成后逐条核对

  • 文档解析确实来自包内 Markdown。
  • 文档和查询向量版本及维度一致。
  • 旧版本与其他租户不进入当前检索排名。
  • 最终请求中没有期望标签。
  • top_k 改动保留中间结果与验收差异。

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

收起答案,检查理解

只修改语料中的 30 天为 20 天,再运行默认快照,会发生什么?

延伸原理与知识练习

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

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

依据与验证范围

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