先补齐必要概念
适合:了解模型请求、提示与工具契约,第一次做 RAG。
- 入库
- 解析文档、确定切块与元数据,并为内容和来源记录版本及摘要。
- Embedding
- 由指定模型将文本映射到向量;文档和查询需使用兼容模型与处理方式。
- 检索候选
- 向量或关键词排名提供可能相关的块,需经过当前权限与版本过滤。
- RRF
- 按各排名的名次融合结果;本实验融合常数为 60,最终上下文数量另由 top_k 控制。
- 必要证据召回
- 人工标注的必需来源有多少进入了上下文,按每道可回答问题计算。
- 向量快照
- 这批文本的真实本地模型推理产物,带模型、维度、语料摘要与模型文件摘要,支持无模型下载的离线回放。
原理怎样一步步成立?
- 解析与保留来源
五份虚构英文 Markdown 产生七个独立块,保留租户、版本、章节、正文与摘要。
- 生成兼容向量
作者实际运行 bge-small-en-v1.5,记录七个文档块和三个查询的 384 维向量。
- 先过滤后排名
SQLite 参数化过滤当前租户与有效版本,再进行关键词和余弦检索。
- 融合与组装
BM25 与向量排名用 RRF 融合,截取最终上下文并写入有来源 ID 的请求。
- 验收候选
作者候选经过引用成员、权限、版本及有限政策标签检查,自由文本语义单独标示。
入门 · 理解原理
为什么相关段落齐了,回答仍可能漏掉例外?
本层目标:画出文档到回答的证据路径,说明入库、检索与生成各承担什么。
先看需要两份证据的问题
用户询问购买 12 天的受损电池能否退货。语料中的普通规则说 30 天,商品例外说受损电池适用 7 天并联系支持复核。完整答案需要解释普通规则为何被例外覆盖。单独找到一段相关的“退货”文字,不能证明上下文已经足够。
本项目提供五份虚构英文 Markdown,包含当前规则、旧版本、另一租户的 VIP 规则、运输与保修说明。教学目录的首行是 JSON 元数据,正文按二级标题分块,得到七个 chunk。来源与段落各有 SHA-256,chunk ID 包含文档、版本与章节,可回查原文。
入库和回答是两条相接的链
入库时解析材料、决定边界、写元数据、生成文档向量并保存索引。回答时根据可信身份过滤、生成查询向量、召回候选、融合排名、截取上下文,再把证据交给模型。生成结束后核对结构、引用与实际支持。每段都保留结果,才能分辨证据在哪里丢失。
本课怎样验证向量这一段
作者使用 FastEmbed 0.8.1 实际运行 BAAI/bge-small-en-v1.5,生成文档和查询的 384 维向量。实验包包含这批向量、模型文件摘要、版本和语料摘要。默认程序读取快照,实际计算余弦、BM25 与 RRF,因此可以离线观察搜索过程。选择重建步骤时,会在自己的电脑重新下载模型并运行推理。
该模型和分词规则服务本课的英文材料。中文或混合语言语料,应重新选模型、重建文档与查询向量,并用对应语言任务验证。相同维度不能证明两个模型的向量空间兼容。
高相似度与可回答性分别检查
海关税费问题也会召回相关退货段落,但语料没有税费依据。向量距离只对给定表示衡量相似性。生成阶段应允许说明材料不足,评测也要单列正确拒答与有证据却拒答。实验用作者候选观察这一验收路径,自由文本语义另行复核。
运行实验,观察反例
实际解析虚构英文 Markdown、运行 SQL 过滤、余弦、BM25、RRF、请求组装与有限契约评测。文档和固定查询向量来自真实本地 Embedding 推理快照;生成候选由作者编写,自由文本语义未评分。
Python 3.10+ · 默认运行只使用标准库 · 在你的电脑运行
- 下载本页的 Agent 应用入门实验包,解压后进入 agent-application-lab-v1 目录。
- 使用 Python 3.10+ 执行上方命令;默认回放只需标准库与包内数据。
- 对照输出与检查点,再运行 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。
本层验收任务
画出入库与回答的两条流程,标记租户、版本、向量、候选、最终上下文与引用,列出电池问题的必要证据集合。
完成后逐条核对
- 保留七个 chunk 到五份原文的映射。
- 电池问题列出普通规则与电池例外。
- 权限与版本在候选进入上下文前检查。
- 说明快照来自实际 Embedding 推理,生成候选由作者编写。
保存自己的过程、代码与结果。这里提供验收要求,暂不自动评分或保存课程掌握状态。
收起答案,检查理解
旧规则和新规则都提到退货,为什么不能把两个版本一起交给模型自行挑选?
展开参考推导
业务需先确定当前有效且获准的政策范围。向量相似不会替你决定版本和权限。检索阶段过滤,候选仍保留来源与版本供验收;存在真实冲突时按业务规则处理并明确说明。
延伸原理与知识练习
遇到不熟悉的原理,先阅读实现、连续追问和迁移案例,再独立说明前提与边界。作答与笔记保存到原有账号记录。
本专题的全部关联解析与练习(5 道)
依据与验证范围
原理依据来自公开资料;数字、案例和任务是本站教学设计。离线实验验证本页注明的范围,学习效果仍需通过独立任务与反馈判断。