生产级 RAG 的难题藏在检索与文档更新里
文章介绍基于 NestJS、PostgreSQL、pgvector 和 Amazon Bedrock 的文档问答系统,涉及混合检索与提示缓存。重点讨论文档更新后检索失效、答案质量下滑及缓存未降低成本等不会直接触发服务告警的问题。
AI 深度解读
RAG 聊天机器人最麻烦的故障,往往没有报错:接口正常返回,数据库也没挂,但新文档搜不到,旧规则还在被引用,错误码 E-4021 被答成 E-4012。作者用 NestJS、PostgreSQL 和 Amazon Bedrock 搭了一套文档问答系统,重点讨论这些问题怎么沿着数据链路产生。
这篇值得读的地方,是把答案质量拆到了切块、向量化、检索、缓存和文档清理上。不过,原文展示的是一套面向生产的实现思路,没有给出线上准确率、延迟或成本对照,不能直接当成经过验证的生产方案。
答案不靠谱,先查检索拿到了什么
RAG(检索增强生成)的流程很直接:先找到相关文档片段,再把片段和问题交给模型,要求它依据材料回答并标注来源。模型能不能答对,首先取决于材料有没有找对。
作者把系统分成三段:离线索引负责读文件、切块、生成向量和入库;在线检索负责找片段;生成阶段负责组装提示词并调用 Claude。NestJS 10 用模块和依赖注入隔开这些职责,PostgreSQL 16 + pgvector 同时承担向量检索与全文检索,数据库访问使用普通 pg,没有引入 ORM。
这个结构对 Node 团队很实用。答案出错时,可以先查看检索结果,再决定是否调整提示词,不必在整条调用链里猜。
一次 POST /api/chat 请求包含 2 次 Bedrock 调用,即问题向量化和答案生成,以及并行执行的 2 条 SQL。请求体只允许 question 字段,空字符串、超过 2000 字符或携带未知字段都会返回 400。
先确认正确材料进入了上下文,再讨论模型为什么没答对。
作者把 RAG 与其他路线作了比较:全部文档塞进提示词适合很小的资料集,原文举的是几十页;RAG 更新知识只需重新入库,还能提供来源。不过,「微调只教风格、不教事实」说得过满。更稳妥的理解是,频繁变化且需要追溯的业务知识,用模型参数维护通常不如检索直接。
切块和清理旧数据,决定知识库会不会越用越乱
索引通过 npm run ingest [-- <folder>] 离线执行,使用 NestFactory.createApplicationContext(AppModule) 启动应用上下文,不开放 HTTP 端口。.md、.txt 各作为一份文档,.json 中带 text 的元素分别作为文档。
切块先按标题,再按长度。CHUNK_MAX_CHARS=1200,相邻片段重叠 CHUNK_OVERLAP_CHARS=120,长章节优先沿段落、句子边界拆分。这里的单位是字符,并非 token。
作者还把章节标题放进片段正文。保修示例中的「12 个月」因此带着「Warranty period」一起生成向量,减少脱离主题的问题。空标题章节则不生成片段。
向量模型使用 cohere.embed-multilingual-v3,输出 1024 维,每次最多提交 96 段文本。文档采用 input_type=search_document,问题采用 input_type=search_query;两端必须使用同一模型,并正确区分输入用途。配置中的 EMBEDDING_DIMENSIONS=1024 也要与数据库 vector(N) 一致,更换维度需要调整表结构并重新生成向量。
更新靠 <source>#<position> 形式的 chunkId 和 INSERT ... ON CONFLICT (chunk_id) DO UPDATE。写完后,deleteStale 删除同一文档不再存在的片段,例如从 5 块缩成 2 块时清掉剩余 3 块。
但这个实现留了几个明确缺口:每次都会重新向量化未修改内容;相对路径和绝对路径会被识别成不同来源;文件不再产生片段时,旧数据不会自动清理,彻底删除需要调用 ChunkRepository.deleteBySource。
因此,文档更新成功不能只看入库数量,还要检查旧内容是否消失。大资料集也应按作者建议保存内容哈希,跳过未变化的片段。
混合检索和缓存,为什么配置了也可能没收益
混合检索的两路各有用途:向量检索负责语义相近的表达,关键词检索补充错误码、名称等精确线索。前者使用 HNSW 索引和 embedding <=> $vec,后者使用 GIN 索引和 websearch_to_tsquery('simple', ...)。
问题在于,simple 不做词干还原,示例中的 product 匹配不到 products;普通空格分隔的词还会组合成 AND 条件。完整自然语言问题中的多个词若不同时出现在片段里,关键词分支就可能返回 0 条。保修示例确实如此,这比笼统宣称「混合检索更准」更有参考价值。
两路各取 CANDIDATE_LIMIT=50 个候选,再用倒数排名融合(RRF)合并。RRF_K=60,每路贡献 1 / (60 + rank),最终取 TOP_K=5。单路第一名得分约为 0.01639,两路都排第一则约为 0.03279。它根据排名融合,避免直接相加不同量纲的分数;关键词分支若没有结果,也不会凭空带来改善。
生成阶段的缓存,则取决于固定前缀是否真正复用。下面是原文的 InvokeModel 请求示例,用来展示固定规则与动态问题如何分开:
{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 2000,
"system": [
{ "type": "text", "text": "You are a Q&A assistant that answers from the provided documents..." },
{ "type": "text", "text": "Examples of good answers: ...",
"cache_control": { "type": "ephemeral" } }
],
"messages": [
{ "role": "user", "content": [
{ "type": "text", "text": "<context>\n[1] Warranty period\n...\n</context>" },
{ "type": "text", "text": "<question>How long is the product warranty?</question>" }
] }
]
}
关键看 cache_control 所在位置:作者希望复用系统提示词和少量示例,检索片段与问题仍按动态输入处理。ENABLE_PROMPT_CACHE=true 只是开关,实际收益要看缓存写入、读取 token 和调用条件。原文没有展开模型缓存门槛与有效期,不能据此保证第二次请求必然命中。
调用遇到 ThrottlingException 或 ServiceUnavailableException 时,分别等待 500ms、1s 重试,其他错误立即抛出。这解决短暂服务异常,不解决检索质量问题。
我的判断
我认可这套实现把数据维护和检索放在前面,也认可用 PostgreSQL 同时保存向量与全文索引:对于已有 PostgreSQL 的 Node 团队,少维护一套服务有实际价值。但作者没有提供与独立向量数据库、纯向量检索或加入重排模型后的对照数据,选型优势仍属于工程取舍。
它距离完整生产方案还有边界。当前 API 无状态,没有用历史对话改写问题;没有置信度阈值,检索不到材料也会调用模型;没有在代码中校验答案里的 [n] 是否存在。拒答示例可以引导模型,却不能替代这些检查。
返回的 citations 还包含全部送入上下文的片段,不等于全部被答案引用。前端需要依据回答实际出现的 [n] 过滤,并处理无效编号,避免把候选材料展示成已核验的证据。
原文使用 ap-northeast-1,列出生成模型示例 ID anthropic.claude-opus-5-5,并提示部分区域需要 inference profile。输入没有提供其可用性验证,不能把示例 ID 当成部署保证。提供的正文也在 loadConfig() 中途截断,没有完整代码和测试结果可供复现。
能拿来做什么
- 做 NestJS 文档助手时,先用
npm run search -- "How long is the product warranty?"查看候选片段和两路排名,把检索调通,再接聊天界面。 - 为文档入库固定来源路径,保存内容哈希,并明确处理缩短、清空和删除文件三种情况,避免旧政策继续进入答案。
- 前端将答案中的
[n]与返回的citations对应,只展示实际引用且编号有效的来源,让用户能回到原始材料核对。 - 上线前补充代表性问题集,分别检查自然语言、错误码和无答案问题;同时记录
cacheWriteTokens、cacheReadTokens,用结果判断检索与缓存是否有效。