返回技术文章

RAG 落地实录:pgvector、增量索引、调参和中文别名那点事

从二十行 Demo 到生产可用的距离

约 6 分钟读完 LLM 应用
RAG 落地实录:pgvector、增量索引、调参和中文别名那点事 封面

动手之前,我以为 RAG 就二十行代码

LangChain4j 的 RAG quickstart 确实就二十来行:文档切片 → embed → 存进 EmbeddingStore → 检索 top3 → 拼进 prompt。Gym-Agent 的第一版 RagQaServiceImpl 也确实就一屏代码,选 pgvector 做向量库纯粹是因为不想多养一个中间件——MySQL 管业务数据,PostgreSQL + pgvector 管向量,各司其职。

EmbeddingMatch<TextSegment> hits = store.search(
        EmbeddingSearchRequest.builder()
                .queryEmbedding(model.embed(query).content())
                .maxResults(3)
                .build());

String context = hits.matches().stream()
        .map(m -> m.embedded().text())
        .collect(Collectors.joining("\n"));
// context 拼进 prompt,扔给 ChatModel,完事

然后我拿它自测了一晚上。知识库里 87 条场馆规则(开闭馆时间、收费说明、退改规则),我编了 60 个问题轮流问,目测准确率不到一半。剩下的不是答错,是一本正经地胡说八道——问"能不能带宠物进场",知识库里根本没有这条,它照样给你编一段"宠物需装笼携带"。

问题出在哪?排查了一晚上,定位到三个,一个比一个隐蔽。

坑一:不设 min-score,模型永远"有话可说"

向量检索有个反直觉的特性:它永远返回 top-k,哪怕库里全是垃圾。相似度 0.3 的片段也是 top3,照样被拼进 prompt,而大模型面对三段不相关文本的标准反应不是"我不知道",是硬着头皮编。

修复:搜索时加 minScore 阈值,低于阈值视为无命中,直接走兜底话术"知识库暂无此信息,已记录您的问题"。

.maxResults(3)
.minScore(0.72)   // 这个值是拿 60 个测试问题试出来的,不是拍脑袋

上线之后,"我不知道"的回答占比上来了。从产品报表看这是"回答率下降",实际是幻觉率下降——敢于承认不知道,是 RAG 系统走向可用的第一步。

坑二:top-k 不是越大越好

第二个想当然的优化:把 maxResults 从 3 调到 5,"给模型更多上下文总没坏处吧"。结果命中率没涨多少,答案质量肉眼可见地下降。看了一批 bad case,模式很一致:3 个强相关片段 + 2 个弱相关片段,模型开始在几个来源之间和稀泥,把两条不同场馆的规则揉成一条答案。

调回 3,把 minScore 卡严,效果反而最好。教训:top-k 是给召回的,不是给生成的;弱相关的上下文不是信息,是噪声。

坑三:切片把表格切碎了

场馆价目表是 markdown 表格,早期切片按固定 token 数硬切,把"周一至周五 | 20 元/小时"和表头切进了两个片段。向量库里每片单独看都人话不是,检索回来模型只能瞎猜。

修复:放弃按字数切,按知识条目切——一行表格、一个 Q&A 对、一条规则,就是一个最小检索单元,宁可片段长短不齐。顺便在每片 metadata 里塞了场馆 ID 和分类标签,后来做召回过滤("只在这个场馆的知识里找")全靠它。

意外之喜:命中来源可视化

排查调参问题的过程中,我给 RagAnswerVO 加了个 sources 字段,每条命中带标题、标签、相似度分数、命中文本片段——本来纯粹是给自己调试用的。

public class RagSourceVO {
    private String title;       // 知识条目标题
    private List<String> tags;
    private Double score;       // 相似度
    private String snippet;     // 命中片段
}

后来前端同学顺手把它渲染成了"回答依据"的折叠面板,用户点开就能看到 AI 是根据哪条规则回答的。上线后用户反馈出乎意料:信任感完全不一样。"AI 说退订要扣 30%"和"AI 根据XX体育馆退改规则第 3 条说退订要扣 30%",是两种产品。做 to C 问答的,这个功能强烈建议保留。

增量索引:运营改一条知识,总不能全量重建

知识库进了后台运营流程后,新问题来了:运营改了一条开闭馆时间(因为场馆装修),问我要不要全量重建向量库。87 条无所谓,8700 条呢?

于是有了 RagIncrementalIndexService:按知识条目的业务 ID 做单条增删改。这里面有个不显眼的坑:幂等。同一条知识重复 embed 会产生重复向量,光删向量 ID 没用,必须按业务 ID 删旧再插新,否则检索时同一条知识占两个坑,还挤掉了别人的召回位。

中文场景的分水岭:实体别名

以上都是 RAG 的通用功课,下面这个是中文业务场景特有的,也是我觉得最值得写的。

用户问"羽毛球馆晚上开到几点"。RAG 检索这一步没问题——语义检索把"羽毛球"相关规则召回了。但我们的架构是 Agent 编排工具:askGymKnowledge 查知识库,queryVenues 查数据库,而后者要按场馆名查库。库里的场馆叫"风雨球场-羽毛球专区",用户嘴里的"羽毛球馆"在数据库里查无此人。

语义检索救不了解决这个:embedding 擅长模糊的语义匹配,业务数据库需要精确的实体匹配。中间必须有一层归一化:

用户口语实体 --(HanLP 分词 + 别名词典)--> 标准实体 --(模糊匹配兜底)--> 数据库主键

落地是两个类:AgentTextNormalizer 在工具调用前跑一遍,用 HanLP 分词 + 运营维护的别名词典("羽毛球馆/羽毛球场地/风雨球场"都是同一个场馆)做实体归一;FuzzyMatchUtils 做最后一层模糊匹配兜底,处理用户输入"风雨球场"漏字、错字的情况。别名词典允许运营在后台自助维护,新别名当场生效。

这套东西不起眼,代码量也不大,但它直接决定了中文场景下 Agent"好不好用"——用户不会按数据库的命名规范说话,这层翻译缺失的时候,Agent 的每一次实体查询都是一次抽奖。

Job-Agent 的升级:混合检索,以及"不该走向量的东西"

到 Job-Agent,知识库换成了面试题库、公司信息、求职方法论,规模上来之后纯向量检索的短板开始显现:公司名、技术术语这类精确词,BM25 式的关键词检索反而更准("字节跳动"四个字做 embedding 之后,和"抖音集团"的语义距离近得离谱)。所以 Job-Agent 上了混合检索(向量 + 关键词双路召回,RRF 融合),再叠一层重排序,把召回质量问题压下去一截。

另一个更重要的认知:简历不是知识,是结构化数据。一开始我们把解析后的简历全文切片入库走向量检索,效果稀烂——检索"五年 Java 经验"召回的是描述长得像的项目经历,而不是"工作年限=5"这个字段。后来彻底重构:Apache Tika + POI + PDFBox 三个引擎做文档解析(PDF 简历、Word 简历、扫描件的兼容性各有各的坑,多引擎互为兜底),解析出的结构化字段(年限、技能、教育经历)进 MySQL 走精确查询,只有真正非结构化的部分(项目经历描述、自我评价)才走向量库。

小结

RAG 这事,quickstart 到能用之间隔着一整条调参和工程化的路。如果只能带走三句话:

  1. minScore 比你想的重要,让系统敢于说"不知道";
  2. 中文业务场景,实体别名归一化是语义检索和业务数据库之间必补的一层;
  3. 结构化数据和知识别混着放,该进字段的进字段,该走向量的走向量。

顺带一提,后来我们还打通了 MCP 文档导入知识库的链路,外部文档能一键入库存证——这个以后有机会单写。最后一篇,聊聊这一年里最硬核的几个工程化暗坑。