Document、检索组合与引用保真
检索链不只是把文本塞入 prompt。文档内容与 metadata 一起进入切分、检索、重排和引用;如果切分后丢失来源或权限,模型即使回答正确,也无法判断能否展示给当前用户。
用真实组件观察文档流
第一章环境已通过 langchain-classic 依赖安装文本切分包。完整实验使用确定性关键词打分,便于看清数据流;它不是向量检索质量实验。
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.runnables import RunnableLambda
sources=[Document(page_content="refund pending. refund complete.",metadata={"id":"A","tenant":"a","version":1}),
Document(page_content="refund secret.",metadata={"id":"B","tenant":"b","version":1})]
splitter=RecursiveCharacterTextSplitter(chunk_size=18,chunk_overlap=3,separators=[" ",""])
chunks=splitter.split_documents(sources)
def retrieve(request):
visible=[d for d in chunks if d.metadata["tenant"] == request["tenant"]]
scored=[(d.page_content.count(request["query"]),d) for d in visible]
return [d for score,d in sorted(scored,key=lambda pair:pair[0],reverse=True) if score>0][:2]
retriever=RunnableLambda(retrieve)
r=retriever.invoke({"tenant":"a","query":"refund"})
assert r and all(d.metadata["id"] == "A" for d in r)
assert retriever.invoke({"tenant":"a","query":"absent"}) == []
print([(d.page_content,d.metadata["id"]) for d in r])权限过滤先于打分,B 不参与候选竞争。真实向量存储应使用 metadata filter 或隔离索引,再在返回前复验;先取全库 top-k 再过滤可能既泄露候选又损失召回。切分保留 metadata,但来源段落/偏移需自己设计,不能假定每个 splitter 都生成可定位行号。
从检索器到有证据的回答
Embedding 把文本映射向量,近邻只表示相似;BM25/关键词擅长标识符,向量擅长语义改写,混合检索融合后再用 reranker 重排。k 太小可能漏关键证据,太大增加噪声与费用。评估分别测 Recall@k、重排相关性和答案忠实度,不能拿最终正确率代替检索召回。
上下文模板把每段资料放在明确数据边界中,附文档ID、版本与位置。模型输出引用ID后,后处理核对ID确实来自当前可见证据;资料中的“忽略之前指令”仍是数据。无结果返回明确无依据,不让模型自动用记忆补库存或政策。
Agentic RAG 让模型决定何时查询,普通检索链每次固定查询;前者灵活但增加漏检与循环风险。工具返回有限证据,重复查询需预算与无进展检测。摘要记忆用于对话压缩,checkpointer 保存运行状态,长期 store 保存跨任务资料,三者生命周期不同。
模型缓存键包含模型、prompt版本和参数,检索缓存还包含租户、权限与索引版本;用户撤权或文档更新需要失效。多模态文档先保存原文件与页码,再记录OCR/解析版本,不能只留抽出的文字。复杂文档摄取与向量库运行在相应专门课程继续展开。
练习
为每个chunk增加chunk_id,格式为文档ID:顺序号;验证每个ID唯一且tenant元数据仍保留。不要重写原source id。