作者:Java宋转AI
标签:RAG / AI Agent / 生产级架构
阅读时间:约8分钟
背景
RAG问答demo谁都能跑通,5分钟教程满天飞。向量数据库一搭,Embedding模型一调,Prompt一塞,出来个能跑的对话界面,成就感拉满。
但我真正上线后发现,demo和生产的差距不是调参数能补的。
是什么差距?是3个架构层面的决策。
每个决策都是踩了坑才想明白的。今天不聊怎么配置,聊"为什么这么选"。
决策1:为什么不能只靠语义检索
我一开始觉得向量检索是银弹。语义理解嘛,把Query往里一丢,语义相近的文档就召回来了,比关键词匹配聪明多了。
直到用户问了一句:"SKU 123456还有货吗"
向量检索召回的第一条是退货政策文档。
你可能会问:退货政策和库存查询,语义上完全不搭边啊,怎么会召回来?
我debug了半天发现,embedding模型在训练时见过太多"SKU"和"退货"、"退款"、"售后"这些词共现,它们在向量空间里的距离确实很近。语义检索的盲区就在这——它学的是语义的统计相关性,不是业务语义的精确匹配。
更坑的是,库存是实时状态。你向量库里存的是昨天的库存快照,今天早卖光了,RAG告诉用户"有货",这笔订单就砸了。
我的第一反应为什么不对
调相似度阈值。从0.7调到0.8,调到0.9。结果退货政策文档确实下去了,但正经的库存查询文档也召不回来了。
阈值调参是一个局部最优解,根本问题没解决。
真正的解法
给RAG和工具调用划清楚边界。
java
// 库存查询 -> 工具调用
@Tool("getInventory")
public InventoryResult getInventory(String skuId) {
// 实时查询库存系统
}
// 退货政策 -> RAG
public String getReturnPolicy(String productId) {
// 查向量库
}
库存查询、价格查询、订单状态——这些是结构化查询,是写操作,是实时数据。它们不该走RAG。
那非实时的事实性知识怎么办?我的做法是语义+词汇双路检索,0.7/0.3权重合并:
java
public class HybridRetriever {
private static final double SEMANTIC_WEIGHT = 0.7;
private static final double LEXICAL_WEIGHT = 0.3;
public List<Chunk> retrieve(String query) {
double semanticScore = semanticSearch(query);
double lexicalScore = lexicalSearch(query);
double combinedScore = semanticScore * SEMANTIC_WEIGHT
+ lexicalScore * LEXICAL_WEIGHT;
// ...
}
}
词汇检索专门处理SKU、订单号、型号这些业务专有名词。语义检索处理业务语义理解。两者互补,盲区就少多了。
深层的思考
RAG不是万能的。实时数据、结构化查询、写操作,这些不该进RAG。
给RAG划清楚边界,比优化检索算法更重要。
决策2:为什么改了文档,但RAG还在召回旧内容
这个坑我踩得比较隐晦。
一开始我建知识库,就是把文档chunk之后扔进向量库,简单粗暴。后来业务方说文档更新了,我去向量库里查,发现召回来的还是旧内容。
为什么?因为向量库里的内容不会自动跟着源文档更新。
你改了源文档,向量库里的向量还是旧的。两条数据完全不在一个版本上。用户问的是新版文档的内容,但RAG召回的是旧版文档的向量,这答案能对吗?
更坑的情况
我们有好几个业务线,共用一个向量库。A业务线的运营更新了他们的知识库,但B业务线的用户在查询时,偶尔会召回A业务线的文档。
原因很简单——向量检索没有加业务隔离的filter,所有文档都在同一个向量空间里,距离近就召回来了。
真正的解法
给每条知识打元数据标签,检索时加filter。
sql
CREATE INDEX idx_rag_chunks_metadata
ON rag_chunks USING GIN (metadata);
检索的时候带上业务隔离:
sql
SELECT * FROM rag_chunks
WHERE embedding <=> %s < 0.6
AND metadata->>'bizLine' = 'product-a'
AND metadata->>'tenantId' = 'tenant-123'
AND metadata->>'knowledgePackId' = 'kp-20240501'
AND metadata->>'promptRevision' = 'v2.3';
上线前对三个ID对账:knowledgePackId、promptRevision、bizLine+tenantId。三个对不上就不允许上线。
实际的向量化pipeline
java
@Data
public class ChunkMetadata {
private String bizLine; // 业务线
private String tenantId; // 租户ID
private String knowledgePackId; // 知识包版本
private String promptRevision; // Prompt版本
private Long createdAt;
private String sourceUri;
}
知识包带版本号,离线任务推版本到pgvector,检索时filter和索引保持一致。
深层的思考
知识库不是"丢进去就行"的,它需要和代码一样的版本管理思维。
代码
知识库
git
knowledgePackId
CI
对账流程
环境隔离
tenant隔离
决策3:为什么召回率很高,但答案还是不准
这个坑我一开始以为是Prompt的问题。
我调Prompt调了两个星期,换了各种写法,加了各种约束条件,答案还是时准时不准。后来我发现,问题根本不在Prompt,在召回的内容。
我统计了一下,top-k=5召回了5条文档,但真正和用户Query相关的只有1条。其他4条是什么?是噪声。LLM被这4条噪声带偏了,给出了一个看起来像那么回事但实际不对的答案。
更坑的情况
有一次运营把整页商详HTML丢进了RAG,一条chunk就是200KB。top-5召回来,Prompt直接超过context limit,LLM报错。
那天半夜2点我爬起来修bug,看到那个200KB的chunk,整个人都麻了。
真正的解法
RAG不该传全文,只传摘要+链接。
向量化pipeline只提取三段内容:
java
@Data
public class Chunk {
private String chunkId;
private String summary; // 摘要:概括性描述
private String coreParams; // 核心参数:芯片、屏幕、电池...
private String afterSales; // 售后条款:退货政策、保修...
private String fullContentUri; // 完整内容存对象存储,按需拉取
}
LLM拿到的是精炼的上下文,不是200KB的HTML噪声。
自适应阈值检索
top-k是固定的,但Query的难度是变化的。
java
public class AdaptiveThresholdRetriever {
private static final double HIGH_THRESHOLD = 0.8;
private static final double LOW_THRESHOLD = 0.6;
public List<Chunk> retrieve(String query, double semanticScore) {
if (semanticScore >= HIGH_THRESHOLD) {
return directPush(query); // 高置信度直接推
} else if (semanticScore <= LOW_THRESHOLD) {
return lexicalFallback(query); // 低置信度回退词汇检索
} else {
return hybridRerank(query); // 中间段走混合重排
}
}
}
简单Query一条就够了,复杂Query需要多条。阈值自适应,不用手调。
深层的思考
RAG的本质是"给LLM提供恰到好处的上下文"。
少了,LLM产生幻觉,开始胡编乱造。
多了,噪声淹没信号,LLM被带偏。
上下文的质量和数量同样重要,甚至更重要。
技术栈参考
本文涉及的技术实现:
-
Spring AI Alibaba + pgvector
-
DashScope text-embedding-v3(1024维)
-
ContentDraftRagRetriever:自适应阈值 + 混合重排 + 缓存
-
工具调用 vs RAG 的边界划分
总结
RAG不是"丢进去就行"的,它需要和代码一样的工程思维。
-
版本管理要像git一样严谨
-
边界划分要像接口设计一样清晰
-
上下文控制要像性能优化一样斤斤计较
demo和生产的差距,不在调参,在这些架构决策。
关注我,少走3个月弯路