
上线第三周我接到产品转来的用户反馈问员工食堂的餐补标准是多少系统答非所问回了一大段考勤制度。我第一反应是检索出了问题立刻翻了 pgvector 的召回日志top5 里确实没有那条餐补标准。可当我手动把这句话向量化、再用 SQL 去查的时候餐补相关文档明明排在第二位。问题不在数据库不在向量化而在于问和存这两端根本没对齐。这个案例我复盘了很久也是这篇手记想讲清楚的核心RAG 落地最大的坑从来不在检索环节而在数据、查询、生成三个层面上的对齐工作。如果你正在用 pgvector 搭知识库问答或者被检索效果差反复折磨这篇内容应该能帮你少走几周弯路。1. 排障故事答案明明在库里系统为什么答不出来1.1 早期排查把检索参数调了一轮几乎没用先说下背景那是一个企业内部制度问答系统大概一千多份制度文档用 PostgreSQL 14.24.2 加 pgvector 0.6.x 做向量存储HNSW 索引文档按固定长度切片后灌入向量库。上线前我们做了基础功能测试看起来都正常常见问题都能答出来。结果一面对真实用户问题马上暴露。用户问的是食堂餐补怎么申请系统返回的引用来自差旅费报销制度完全没有命中员工食堂管理办法这份文档。我的第一反应是向量检索的召回范围太小于是做了几件事把ef_search从默认值调到 200把 top_k 从 3 调到 10还把相似度阈值从 0.75 一路降到 0.6。结果呢相关片段确实进入了候选集但排名还是靠后排在差旅、报销、考勤这些制度后面。更诡异的是如果我直接在数据库里用LIKE %餐补%查原文能查到员工食堂管理办法里有大量相关段落。数据没有丢检索也没坏但它就是排不上来。1.2 问题反转真正的错位不在距离计算在数据形态我把那条用户 query 和召回的文本片段放在一起对比才发现问题出在哪。相关文档里写的是误餐补助员工福利就餐补贴而用户输入的是食堂餐补这种口语化缩写。两边在字面上几乎没有重叠语义上虽然接近但 embedding 模型把它们映射到向量空间之后距离并没有想象中那么近。而差旅制度为什么会被召回因为它的开头段落写着为规范员工福利与费用报销管理……从整段语义上看它和食堂餐补的相似度反而更高。这件事让我意识到检索链路里真正的不确定性不在 pgvector 计算距离的那一下而在三段转换上原始文档转成 chunk 再转成向量、用户的问法被映射到向量空间、以及召回结果拼接后交给大模型。每一段转换都会引入错位。我把它们归纳成三层对齐第一层数据形态对齐切片和清洗方式决定了文档能不能被问中。第二层查询语义对齐用户的表达方式能不能和文档的表达方式对上。第三层上下文装配对齐召回结果怎么组织大模型才不会被带偏。理解了这三层之后后面的所有调整就都有了方向。以前调参数是瞎试现在每改一个地方都知道自己在解决哪一层的问题。2. pgvector 起步时的工程细节版本、索引和入库节奏聊对齐之前有必要先把 pgvector 本身的基础工程问题说清楚。很多人在第一步就卡住后面全白搭。2.1 Windows 环境安装版本匹配是第一个坑Windows 上装 pgvector 的教程很多但大家最容易踩的坑是版本匹配。pgvector 是以 PostgreSQL 扩展的形式运行的它的二进制文件和你的 PostgreSQL 大版本必须严格对应。假如你装的是 PostgreSQL 14.24.2那 pgvector 也要选针对 PG 14 编译的版本拿 PG 16 的扩展文件放到 PG 14 里百分百报错。我的安装步骤是先在数据库里执行SELECT version();确认 PostgreSQL 的大版本号。到 pgvector 的 GitHub Releases 页面下载对应的 Windows 二进制包注意选择与 PG 大版本匹配的文件。解压后把vector.control和vector--x.x.x.sql复制到 PostgreSQL 安装目录下的share/extension文件夹把vector.dll复制到lib文件夹。重启 PostgreSQL 服务然后在目标数据库里执行CREATE EXTENSION vector;。下面这个表是几个常见报错的定位方法报错信息常见原因处理方式could not open extension control file vector.control控制文件没放对位置或者解压目录层级不对确认文件在share/extension下文件名不能带额外路径could not load library C:/.../vector.dll扩展版本与 PG 大版本不匹配或者位数不一致换用与 PG 版本匹配的 pgvector 版本type vector does not exist没执行CREATE EXTENSION或者连到了错误的数据库在目标业务库执行扩展创建不是默认的 postgres 库Linux 上就简单多了Ubuntu 可以直接apt install postgresql-14-pgvector或者从源码编译跑一遍make make install就行。源码编译时记得先确认pg_config指向的是你要用的 PostgreSQL 实例。2.2 HNSW 还是 IVFFlat这个取舍要按数据形态来pgvector 提供两种索引HNSW 和 IVFFlat。很多新手一上来就照着文档写USING hnsw其实应该先想清楚自己的数据量级和查询场景。HNSW 的优点是召回率高、查询稳定缺点是索引构建时内存占用大、构建时间长。IVFFlat 构建快、省内存但召回率略低需要调lists和probes两个参数。我的经验是十万元素以内无脑选 HNSW省心数据量到百万级别、机器内存又比较紧张才考虑 IVFFlat 或者用 pgvector 0.7.0 之后支持的halfvec半精度向量来降低内存占用。建索引的参考配置CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);m表示每个节点的最大连接数ef_construction是构建时搜索的候选范围。起步阶段这两个值不用太大等基础链路跑通再针对性调。查询时可以在事务里调整ef_search这个值控制查询时的搜索广度越大召回越全、延迟越高BEGIN; SET LOCAL hnsw.ef_search 100; SELECT id, text, 1 - (embedding $1) AS similarity FROM kb_chunks ORDER BY embedding $1 LIMIT 5; COMMIT;2.3 向量化入库的节奏控制还有一件事经常被忽略大批量向量化入库的节奏。Embedding 模型生成向量的速度远慢于数据库写入尤其在本地用 CPU 跑模型的时候几百篇文档可能要跑好几个小时。我建议按批次处理每批 32 条左右向量生成一批就写入一批避免一次性把所有向量都存在内存里。一个简化版的入库脚本import psycopg2 from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-large-zh-v1.5) conn psycopg2.connect(dbnamerag userpostgres hostlocalhost) cur conn.cursor() batch [] for chunk in chunks: vec model.encode(chunk.text).tolist() batch.append((chunk.doc_id, chunk.chapter_path, chunk.text, vec)) if len(batch) 32: cur.executemany( INSERT INTO kb_chunks(doc_id, chapter_path, text, embedding) VALUES(%s, %s, %s, %s), [(b[0], b[1], b[2], b[3]) for b in batch] ) conn.commit() batch.clear()另外切记先入库后建索引。如果你的数据有几十万条建索引又是几分钟到十几分钟的事情顺序反了会导致写入时索引反复调整整体慢到怀疑人生。3. 第一层对齐切片必须在写入前面向问题做好形态设计这是全文我认为最重要的一层也是绝大多数检索效果差问题的根源。3.1 固定字数切块为什么总是让答案消失最开始的系统切片策略是每 300 字切一块重叠 50 字。看起来没什么问题直到用户问年假可以顺延到下一年吗系统答不出来。我去翻原始文档里面明确写着当年未休年假可顺延至次年6月31日前使用但这句话在按字数切块时被从中间劈开了上半句在前一个块下半句在后一个块。单独看哪个块都称不上一个完整的答案。规律是这样的用户的问题天然是一个完整语义单元如果你的 chunk 不是围绕完整语义单元切分的那向量化之后每个片段都在表达一个半句话和问题之间的语义距离自然就远。固定字数切块的问题在于它完全无视文本结构随机切断句子的概率很高。更合理的做法是按语义边界切分Markdown 的一级/二级标题、段落、列表项、表格这些天然就是语义边界。切块时优先在这些边界处断开如果某一段实在太长再用重叠窗口兜底。LangChain 的RecursiveCharacterTextSplitter默认就是从 Markdown 分隔符开始逐级下探的这个方向是对的。我后来干脆自己写了一个递归切分器逻辑不复杂先按一级标题分块每块超过阈值就按二级标题继续切最后按段落切段落仍然超长才按句子切句尾必须落在句号、问号上。三种策略的对比切块策略优点缺点适用场景固定 300 字 重叠实现简单经常切断句子语义不完整纯文本、无结构文档Markdown 边界切分语义完整每个块长度差异大排版规范的制度文档语义边界 元数据 上下文化召回质量最高实现成本高需要额外处理知识库问答、复杂业务场景3.2 清洗和结构还原表格、编号、公式的不对齐问题切块之后紧跟着的坑是清洗。真实文档不是干净的 Markdown它从 PDF 或 Word 转出来之后充满了各种错位问题。最常见的是表格原本一个三列的表格被转成一行一行的纯文本列与列之间的对应关系完全丢失。比如岗位等级/补贴标准/备注的表格转出来变成三行独立的文字前两行看起来像碎句向量化之后很难和补贴标准是多少这个问题对齐。我的处理办法是解析阶段先识别表格区域把表格整体转成 Markdown 表格或字段名值的结构化文本再交给后续流程。公式块要做占位处理避免公式里的特殊符号把整段文本污染掉。页眉页脚、文档编号、目录页这些噪声在解析阶段就要剔除否则每个切块里都混着一堆和正文无关的重复文本向量空间会被这些公共词汇拉平导致不同文档之间的区分度严重下降。这其实是很多系统感觉什么都召回了但什么都不准的隐藏原因。3.3 给每个切片加锚点元数据与上下文化切块做对之后还需要给每个块加上足够的锚点信息。建议字段包括来源文档 ID、标题、章节路径、业务标签、页码、更新时间。这些元数据有两个作用一是支持检索阶段的过滤比如用户指定只看报销制度可以直接用WHERE doc_id expense_policy缩小范围二是生成阶段的溯源给大模型的引用标注提供素材。在此基础上还有一个很值得投入的技巧上下文化检索。原理很简单切出来的每个块往往脱离上下文比如某个块开头是第三条、补贴标准如下如果不看前文根本不知道这个第三条属于哪份制度。处理方式是在写入前让 LLM 为每个块生成一段上下文摘要拼到块的头部或尾部再统一向量化。这个思路来自 Anthropic 的 contextual retrieval亲测对制度文档这种块与块之间上下文依赖强的场景非常有效召回率提升非常明显。4. 第二层对齐查询端如何跟文档说同一门语言数据侧准备好了接下来是查询侧。这一层解决的是用户怎么问和文档怎么写之间的语义缝隙。4.1 相似度排序的盲区措辞差异和语境缺失回到最开始那个案例。食堂餐补和误餐补助在中文里是同一个意思但字面上几乎没有交集。通用 embedding 模型能不能把这两句话映射到相近的位置完全取决于模型的训练语料里有没有见过类似的对齐关系。实测下来很多通用模型对业务内部的黑话、缩写、口语表达识别能力并没有想象中那么强。我踩过的一个具体例子公司内部把预算调整叫调预算制度文档里却写的是预算变更审批流程。用户问调预算找谁审批好几个 embedding 模型都把这个 query 分配到了预算编制相关的文档上而真正包含审批流程的文档排在十几名开外。这让我意识到embedding 模型是 RAG 系统里上限最高的组件也是下限最低的组件。选型时不能只看网上的评测分数必须拿自己业务里真实的 20 到 30 条问题跑一遍手动检查召回质量再决定用哪个模型。4.2 用查询改写解决多轮对话和口语化提问查询侧的第二大问题是多轮对话。用户第一句问公司年假政策是什么第二句问那能顺延吗这个那指代的是年假但如果没有上下文处理系统只会拿那能顺延吗去检索结果自然一塌糊涂。办法是在检索之前加一个查询改写模块把多轮历史压缩成一个独立的检索 query。这个模块可以是一个 LLM 调用也可以用规则加模型的混合方式。我用的是一个简单的改写 prompt给定一段对话历史和一个新的用户问题生成一个可以不依赖上下文、 独立用于知识库检索的查询语句。 要求 1. 保持原意补充指代对象如它这个那边 2. 保留业务关键词不要过度扩展 3. 如果当前问题本身已经完整直接原样输出。 对话历史 用户公司年假政策是什么 助手公司年假政策规定累计工作满一年不满十年年休假为 5 天。 当前问题 那能顺延吗 改写结果实际工程里我会先用正则做快速判断如果当前问题包含它这个那这类代词或者句子长度很短就走 LLM 改写否则直接用原文检索省一次大模型调用也省几十毫秒延迟。4.3 混合检索 重排序是性价比最高的调优手段只靠向量检索最大的问题是它对精确匹配不敏感。用户问一个设备型号XH-2000 的保修期向量检索很可能因为文档里写的是XH2000或者型号 XH-2000 型设备而打分不足。这种时候关键词命中的信号比语义相似度强得多。好在 PostgreSQL 自带全文检索能力完全不需要额外引入 Elasticsearch。做法是给文本列加一个tsvector生成列并建 GIN 索引然后在查询时同时跑向量检索和全文检索再用 RRFReciprocal Rank Fusion把两路结果融合。RRF 的公式很朴素每个文档的得分是它在各路结果中排名的倒数之和常用参数k60。def rrf(ranked_lists, k60): scores {} for lst in ranked_lists: for rank, doc_id in enumerate(lst): scores[doc_id] scores.get(doc_id, 0) 1 / (k rank 1) return sorted(scores.items(), keylambda x: -x[1])在 PostgreSQL 里可以先分别检索出两个 top 20 的 ID 列表回到应用层做 RRF再取融合后的 top 10最后用重排序模型或者 LLM 对候选做精排。实测下来混合检索对带编号、型号、缩写的问题提升最明显原先完全召回不到的一类问题上线混合检索后命中率明显上升。这个优化只需要一张 GIN 索引加几十行逻辑代码非常划算。5. 第三层对齐召回片段如何装配成 LLM 的可靠上下文检索做对了不等于回答就对。召回结果怎么组织、怎么交给大模型是最后一层对齐。5.1 别把 top5 一股脑塞给 LLM很多人直接把向量检索的 top5 拼成一个字符串塞进 prompt结果回答经常重复、跑偏甚至自我矛盾。问题出在召回片段本身相邻两个切块之间因为重叠窗口会有重复内容不同制度版本对同一件事的规定可能互相冲突这些原始问题不处理大模型只会照单全收然后越绕越乱。我的处理流程是先做相似度去重比如片段之间相似度超过 0.95 的只保留最早出现的一个再按文档内章节顺序重排而不是按相似度降序排列。这是因为给大模型的上下文应该像一篇原文摘录按照文档自己的逻辑顺序排列模型更容易理解前后关系按相似度降序排列的片段来源文档是乱的模型很难拼出一个连贯的答案。最后控制上下文总量一般 1500 到 2500 token 的片段区就够用超出部分宁可不要也不要硬塞。5.2 Prompt 的证据规则怎么写上下文装配的另一个重点是 Prompt 约束。我见过太多默认 prompt 直接说请根据以下内容回答结果模型自由发挥补了一堆知识库里没有的信息。更稳妥的做法是给模型定几条明确的证据规则只根据提供的片段回答禁止使用内部知识补充片段不足以支撑答案时明确说根据现有资料无法确认片段之间相互矛盾时指出矛盾点并分别标注出处回答里涉及数字、日期、金额时必须能在片段里找到对应原文。给片段编号也很有用。我在拼上下文时会为每个片段加上[1][2]这样的编号要求模型在回答中引用对应的编号比如根据[2]中的规定……。这样做除了便于溯源还能明显减少模型编造内容的概率因为一旦编造来源标注就对不上用户很容易发现。5.3 多轮对话里的历史上下文该怎么管多轮对话是第三层对齐里最容易失控的场景。很多实现把整个聊天历史全部塞给模型看起来信息全实际上检索结果和新问题混在一起模型分不清哪些是历史、哪些是当前要回答的。我的做法是每一轮用户提问都先做独立改写保证拿到的检索 query 是自包含的检索之后Prompt 里只带当前问题的召回片段历史对话最多保留最近一两轮而且存放在独立的记忆区不带入检索上下文的拼接过程。系统可以额外记录用户提到的业务实体比如张三的预算调整华南区的报销标准把这些记忆放进下一轮检索的过滤条件里。这样既保留了多轮对话的连续性又避免了历史噪声对当前答案的干扰。6. 从检索效果差到可上线一套可复制的排查和调优流程最后分享一套排查和调优方法这也是我踩了一圈坑之后总结出来的工作节奏。6.1 按症状定位层级别再瞎调参数了遇到检索效果差第一件事不是调ef_search而是先判断问题出在哪一层。我整理了一张排查表症状问题层级快速验证方法处理方向检索结果里根本没有相关文档数据形态对齐用关键词直接查原文确认数据存在检查切块边界确认是否切断句子换 embedding 模型对比相关文档在候选集里但排名靠后查询语义对齐手动改写 query 再检索看排名是否变化加查询改写、混合检索、元数据过滤top5 结果都对但模型答错上下文装配对齐把召回片段人工拼给模型看是否复现问题改 Prompt 证据规则按章节顺序重排去重相似度分数普遍很低模型或维度问题用业务样本跑模型效果测试换 embedding 模型或对文本做清洗增强判断层级的意义在于每一层的修复手段完全不同跨层操作基本无效。我曾经花了一整天调 HNSW 参数最后发现问题是切块把关键段落切碎了白费力气。6.2 用最小评测集把效果差变成可量化指标调优最怕没有基线。现在每做一个 RAG 项目第一天我就会建一个最小评测集从业务方收集 50 条真实用户问题逐条人工标注正确答案出自哪份文档的哪个片段再用这 50 条跑出三个指标。命中率正确答案所属片段是否出现在 top5 召回结果里忠实度模型回答的要点是否全部能在召回片段里找到原文依据无关率模型回答中是否存在召回片段之外的额外信息。这三个指标把效果差从模糊的感觉变成了可以对比的数字。每次改动切块策略、查询改写、重排序逻辑都跑一遍评测集数值往上涨就保留掉下来就回滚。评测集一周跑一次防止后续改动引入回归。6.3 迭代与上线管理最后说一句关于迭代节奏的体会embedding 模型不要频繁更换。换一次模型全库重新向量化可能要跑一天一夜而且新旧向量不能混用否则检索质量会崩。我的流程是先拿旧库和新模型分别对评测集跑召回确认新模型在业务样本上确实更优再排期全量重建。切块参数、检索参数、Prompt 模板这些配置全部纳入版本管理。我习惯把这些配置写成 YAML 文件随代码一起提交每次改动都有历史记录。上线新检索策略时先走影子模式检索结果打到日志里但线上回答仍然用旧策略人工对比一周再切换。很多上线即翻车的问题都是跳过了这一步直接全量切换导致的。最后分享一个让我印象深刻的细节有一次调完混合检索之后我开始观察用户的重试行为发现了一个以前完全没注意到的现象很多用户第一次提问得不到满意答案后会用更口语化、更简短的方式重新问一遍比如第一次问员工食堂的误餐补贴发放标准是什么第二次问食堂饭补多少钱。这说明用户本身就在进行查询改写。如果系统能识别这种重试行为在用户第二次提问时自动把前一次提问和当前问题结合起来重新检索效果会比被动等待用户自己重新组织语言好得多。受这个观察启发我在系统里加了一个规则同一会话内如果用户连续两次提问的向量相似度较高就把两次问题合并成一条完整 query 再走一次检索。这个功能上线后用户二次提问的满意度提升了不少。RAG 项目做到最后你会发现参数、索引、模型都是明面上的功夫真正决定体验的是那些文档和用户之间、历史和当下之间、召回和生成之间的对齐细节。把这些细节当作产品功能来打磨检索效果自然不会差。