降SpringAI阿里第8掌-震惊百里-入藏RAG
降SpringAI阿里第8掌-震惊百里-入藏RAG
项目知识串库,比答不出来更致命
[读者] 正在给数字人项目补知识问答的 Java 工程师
[痛点] 一个向量库混装所有项目,答得头头是道却答错了项目
[现在读] 第8掌要把 RAG 纳入工程约束,而不是先堆检索链
[读完] 能交付一套按 projectId 隔离、可溯源、可演进的检索链
[旧方案] 单库灌数据 + 全库 topK + 直接拼进 prompt
|
v
[新需求] 多个数字人项目各自有 FAQ 与项目介绍,知识必须互不可见
|
v
[冲突] 向量相似度只表达"语义像不像",无法表达"属于哪个项目"
|
v
[后果] 串知识、答无源、越权泄露,用户一旦发现就不再信任
我是老李。第7掌我们刚把 MCP 通玄打通,数字人项目已经能调用外部工具了。但就在这一掌,运营同学提了一个特别朴素的需求:数字人上线后,客户会问"你们这个数字人怎么收费""能对接我们的 CRM 吗""服务热线是多少",这些答案都写在每个项目的 FAQ 和项目介绍文档里,我们得让数字人会答。
需求提得很朴素,也很硬:把每一个数字人项目的 FAQ 和项目介绍,变成该数字人可检索的知识库,回答必须带来源,项目之间不能串知识。注意,这不是"做一个 RAG Demo",而是"把 RAG 纳入数字人项目的多租户边界"。
我们当时的第一反应非常简单:Spring AI 有 Document、TextSplitter、EmbeddingModel、VectorStore,凑一个 VectorStore,把所有项目的文档全灌进去,用户问什么就 topK 取几条拼进 prompt。半小时就能跑通,看起来很美。但很快,第二个项目来了。
两个项目的知识,能放同一个向量库吗?
如果放,怎么保证 A 项目的用户问不到 B 项目的内部报价?如果不放,每新增一个项目就要新建一套库、一套配置、一套运维,成本谁来背?
更要命的是第三问:如果隔离没做对,最先炸的不是"答不出来",而是"答得太流利"——数字人会把 B 项目的报价,用 A 项目的口吻讲给 A 项目的客户听。这种错比不回答严重得多,你选哪条路?
01、故事
先把现场交代清楚。第3掌我们已经把数字人底座立起来了:项目有标题、主题色、背景图、开场白、结束语、Agent 模型和 System Prompt。第5掌加了 Memory 与流式,第6掌加了 Tool Calling,第7掌通了 MCP。到第8掌,每个数字人项目多了两样东西:一份 FAQ,一份项目介绍。
原来的方案是"硬写"。运营把 FAQ 手抄进 System Prompt,项目介绍压缩成两三段塞在 Prompt 里。两个项目的时候还能忍,五个项目之后 Prompt 膨胀到没法看,改一个价格要重新发版,而且没有任何引用来源——客户问"你从哪看到的",我们答不上来。
于是我决定上 RAG。这一掌的任务是:让每个数字人项目从自己的知识库里找答案,而不是从别人的知识库里捡答案。第一版目标非常清楚,就三条:每个项目一份可检索的知识库;问答结果必须带来源(哪个文档、第几段);A 项目的问题,检索结果里不能出现 B 项目的任何一条。
我先把 Document、TextSplitter、EmbeddingModel、VectorStore 这条链路接起来。写进去很快,跑起来也很快,前三个测试问题答得漂亮。然后我用第二个项目的账号问了一句同样的问题——数字人把我上一条项目里"客户专享折扣"讲了回来。
那一刻我才意识到,RAG 的第一道工程门槛不是"检索准不准",而是"检索的边界在哪"。
库中无先后
答里有乾坤
一界不分明
万语皆成祸
02、问题
旧方案在两个地方同时失效。
第一处失效在数据侧。我们把所有项目的文档一股脑 add 进同一个 VectorStore,Document 的 metadata 里只有一个文件名。向量库里住的是一堆"无国籍"的文本块,检索时它只知道"这段话和你的问题很像",完全不知道"这段话属于哪个项目"。
第二处失效在检索侧。我们把 topK=4 当成"给模型看的条数",没有过滤、没有阈值策略、没有命中明细。于是系统的最优行为是"全局语义最相似的 4 条",而全局最相似的 4 条,很可能来自另一个项目。
业务影响是直接的:客户服务场景里,串知识等于信息泄露;答不出可以转人工,答错了要赔钱。
技术表现可以归纳成三个可观测的现象。一是检索结果里出现非目标 projectId 的文档;二是回答内容里出现本项目知识库中不存在的实体(价格、热线、内部代号);三是同一个问题换个说法,答案就从"我不知道"变成"我知道得很具体"——典型的无上下文自由发挥。
这一掌的完成标准,我定了四条可验证的:
- 写入时每条 chunk 都能查到
projectId、docName、chunkIndex三个元数据; - 任意 projectId 的检索请求,返回结果的 metadata 中
projectId全部等于请求值; - 回答文本中包含至少一个来源标注,且标注可映射到具体文档;
- 当本项目知识库无相关内容时,回答是明确的拒答,而不是编造。
这四条标准,一条都不能靠"感觉不错"来验收。
无据不轻信
无界不检索
无源不成答
无验不交付
03、原理
RAG 这条链路的骨架只有五段,但每一段都在偷偷决定"边界"。
Document 是知识的最小载体,它有两个袋子:一个是 text(给模型看的),一个是 metadata(给工程系统看的)。绝大多数 RAG 事故,都是把该放 metadata 的东西写进了 text。
Reader/Parser 负责把 PDF、Markdown、HTML、数据库行变成 Document,它决定你能拿到哪些原始结构(标题、页码、表格),这些结构最终都会变成 metadata 的一部分。
TextSplitter 负责切块。切块带来一个容易被忽略的副作用:元数据的复制发生在切分之后,而不是之前。所以"我在 Document 上写了 projectId"和"我的每个 chunk 上都有 projectId",是两件不同的事,必须单独验证。
EmbeddingModel 把 chunk 变成向量。它只处理 text,完全看不见 metadata。这是第一个关键认知:向量相似度里没有任何"项目归属"信息。
VectorStore 存向量 + metadata + text,并提供 similaritySearch。它是对齐"边界"的唯一位置:SearchRequest 里的 topK、similarityThreshold、filterExpression 共同决定了"哪些内容有资格进入候选"。
Retriever/Advisor 把命中的文档拼进 Prompt,它决定了模型看得见什么,以及模型知不知道这些内容来自哪里。
反直觉判断:RAG 的质量上限,首先由元数据契约决定,其次才由 Embedding 模型决定。 这个判断有点反常识,因为所有人第一反应都是"换个大模型、换个向量模型"。但在多项目场景下,向量模型再强,也救不了没有 projectId 的向量库——它压根就没有表达项目归属这个维度。你换模型解决的是"排序问题",而你的故障在"边界问题"。
第二个必须记住的点:检索参数不是配置,是预算。topK 是"我要从库里拿多少候选",similarityThreshold 是"低于多少分我直接丢掉"。这两者共同决定了进入 Prompt 的信息预算。把 topK 当成"最终给模型看的条数",是把预算当成了结果,后面一定会撞墙。
最后一个概念层面的分歧,要在动手前想清楚,因为它决定你后面要不要升级:Two-step RAG、Agentic RAG、Hybrid RAG 不是三代产品,而是三种不同的"把不确定性放在哪"的方案。
Two-step RAG 把不确定性放在一次显式的中间步骤上:先改写或扩展查询,再检索生成。不确定就多花一次模型调用,换一次更干净的问题。
Agentic RAG 把不确定性交给 Agent:把检索做成一个工具,让模型自己决定检索几次、用什么词、要不要追问。代价是延迟和不可预测性一起升高。
Hybrid RAG 把不确定性放在召回通道上:向量召回 + 关键词召回并行,再做融合(如 RRF)。它解决的是"向量对专有名词、编号、精确短语不敏感"的问题。
这一掌我们的选择是:先做最朴素的一条链路,把边界做死;等边界稳了,再按故障买升级。 不是因为我们不会写复杂链,而是因为复杂链会同时引入召回正确性和边界正确性两类问题,一旦串库,你根本分不清是哪一类导致的。
先立界
后求准
再求全
终求稳
04、架构
[输入] HTTP 请求(projectId + 文档 / 问题)
|
v
[模块] IngestService(Document+Splitter+VectorStore) / RetrieveService(filter+topK) / AnswerService(Source+Prompt)
|
v
[数据/状态] chunk(metadata: projectId/docName/chunkIndex/ingestedAt) -> VectorStore 索引
|
v
[处理] 写入时补齐元数据并回读校验;检索时按 projectId 预过滤、按预算取候选、按来源拼装
|
v
[输出] 带来源标注的答案 + 来源清单;无命中时明确拒答
边界在哪?这套架构只管三件事:项目级写入、项目级检索、带来源回答。它不管多轮对话(第5掌的 Memory 负责)、不管工具调用(第6、7掌负责)、也不做跨项目问答(刻意不做)。
收益是什么?第一,隔离逻辑只有一处——检索入口的 filterExpression,审查范围极小;第二,来源信息在写入时就固化进 metadata,回答阶段只是渲染,不依赖模型自觉;第三,无命中时能明确拒答,而不是让模型补全。
代价是什么?第一,每新增一个项目,写入侧都要走同一条契约,元数据缺失会直接在检索时暴露为空;第二,物理上仍在一个向量库里,隔离是"逻辑隔离",安全性依赖过滤条件永远正确;第三,单跳检索对口语化提问的召回上限有限。
适用条件呢?项目数量在几十到几百量级、知识量在万级 chunk 以内、对延迟敏感、团队还没有评测体系时——这套"逻辑隔离的单库 RAG"是性价比最高的起点。当隔离要求升级为合规要求(比如不同客户数据必须物理分离)时,就要把 projectId 提升为 collection/namespace 维度。
一处定边界
一路管始终
元数据为先
引用可回看
05、实战一次
环境与版本:JDK 21 LTS,Spring Boot 3.x,alibaba/spring-ai-alibaba v1.1.2.2,DashScope 作为模型服务,分支 chapter/08-rag。
依赖(groupId 与 artifactId 需按官方 pom 核验):
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>1.1.2.2</version>
</dependency>
配置:
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
embedding:
options:
model: text-embedding-v3
核心实现分几段来看。
第一段,VectorStore 与 Splitter 的装配:
@Configuration
public class ProjectRagConfig {
@Bean
public VectorStore projectVectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
@Bean
public TextSplitter projectTextSplitter() {
return TokenTextSplitter.builder()
.withChunkSize(400)
.withMinChunkSizeChars(50)
.withMinChunkLengthToEmbed(5)
.withMaxNumChunks(1000)
.build();
}
}
第二段,写入——元数据契约在这里落地。注意切分后我显式重建每个 chunk 的 metadata,而不是假设 Splitter 一定帮我把 projectId 带过去:
@Service
public class ProjectKnowledgeIngestService {
private final VectorStore vectorStore;
private final TextSplitter textSplitter;
public ProjectKnowledgeIngestService(VectorStore vectorStore, TextSplitter textSplitter) {
this.vectorStore = vectorStore;
this.textSplitter = textSplitter;
}
public int ingest(String projectId, String docName, String docType, String content) {
Document raw = Document.builder()
.text(content)
.metadata(Map.of(
"projectId", projectId,
"docName", docName,
"docType", docType))
.build();
List<Document> split = textSplitter.apply(List.of(raw));
List<Document> chunks = new ArrayList<>(split.size());
for (int i = 0; i < split.size(); i++) {
Document c = split.get(i);
Map<String, Object> meta = new HashMap<>(c.getMetadata());
meta.put("projectId", projectId);
meta.put("docName", docName);
meta.put("chunkIndex", i);
meta.put("ingestedAt", Instant.now().toString());
chunks.add(Document.builder().text(c.getText()).metadata(meta).build());
}
vectorStore.add(chunks);
return chunks.size();
}
}
第三段,检索——过滤条件只在这里出现一次:
@Service
public class ProjectKnowledgeRetriever {
private final VectorStore vectorStore;
public ProjectKnowledgeRetriever(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public List<Document> retrieve(String projectId, String query, int topK, double threshold) {
FilterExpression filter = new FilterExpressionBuilder()
.eq("projectId", projectId)
.build();
SearchRequest request = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(threshold)
.filterExpression(filter)
.build();
return vectorStore.similaritySearch(request);
}
}
第四段,问答入口:
@Service
public class ProjectAnswerService {
private final ProjectKnowledgeRetriever retriever;
private final ChatClient chatClient;
public ProjectAnswerService(ProjectKnowledgeRetriever retriever, ChatClient.Builder builder) {
this.retriever = retriever;
this.chatClient = builder.build();
}
public Answer ask(String projectId, String question) {
List<Document> hits = retriever.retrieve(projectId, question, 20, 0.0);
List<Document> top = hits.stream().limit(4).toList();
if (top.isEmpty()) {
return new Answer("当前项目知识库中没有找到与该问题相关的内容。", List.of());
}
String context = IntStream.range(0, top.size())
.mapToObj(i -> {
Document d = top.get(i);
return "[%d] 来源=%s#%s\n%s".formatted(
i + 1,
d.getMetadata().get("docName"),
d.getMetadata().get("chunkIndex"),
d.getText());
})
.collect(Collectors.joining("\n\n"));
String answer = chatClient.prompt()
.system("""
你是数字人项目知识助手。
只能依据 <context> 中的内容回答,回答中必须用 [序号] 标注引用来源。
若 <context> 中没有答案,直接回复"当前项目知识库中没有找到相关内容",禁止编造。
""")
.user(u -> u.text("""
<context>
{context}
</context>
问题:{question}
""")
.param("context", context)
.param("question", question))
.call()
.content();
List<String> sources = top.stream()
.map(d -> "%s#%s".formatted(d.getMetadata().get("docName"), d.getMetadata().get("chunkIndex")))
.toList();
return new Answer(answer, sources);
}
}
启动:
./mvnw spring-boot:run
请求(先写入两个项目,再提问):
curl -X POST localhost:8080/api/projects/p-1001/knowledge \
-H 'Content-Type: application/json' \
-d '{"docName":"p-1001-faq.md","docType":"faq","content":"Q: 服务热线是多少?A: 400-000-0001。"}'
curl -X POST localhost:8080/api/projects/p-2002/knowledge \
-H 'Content-Type: application/json' \
-d '{"docName":"p-2002-faq.md","docType":"faq","content":"Q: 服务热线是多少?A: 400-000-0002。"}'
curl -X POST localhost:8080/api/projects/p-1001/knowledge/ask \
-H 'Content-Type: application/json' \
-d '{"question":"你们服务热线是多少"}'
验证(示例输出,未在当前环境实测,以下为预期结果):
{
"answer": "本项目服务热线是 400-000-0001 [1]。",
"sources": ["p-1001-faq.md#0"]
}
同时把检索命中明细打出来,验证隔离确实生效:
[retrieve] projectId=p-1001 topK=20 hits=2
[retrieve] hit[0] projectId=p-1001 docName=p-1001-faq.md chunkIndex=0
[retrieve] hit[1] projectId=p-1001 docName=p-1001-intro.md chunkIndex=3
到这里 V1 跑通了。但它留下三个我一眼能看见的问题:topK=20 是拍出来的;SimpleVectorStore 是内存实现,重启就没了;还有最关键的一条——第二个项目写进来的那一次,我根本没验证过它会不会污染第一个项目。
一灌一检索
一答一来源
先验界内净
再论准与全
06、排查
诊断一:跨项目串知识。
现象:用 p-1001 提问服务热线,回答里出现了 400-000-0002,来源标注却是 p-1002-faq.md。
怀疑:检索时没有真正按 projectId 过滤。
检查:在 ProjectKnowledgeRetriever.retrieve 返回前打印每条命中的 metadata,先不带过滤条件查一遍全库,再带上过滤条件查一遍。
证据:全库 topK=20 的结果里,p-1001 与 p-1002 的 chunk 混在一起;加上过滤条件后,p-1001 的命中数从 2 变成 0。
根因:写入侧我把 projectId 拼进了 text 的前缀("项目 p-1001:Q: 服务热线……"),却没有写进 metadata。过滤条件按 metadata 字段匹配,字段不存在,过滤后自然一条都不剩;而当时的检索代码根本没有传 filterExpression,所以走了全库。
修复:写入侧把 projectId、docName、chunkIndex 全部写进 metadata;检索侧强制传 filterExpression;并加一条写入后回读校验——用 filter 查询能查到,才算写入成功。
错误尝试: 我把 similarityThreshold 从 0.5 提到 0.85,希望"过滤掉另一个项目的内容"。
为什么错:串库的本质是边界问题,不是相关度问题。p-1002 的热线文档和"你们服务热线是多少"这句话,在语义上就是高度相似的——它的相似度很可能比 p-1001 的项目介绍还高。提高阈值只会让所有项目的短答案一起被误杀,然后模型在没有上下文的情况下自由发挥,等于把一个更隐蔽的故障(编造)换掉一个更明显的故障(串库)。用相关度去模拟隔离,方向从一开始就是错的。
诊断二:加了过滤之后,命中恒为 0。
现象:加上 filterExpression 之后,p-1001 的常规问题也经常返回"没有找到相关内容",而 p-1001 的文档确实已经写进去了。
怀疑:过滤没有生效,或者切分后的 chunk 上确实没有 projectId。
检查:分三步——第一,写一个诊断接口,直接用 filterExpression 查 projectId == 'p-1001',看能返回几条;第二,用不带过滤的 similaritySearch 查同一个问题,看 p-1001 的 chunk 排在第几名;第三,对比两组参数下命中数的变化。
证据:不带过滤时,p-1001 的相关 chunk 排在第 7、第 9 位;带过滤且 topK=4、similarityThreshold=0.5 时,命中数为 0。
根因:两个因素叠在一起。一是 topK=4 是"给模型看的条数",被误当成了检索预算——过滤和阈值是在这 4 条里面生效的;二是中文短问句和长 chunk 之间的余弦相似度普遍不高,0.5 这个阈值先把大量正确候选筛掉了。
修复:检索预算与展示条数分离。检索侧 topK=20、similarityThreshold=0.0(先关闭初筛,把候选捞回来),业务侧再 limit(4);等有了评测集,再按真实分布调阈值,而不是拍脑袋。
诊断三:答案有引用,但来源是 null。
现象:回答里模型确实写了 [1],但接口返回的 sources 里是 null#0。
怀疑:命中的 Document 上没有 docName。
检查:打印 chunk.getMetadata() 的完整内容。
证据:metadata 里只有 projectId 和 chunkIndex,docName 缺失。
根因:Splitter 切分后返回的 chunk,其 metadata 来源需要显式确认;我在重建 Document 时只补了 projectId,没有把原始 Document 上的 docName 一起补回去。
修复:把元数据补齐写成一个不可跳过的步骤,并在写入后用一次 similaritySearch 做"元数据冒烟测试"——缺字段直接抛异常,而不是等到回答阶段才发现。
无界先修界
无源先补源
阈值非隔离
预算非结果
07、优化
基于第 06 章的三条证据,V2 只做四件事,不多做。
第一件,把元数据契约固化成一次显式校验。根因是"元数据靠人记得写",修改是把必需字段声明成契约,写入前后各校验一次。原因是人的记忆不是接口。新行为:任何缺少 projectId、docName、chunkIndex 的 chunk 直接拒绝入库。验证:故意写一条缺 docName 的文档,应抛异常而非静默通过。
第二件,把检索预算与展示条数拆开。根因是 topK 被当成结果数。修改是检索用 topK=20、similarityThreshold=0.0,业务侧再截断。原因是先保证候选池里有正确结果,再谈排序。新行为:小知识库场景下命中不再为 0。验证:对同一批问题记录"候选池内是否包含正确 chunk"的命中情况,与 V1 做对比——这里只给方法,具体数值必须由你自己的评测集跑出来。
第三件,把过滤条件收敛成唯一入口。根因是过滤条件散落在调用处,漏传一处就串库。修改是把 projectId 作为 ProjectKnowledgeRetriever 的必填参数,不提供"不过滤"的重载。原因是:可以绕过的边界等于没有边界。新行为:编译期就无法发起一次无 projectId 的检索。验证:代码审查层面确认没有其他 similaritySearch 调用点。
第四件,来源信息从"模型自觉"变成"系统渲染"。根因是引用是否出现依赖模型的服从性。修改是 Prompt 里以 [n] 来源=docName#chunkIndex 的固定格式提供上下文,同时接口层独立返回 sources 数组。原因是模型可能漏标,但系统不能。新行为:即使模型忘记标引用,接口调用方依然能拿到来源清单。验证:断言 sources 非空且每个元素都能映射到已入库的 docName。
关于 Query Rewrite,这一掌我刻意没有放进 V2。理由是:现在还没有任何证据表明"检索不准是因为查询表达不对"。Query Rewrite 需要多一次模型调用,还必须额外验证"改写后是否改变了意图"。等第 15 掌有了评测集,如果数据显示"口语化提问召回明显低于书面提问",那时再上改写,才有依据。
契约先成文
边界只一处
引用由系统
升级靠证据
08、演进
[同一输入] question + projectId
|
+--[V1] 无 projectId 元数据 + 无过滤 + topK 即结果:串库、无源、命中为 0
|
+--[V2] projectId 元数据契约 + 唯一过滤入口 + 预算/结果分离:隔离、可溯源、可验收
|
[Trade-off]
得到:项目级隔离与来源可追溯,故障可定位到写入侧或检索侧
失去:全库 cross-project 检索能力、写入的"随手一灌"便利性
适用边界:项目数中等、隔离要求为逻辑隔离;合规要求物理隔离时需升级为按 projectId 分 collection
正确性上,V2 把"串库"从概率事件变成了结构性不可能——只要过滤条件正确,其他项目的数据根本不会进入候选集。V1 的正确性依赖运气。
稳定性上,V2 的失败模式是确定的:要么命中为空(拒答),要么命中正确;V1 的失败模式是"答得像真的",最难发现。工程上,可预测的失败比不可预测的成功重要。
复杂度上,V2 增加了一次写入校验、一层参数分离、一个必填的 projectId 参数。都不重,但确实比 V1 多写了大约五十行代码。这五十行买到的是隔离性。
成本上,V2 的检索预算从 4 提到 20,只影响向量库的查询开销,不增加模型调用次数,进入 Prompt 的仍然只有 4 条,token 成本基本不变。
适用范围上,V2 适合"同一个系统内多项目逻辑隔离"。如果客户合同里写了"数据必须物理隔离",那 metadata 过滤就不够了,必须把 collection/namespace 或独立库作为隔离单位,并接受运维成本上升。
遗留问题有三个,必须先记下来,而不是假装没看见。一是 SimpleVectorStore 是内存实现,重启即丢,多实例之间也无法共享,生产必须换持久化向量库,而且要逐库核验过滤语义是否一致(需按当前官方文档核验)。二是 FAQ 是"问答对"结构,按 token 数切很可能把 Q 和 A 切到两个 chunk 里,导致答案缺少问题上下文,短期可以约定"一问一答作为一个 Document"绕开。三是单跳检索对多跳问题无能为力("这个项目的收费和它支持的系统有关系吗"),这正是 Two-step RAG 与 Agentic RAG 的用武之地,但它们的引入应该由评测数据驱动,而不是由新鲜感驱动。
先划界
再溯源
后调参
终升级
09、洞见
9.1 隔离是元数据契约,不是模型能力
反直觉判断:RAG 的隔离性从来不是"检索质量"问题,而是"数据建模"问题。 当你听到有人讨论"换个更强的 Embedding 模型能不能解决串库",基本可以判断这个系统的元数据契约是缺失的。
Embedding 只作用于 text,它对 metadata 一无所知。你写进 text 的项目编号,在模型眼里只是一个普通字符串,和"目录""作者"没有本质区别,没有任何约束力。真正有约束力的,是 SearchRequest.filterExpression 这个结构化的、可被程序验证的过滤条件。
所以这一掌的结构是:写入侧把项目归属变成硬字段,检索侧把项目归属变成必填参数。这条链路里,模型唯一的工作是"读上下文并回答问题",它不负责、也不应该负责"判断这段话属于谁"。
9.2 检索参数是预算,不是配置
反直觉判断:topK 调大通常不会让回答变差,只会让回答变贵;similarityThreshold 调高才是导致"突然答不出来"的常见原因。 这和很多人的直觉相反,因为直觉上"取更多条"等于"引入更多噪声"。实际上在过滤后的单项目候选池里,噪声主要来自长文档的碎片,而阈值是硬筛,一旦筛掉正确 chunk,模型就拿不到任何依据。
这一掌把两者拆开的做法可以复用到任何 RAG 系统:召回阶段用宽口径(宁可捞多),编排阶段用窄口径(只给模型看少量高质量条目)。这个拆分还带来一个额外好处——你可以单独评估召回,不用把生成质量混在一起看。
9.3 来源不是给模型看的,是给系统看的
把来源写进 Prompt 并让模型标注,是一种"礼貌请求";把来源作为接口字段独立返回,是一种"系统承诺"。
这两者的区别在故障时会暴露得非常清楚。模型可能因为上下文被截断、或者被用户话术引导而漏标引用;但接口层的 sources 数组不会漏,因为它是从命中的 Document 直接映射出来的,不经过模型。
在数字人项目里这一点尤其重要:运营和客户成功团队需要看到"这条答案依据的是哪份文档的第几段",他们需要的是一个可以核对的来源清单,而不是一段自称有出处的文字。
9.4 复杂检索链是负债,要让它自己挣回成本
Two-step RAG、Agentic RAG、Hybrid RAG 都是好方案,但它们的共同特点是:同时增加了"召回正确性"和"边界正确性"两类变量的耦合度。
在边界还没做死之前上复杂链,你会遇到最难排查的故障:检索不准,你不知道是改写把意图改跑了,还是召回通道选错了,还是根本就是过滤没生效。这一掌坚持先做单跳,就是为了让后续每一个升级动作都能有单一变量归因。
判断标准很简单:如果评测集显示"同一批问题,改写后召回明显提升",那就上 Two-step;如果显示"专有名词和编号类问题召回差",那就上 Hybrid;如果显示"用户问题需要多轮澄清才能回答",那才轮到 Agentic。升级的触发条件应该来自数据,而不是来自框架文档的推荐顺序。
界由数据定
预算宜分开
引用归系统
升级看证据
10、系统落地
原来有什么:第 7 掌之后,数字人项目已经有 ChatClient 对话、Memory 多轮、Tool Calling 和 MCP 外部工具能力,但知识仍然靠手写 System Prompt 维持,一改就要发版,改错了还没人知道。
这一掌新增了什么:一套项目级知识库写入链路(Document + TextSplitter + EmbeddingModel + VectorStore)、一套以 projectId 为唯一过滤入口的检索链路、一套带来源标注的回答链路,以及"写入后回读校验"这条工程约束。
现在能做什么:数字人 Web 运行页可以针对指定 projectId 回答 FAQ 和项目介绍类问题,回答里带来源,来源可以在接口层被独立校验;A 项目的知识不会出现在 B 项目的回答里;没有命中时系统会明确拒答,而不是编造。
还缺什么:向量库还是内存实现,重启丢数据、多实例不共享;FAQ 分块策略没有针对问答对优化;检索质量没有评测基线,阈值还是经验值;多跳问题和跨文档推理还答不了;外部知识(客户文档、CRM 数据)的接入还没有权限模型。
下一步怎么演进:第一,把向量存储换成持久化实现,并核验其过滤语义,这一步会在后面服务化与外置状态的章节里收口;第二,把检索能力封装成工具,交给 Agent 决定检索时机和次数,这是从"单跳 RAG"走向"Agentic RAG"的开关;第三,建立一套最小评测集(问题 → 期望来源),让每一次检索参数调整都有依据,而不是靠感觉。
有库先有界
有界再入库
入库必可查
可查方能答
11、小结
[输入] projectId + 文档/问题
|
v
[写入] Document(metadata契约) -> Splitter -> 回读校验 -> VectorStore
|
v
[检索] filterExpression(projectId) -> topK预算 -> 业务截断
|
v
[回答] 来源化上下文 -> 模型作答 -> sources 独立返回
|
v
[输出] 项目隔离 + 来源可溯 + 无据拒答
Q1 → 两个项目的知识能放同一个向量库吗?能,前提是每条 chunk 都带 projectId 元数据,且检索侧把 projectId 作为唯一且必填的过滤入口;隔离的强度由过滤条件保证,不由向量模型保证。
Q2 → 如果隔离没做对会怎样?最先炸的不是"答不出来",而是"答得太流利"——用本项目的口吻讲出另一个项目的价格和热线。它比拒答严重得多,因为它不触发任何告警,只消耗信任。
Q3 → 该不该一上来就上 Two-step / Agentic / Hybrid?不该。这三者都会把"召回正确性"和"边界正确性"耦合在一起,让故障归因变得困难。先用单跳 RAG 把边界做死,再用评测数据决定买哪一个升级。
状态 → 本篇交付的能力是:一个按 projectId 逻辑隔离、带来源元数据、无命中可拒答的项目级知识 RAG,以及一套可以继续演进到 Query Rewrite、检索验证、答案校验的检索骨架。
边界不清
检索无义
来源不记
答案难信
12、作业
12.1 理解题:为什么说"过滤条件必须只有一个入口"?
参考答案:因为可以绕过的边界等于没有边界。当 similaritySearch 在多个地方被调用时,只要有一处忘记传 filterExpression,整个系统的隔离性就退化成"取决于调用方自觉"。把 projectId 设计成检索方法的必填参数、不提供无过滤重载,本质上是把运行时约束提升为编译期约束——让错误写不出来,而不是靠 code review 抓出来。
12.2 实战题:给 V2 加一个"元数据冒烟测试"接口,并说明它防的是什么故障。
参考答案:接口接收 projectId,用 filterExpression 做一次检索,断言返回结果的每一条 metadata 中 projectId 等于入参、且 docName、chunkIndex 均非空,否则返回失败并列出违规 chunk。它防的是第 06 章诊断一和诊断三这类故障:元数据漏写在写入时毫无症状,只有在用户提问、答案已经生成之后才暴露,属于"延迟暴露型"缺陷。把校验前移到写入之后,故障就从"线上事故"降级为"启动告警"。
12.3 排障题:加了过滤之后命中数变 0,但去掉过滤能看到本项目的 chunk,怎么定位?
参考答案:按三步走。第一步,去掉过滤条件,topK 开到足够大,确认目标 chunk 在全库中的排名;第二步,恢复过滤,逐项放开 similarityThreshold 与 topK,观察命中数在哪一步从 0 变成非 0;第三步,检查过滤字段名与大小写是否和写入时完全一致。常见根因有两个:一是把 topK 当成了最终结果数,导致预算不足;二是相似度阈值对中文短问句过于严格,正确候选在过滤前就被筛掉了。修复方向是"检索宽口径、编排窄口径",而不是继续调高阈值。
12.4 架构判断题:在什么条件下,应该放弃 metadata 逻辑隔离,改为按 projectId 独立 collection 或独立库?
参考答案:当隔离从"工程便利"升级为"合规或合同约束"时,比如客户明确要求数据物理分离、或者不同租户的向量数据不能共存于同一索引、或者单个项目的向量规模已经让统一索引的过滤成本不可接受。此时把 projectId 提升为 collection/namespace 维度,收益是隔离强度与运维边界都更清晰,代价是连接与索引数量增长、跨项目检索能力彻底丧失、以及需要统一的写入与配置管理。判断标准是:逻辑隔离的失效代价(泄露)是否已经高于物理隔离的运维代价。如果是,就升级;如果不是,就先用一条过滤条件把问题压住。
契约可验证
边界可复现
升级有依据
收敛才是功
13、思考
回到这一掌最初的那个场景:两个项目的知识,能不能放进同一个向量库?
V1 给出的答案是"能,先跑起来再说",结果是用一句"客户专享折扣"讲给错了的人听。V2 给出的答案是"能,但每条 chunk 必须带 projectId,每次检索必须带过滤条件,每个回答必须能回溯来源"。
这两版代码的差别并不大,真正的差别在判断顺序上:先确定边界,再优化质量。 这条顺序在多租户系统里几乎是通用的——不是 RAG 专有的,只是 RAG 让违背它的代价变得特别贵,因为它的失败方式是"自信地答错"。
另一个可以带走的判断是:升级的触发条件应该来自评测数据,而不是来自技术栈的新鲜度。 Two-step、Agentic、Hybrid 都会到来,但到来的方式应该是"我们的评测显示这里不够用",而不是"最新方案里提到了它"。这一掌我们把 Query Rewrite、检索验证、答案校验留在门外,不是不做,是要等一扇由证据推开的门。
还有一句更朴素的:任何"没有来源"的智能回答,在业务系统里都只能算演示。
先定界
再求准
后求全
终求真

浙公网安备 33010602011771号