[{"data":1,"prerenderedAt":407},["ShallowReactive",2],{"content:article:rag-pgvector-in-production":3,"content:article-surround:\u002Farticles\u002Frag-pgvector-in-production":398},{"id":4,"title":5,"body":6,"category":381,"cover":382,"date":383,"description":384,"draft":385,"extension":386,"featured":385,"meta":387,"navigation":73,"path":388,"seo":389,"stem":390,"tags":391,"updated":396,"__hash__":397},"articles\u002Farticles\u002Frag-pgvector-in-production.md","RAG 落地实录：pgvector、增量索引、调参和中文别名那点事",{"type":7,"value":8,"toc":370},"minimark",[9,14,27,99,107,110,114,121,128,143,150,154,161,170,174,177,184,188,199,234,241,245,248,259,263,266,281,288,296,307,314,318,325,332,335,338,363,366],[10,11,13],"h2",{"id":12},"动手之前我以为-rag-就二十行代码","动手之前，我以为 RAG 就二十行代码",[15,16,17,18,22,23,26],"p",{},"LangChain4j 的 RAG quickstart 确实就二十来行：文档切片 → embed → 存进 ",[19,20,21],"code",{},"EmbeddingStore"," → 检索 top3 → 拼进 prompt。Gym-Agent 的第一版 ",[19,24,25],{},"RagQaServiceImpl"," 也确实就一屏代码，选 pgvector 做向量库纯粹是因为不想多养一个中间件——MySQL 管业务数据，PostgreSQL + pgvector 管向量，各司其职。",[28,29,34],"pre",{"className":30,"code":31,"language":32,"meta":33,"style":33},"language-java shiki shiki-themes github-light github-dark","EmbeddingMatch\u003CTextSegment> hits = store.search(\n        EmbeddingSearchRequest.builder()\n                .queryEmbedding(model.embed(query).content())\n                .maxResults(3)\n                .build());\n\nString context = hits.matches().stream()\n        .map(m -> m.embedded().text())\n        .collect(Collectors.joining(\"\\n\"));\n\u002F\u002F context 拼进 prompt，扔给 ChatModel，完事\n","java","",[19,35,36,44,50,56,62,68,75,81,87,93],{"__ignoreMap":33},[37,38,41],"span",{"class":39,"line":40},"line",1,[37,42,43],{},"EmbeddingMatch\u003CTextSegment> hits = store.search(\n",[37,45,47],{"class":39,"line":46},2,[37,48,49],{},"        EmbeddingSearchRequest.builder()\n",[37,51,53],{"class":39,"line":52},3,[37,54,55],{},"                .queryEmbedding(model.embed(query).content())\n",[37,57,59],{"class":39,"line":58},4,[37,60,61],{},"                .maxResults(3)\n",[37,63,65],{"class":39,"line":64},5,[37,66,67],{},"                .build());\n",[37,69,71],{"class":39,"line":70},6,[37,72,74],{"emptyLinePlaceholder":73},true,"\n",[37,76,78],{"class":39,"line":77},7,[37,79,80],{},"String context = hits.matches().stream()\n",[37,82,84],{"class":39,"line":83},8,[37,85,86],{},"        .map(m -> m.embedded().text())\n",[37,88,90],{"class":39,"line":89},9,[37,91,92],{},"        .collect(Collectors.joining(\"\\n\"));\n",[37,94,96],{"class":39,"line":95},10,[37,97,98],{},"\u002F\u002F context 拼进 prompt，扔给 ChatModel，完事\n",[15,100,101,102,106],{},"然后我拿它自测了一晚上。知识库里 87 条场馆规则（开闭馆时间、收费说明、退改规则），我编了 60 个问题轮流问，目测准确率不到一半。剩下的不是答错，是",[103,104,105],"strong",{},"一本正经地胡说八道","——问\"能不能带宠物进场\"，知识库里根本没有这条，它照样给你编一段\"宠物需装笼携带\"。",[15,108,109],{},"问题出在哪？排查了一晚上，定位到三个，一个比一个隐蔽。",[10,111,113],{"id":112},"坑一不设-min-score模型永远有话可说","坑一：不设 min-score，模型永远\"有话可说\"",[15,115,116,117,120],{},"向量检索有个反直觉的特性：",[103,118,119],{},"它永远返回 top-k，哪怕库里全是垃圾","。相似度 0.3 的片段也是 top3，照样被拼进 prompt，而大模型面对三段不相关文本的标准反应不是\"我不知道\"，是硬着头皮编。",[15,122,123,124,127],{},"修复：搜索时加 ",[19,125,126],{},"minScore"," 阈值，低于阈值视为无命中，直接走兜底话术\"知识库暂无此信息，已记录您的问题\"。",[28,129,131],{"className":30,"code":130,"language":32,"meta":33,"style":33},".maxResults(3)\n.minScore(0.72)   \u002F\u002F 这个值是拿 60 个测试问题试出来的，不是拍脑袋\n",[19,132,133,138],{"__ignoreMap":33},[37,134,135],{"class":39,"line":40},[37,136,137],{},".maxResults(3)\n",[37,139,140],{"class":39,"line":46},[37,141,142],{},".minScore(0.72)   \u002F\u002F 这个值是拿 60 个测试问题试出来的，不是拍脑袋\n",[15,144,145,146,149],{},"上线之后，\"我不知道\"的回答占比上来了。从产品报表看这是\"回答率下降\"，实际是幻觉率下降——",[103,147,148],{},"敢于承认不知道，是 RAG 系统走向可用的第一步","。",[10,151,153],{"id":152},"坑二top-k-不是越大越好","坑二：top-k 不是越大越好",[15,155,156,157,160],{},"第二个想当然的优化：把 ",[19,158,159],{},"maxResults"," 从 3 调到 5，\"给模型更多上下文总没坏处吧\"。结果命中率没涨多少，答案质量肉眼可见地下降。看了一批 bad case，模式很一致：3 个强相关片段 + 2 个弱相关片段，模型开始在几个来源之间和稀泥，把两条不同场馆的规则揉成一条答案。",[15,162,163,164,166,167],{},"调回 3，把 ",[19,165,126],{}," 卡严，效果反而最好。教训：",[103,168,169],{},"top-k 是给召回的，不是给生成的；弱相关的上下文不是信息，是噪声。",[10,171,173],{"id":172},"坑三切片把表格切碎了","坑三：切片把表格切碎了",[15,175,176],{},"场馆价目表是 markdown 表格，早期切片按固定 token 数硬切，把\"周一至周五 | 20 元\u002F小时\"和表头切进了两个片段。向量库里每片单独看都人话不是，检索回来模型只能瞎猜。",[15,178,179,180,183],{},"修复：放弃按字数切，按",[103,181,182],{},"知识条目","切——一行表格、一个 Q&A 对、一条规则，就是一个最小检索单元，宁可片段长短不齐。顺便在每片 metadata 里塞了场馆 ID 和分类标签，后来做召回过滤（\"只在这个场馆的知识里找\"）全靠它。",[10,185,187],{"id":186},"意外之喜命中来源可视化","意外之喜：命中来源可视化",[15,189,190,191,194,195,198],{},"排查调参问题的过程中，我给 ",[19,192,193],{},"RagAnswerVO"," 加了个 ",[19,196,197],{},"sources"," 字段，每条命中带标题、标签、相似度分数、命中文本片段——本来纯粹是给自己调试用的。",[28,200,202],{"className":30,"code":201,"language":32,"meta":33,"style":33},"public class RagSourceVO {\n    private String title;       \u002F\u002F 知识条目标题\n    private List\u003CString> tags;\n    private Double score;       \u002F\u002F 相似度\n    private String snippet;     \u002F\u002F 命中片段\n}\n",[19,203,204,209,214,219,224,229],{"__ignoreMap":33},[37,205,206],{"class":39,"line":40},[37,207,208],{},"public class RagSourceVO {\n",[37,210,211],{"class":39,"line":46},[37,212,213],{},"    private String title;       \u002F\u002F 知识条目标题\n",[37,215,216],{"class":39,"line":52},[37,217,218],{},"    private List\u003CString> tags;\n",[37,220,221],{"class":39,"line":58},[37,222,223],{},"    private Double score;       \u002F\u002F 相似度\n",[37,225,226],{"class":39,"line":64},[37,227,228],{},"    private String snippet;     \u002F\u002F 命中片段\n",[37,230,231],{"class":39,"line":70},[37,232,233],{},"}\n",[15,235,236,237,240],{},"后来前端同学顺手把它渲染成了\"回答依据\"的折叠面板，用户点开就能看到 AI 是根据哪条规则回答的。上线后用户反馈出乎意料：",[103,238,239],{},"信任感完全不一样","。\"AI 说退订要扣 30%\"和\"AI 根据XX体育馆退改规则第 3 条说退订要扣 30%\"，是两种产品。做 to C 问答的，这个功能强烈建议保留。",[10,242,244],{"id":243},"增量索引运营改一条知识总不能全量重建","增量索引：运营改一条知识，总不能全量重建",[15,246,247],{},"知识库进了后台运营流程后，新问题来了：运营改了一条开闭馆时间（因为场馆装修），问我要不要全量重建向量库。87 条无所谓，8700 条呢？",[15,249,250,251,254,255,258],{},"于是有了 ",[19,252,253],{},"RagIncrementalIndexService","：按知识条目的业务 ID 做单条增删改。这里面有个不显眼的坑：",[103,256,257],{},"幂等","。同一条知识重复 embed 会产生重复向量，光删向量 ID 没用，必须按业务 ID 删旧再插新，否则检索时同一条知识占两个坑，还挤掉了别人的召回位。",[10,260,262],{"id":261},"中文场景的分水岭实体别名","中文场景的分水岭：实体别名",[15,264,265],{},"以上都是 RAG 的通用功课，下面这个是中文业务场景特有的，也是我觉得最值得写的。",[15,267,268,269,272,273,276,277,280],{},"用户问\"羽毛球馆晚上开到几点\"。RAG 检索这一步没问题——语义检索把\"羽毛球\"相关规则召回了。但我们的架构是 ",[103,270,271],{},"Agent 编排工具","：",[19,274,275],{},"askGymKnowledge"," 查知识库，",[19,278,279],{},"queryVenues"," 查数据库，而后者要按场馆名查库。库里的场馆叫\"风雨球场-羽毛球专区\"，用户嘴里的\"羽毛球馆\"在数据库里查无此人。",[15,282,283,284,287],{},"语义检索救不了解决这个：",[103,285,286],{},"embedding 擅长模糊的语义匹配，业务数据库需要精确的实体匹配","。中间必须有一层归一化：",[28,289,294],{"className":290,"code":292,"language":293},[291],"language-text","用户口语实体 --(HanLP 分词 + 别名词典)--> 标准实体 --(模糊匹配兜底)--> 数据库主键\n","text",[19,295,292],{"__ignoreMap":33},[15,297,298,299,302,303,306],{},"落地是两个类：",[19,300,301],{},"AgentTextNormalizer"," 在工具调用前跑一遍，用 HanLP 分词 + 运营维护的别名词典（\"羽毛球馆\u002F羽毛球场地\u002F风雨球场\"都是同一个场馆）做实体归一；",[19,304,305],{},"FuzzyMatchUtils"," 做最后一层模糊匹配兜底，处理用户输入\"风雨球场\"漏字、错字的情况。别名词典允许运营在后台自助维护，新别名当场生效。",[15,308,309,310,313],{},"这套东西不起眼，代码量也不大，但",[103,311,312],{},"它直接决定了中文场景下 Agent\"好不好用\"","——用户不会按数据库的命名规范说话，这层翻译缺失的时候，Agent 的每一次实体查询都是一次抽奖。",[10,315,317],{"id":316},"job-agent-的升级混合检索以及不该走向量的东西","Job-Agent 的升级：混合检索，以及\"不该走向量的东西\"",[15,319,320,321,324],{},"到 Job-Agent，知识库换成了面试题库、公司信息、求职方法论，规模上来之后纯向量检索的短板开始显现：",[103,322,323],{},"公司名、技术术语这类精确词，BM25 式的关键词检索反而更准","（\"字节跳动\"四个字做 embedding 之后，和\"抖音集团\"的语义距离近得离谱）。所以 Job-Agent 上了混合检索（向量 + 关键词双路召回，RRF 融合），再叠一层重排序，把召回质量问题压下去一截。",[15,326,327,328,331],{},"另一个更重要的认知：",[103,329,330],{},"简历不是知识，是结构化数据","。一开始我们把解析后的简历全文切片入库走向量检索，效果稀烂——检索\"五年 Java 经验\"召回的是描述长得像的项目经历，而不是\"工作年限=5\"这个字段。后来彻底重构：Apache Tika + POI + PDFBox 三个引擎做文档解析（PDF 简历、Word 简历、扫描件的兼容性各有各的坑，多引擎互为兜底），解析出的结构化字段（年限、技能、教育经历）进 MySQL 走精确查询，只有真正非结构化的部分（项目经历描述、自我评价）才走向量库。",[10,333,334],{"id":334},"小结",[15,336,337],{},"RAG 这事，quickstart 到能用之间隔着一整条调参和工程化的路。如果只能带走三句话：",[339,340,341,351,357],"ol",{},[342,343,344,346,347,350],"li",{},[19,345,126],{}," 比你想的重要，",[103,348,349],{},"让系统敢于说\"不知道\"","；",[342,352,353,354,350],{},"中文业务场景，",[103,355,356],{},"实体别名归一化是语义检索和业务数据库之间必补的一层",[342,358,359,360,149],{},"结构化数据和知识别混着放，",[103,361,362],{},"该进字段的进字段，该走向量的走向量",[15,364,365],{},"顺带一提，后来我们还打通了 MCP 文档导入知识库的链路，外部文档能一键入库存证——这个以后有机会单写。最后一篇，聊聊这一年里最硬核的几个工程化暗坑。",[367,368,369],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":33,"searchDepth":52,"depth":52,"links":371},[372,373,374,375,376,377,378,379,380],{"id":12,"depth":46,"text":13},{"id":112,"depth":46,"text":113},{"id":152,"depth":46,"text":153},{"id":172,"depth":46,"text":173},{"id":186,"depth":46,"text":187},{"id":243,"depth":46,"text":244},{"id":261,"depth":46,"text":262},{"id":316,"depth":46,"text":317},{"id":334,"depth":46,"text":334},"LLM 应用","\u002Fimages\u002Fcovers\u002Frag-pgvector-in-production.jpg","2025-12-15","从二十行 Demo 到生产可用的距离",false,"md",{},"\u002Farticles\u002Frag-pgvector-in-production",{"title":5,"description":384},"articles\u002Frag-pgvector-in-production",[392,393,394,395],"RAG","pgvector","检索调优","中文 NLP",null,"X2ZVuk2PFynvH8APGuFLGir32QanfkyW6z911B0EXv8",[399,403],{"title":400,"path":401,"date":402},"Agent 工程化暗坑实录：CGLIB 代理、手动日志、Guardrails 与 Eval","\u002Farticles\u002Fagent-engineering-pitfalls","2026-01-26",{"title":404,"path":405,"date":406},"Agent 的记忆，不止 ChatMemory：从五轮对话到带冲突检测的记忆系统","\u002Farticles\u002Fagent-memory-pipeline","2025-11-20",1792063306798]