知识库边导入边检索,如何隔离资源争用
文章提出 PostgreSQL 检索边界设计:解析、分块和嵌入异步执行,检索只读取已发布的文档版本,并强调背压机制。作者同时建议 Agent 记忆先经过导入验证,避免检索自身未经验证的输出;方案尚非实测结论。
AI 深度解读
知识库还在导入,线上 Agent 的检索却越来越慢,问题未必出在向量索引。原文讨论的是:当数据写入和用户查询共用 PostgreSQL,怎样限制导入压力、守住检索延迟,并确保搜到的内容已经具备使用资格。
作者以外部集成者身份评估 Tencent/WeKnora,提出了一套检索边界方案。**这不是 WeKnora 的故障报告,也不是经过压测验证的性能结论。**它的价值在于把教程里常被省略的并发、发布状态、租户过滤和召回验证放到了一起。
文档入库,为什么不能跟着回答请求一起跑
作者建议把解析文档、切分文本、生成 embedding 全部交给异步 worker。worker 写入带版本的文本块,检索只读取已经发布的文档版本,生成模型负责根据检索内容及其来源回答问题。
这里需要区分「写入成功」和「可以被搜索」。一份文档可能只处理了一半,也可能正在重新生成向量。如果每个文本块一落库就对外可见,用户可能搜到不完整内容,或者同一文档的新旧版本混在一起。
Agent 的记忆也应该经过这条链路。模型提出的新记忆先进入入库流程,通过验证后再发布,避免 Agent 立即搜到自己尚未验证的输出,随后把它当作证据反复强化。
模型替换也要沿着这个边界拆开。更换 embedding 模型需要建立新的向量空间并重新索引;更换生成模型的调用端点,则不应该触发这些操作。作者提出在生成基准测试中使用可选的 B-Lost /v1 网关,但明确把它排除在 embedding 生成与数据库计时之外,其可用性、认证和模型兼容性仍需验证,文中没有相关测量结果。
检索从 80ms 涨到 4 秒,先查什么
原文用凌晨 3:17 的故障重建引出问题:导入 worker 不断重试文本块,检索延迟从 80ms 涨到 4 秒。这个场景用于解释风险,并非已经报告的 WeKnora 事故。
已有大量数据的 HNSW 索引继续接收并发插入,可能产生内部索引页锁竞争。但只说「索引被锁了」,还不足以定位原因。作者建议把 PostgreSQL 等待事件 pg_stat_activity.wait_event_type、wait_event,与导入并发、WAL 写入量、shared_buffers 压力和查询计划对应起来,再通过 pg_stat_statements 查看插入耗时,通过 pg_locks 查阻塞。
磁盘达到 IOPS 上限、写队列堆积,以及无关事务持锁,都可能制造相似症状。
处理方向是先限制压力:用信号量或 worker pool 约束并发,每个事务控制在 500-1000 行,而不是一次塞入 50,000 行;失败后使用带随机抖动的指数退避,避免所有 worker 同时重试。
首次批量导入可以先装载数据,再建立 HNSW,减少写放大。已经承接线上查询的表,则需要单独安排 CREATE INDEX CONCURRENTLY,避免普通建索引操作阻塞读取。初始化也应与服务流量分开执行。
连接数同样需要边界。原文给出的选择包括 transaction mode 的 pgbouncer,或应用连接池配置 pool_size=20、max_overflow=10。这些值是方案示例,不能直接当作所有团队的容量标准。
另一类问题会被误诊为「向量坏了」。采用内积排序时,归一化不一致可能让向量长度干扰相关性;采用余弦距离时,普通正比例缩放不会改变排名。排查还应覆盖 OpenAI text-embedding-3-small 与 ada-002 的模型版本差异、补齐或截断到 768 维的处理、零向量、异常输入产生的 NaN,以及过滤条件。
查询端和入库端必须使用相同的预处理规则。
混合检索能补漏,但租户过滤会改变成本
作者提供的检索示例依赖 Python 3、psycopg[binary]、PostgreSQL 和 pgvector 0.8 或更新版本。完整脚本保存为 retrieve.py,通过 DATABASE_URL 连接数据库,先运行 python retrieve.py init,再运行 python retrieve.py query tenant-uuid embedding.json search words。
其中 embedding.json 必须包含来自同一 embedding 模型的 768 个数值。校验逻辑检查维数、有限数值和零向量,并执行归一化;入库端也要执行相同处理。
表结构使用 vector(768),HNSW 采用 vector_cosine_ops,构建参数为 m = 16、ef_construction = 128。文本检索则使用 to_tsvector('english', content) 生成搜索字段,并建立 GIN 索引。
下面这段原样摘录展示了向量查询如何同时设置扫描预算和租户过滤:
conn.execute("SET LOCAL hnsw.ef_search = 100")
conn.execute("SET LOCAL hnsw.iterative_scan = strict_order")
conn.execute("SET LOCAL hnsw.max_scan_tuples = 20000")
dense = conn.execute("""
SELECT id, content
FROM memory_chunk
WHERE tenant_id = %s::uuid
ORDER BY embedding <=> %s::vector
LIMIT 50
""", (tenant, vector)).fetchall()
要重点看 WHERE tenant_id = %s::uuid:它限制返回结果,却不保证 HNSW 只访问该租户的向量。搜索可能先遇到大量其他租户的数据,再找到符合条件的候选。strict_order 迭代扫描有助于继续寻找,但 hnsw.max_scan_tuples = 20000 的预算仍然有限。
因此,作者提出:单租户数据少于 10,000 行时,先按租户过滤、再精确计算距离,可能比过滤开销很高的近似搜索更快、更完整。这是需要验证的选择,原文没有给出对照数据。
另一条检索路径使用 websearch_to_tsquery('english', %s) 和 ts_rank_cd 返回词法候选,也取前 50 条。两路结果通过倒数排名融合(RRF)合并,默认 k=60,最终返回 10 条。
RRF 按名次分配分数,避免直接相加量纲不同的文本分数和向量距离。它可以补充 embedding 容易漏掉的标识符、精确短语,但两路都没召回的内容,它也救不回来。效果应当通过标注评测集上的 recall@10 判断。
我的判断
我认可这套方案对职责边界的划分:入库负责把内容准备好,检索负责读取合格内容,生成负责回答。它也正确指出,仅凭接口返回得快,无法证明检索系统可靠。
不过,示例距离完整实现还有明确缺口。表里没有文档版本和发布状态,作者也说明这些属于外围入库契约。团队若直接复制代码,依然需要实现「哪些版本可以被搜索」的规则。示例还固定使用 english 全文检索配置,原文没有说明中文文档应如何处理。
租户条件也不等于权限校验。tenant_id 应从 JWT claims 或 session middleware 等认证上下文派生;多个租户共用表时,按隔离要求配置行级安全策略(RLS),不能直接信任客户端提交的租户标识。
至于 500-1000 行批次、10,000 行以下考虑精确检索,以及连接池参数,都可以作为实验起点。作者没有提供硬件、数据分布和负载条件下的系统对照,不能替这些数值背书。
文末提到的两种策略也属于经验之谈:有团队在检索 p99 超过 500ms 时暂停上传,也有团队接受批量导入期间 2 秒的检索延迟。它们对应不同的产品取舍,原文没有证明哪一种更稳。更有价值的验证方式,是在持续入库时,用租户过滤后的精确查询对照近似检索,同时检查延迟和召回。
能拿来做什么
- 如果正在用 Node 接知识库上传,把解析、切块和 embedding 放进有并发上限的后台队列,并明确文档完成什么检查后才能发布给检索。
- 给入库重试加入指数退避和随机抖动,将批次大小与 worker 数量设为可调参数,再结合数据库等待事件判断压力来自哪里。
- 为混合检索准备包含标识符、精确短语的标注查询集,在持续入库期间同时检查
recall@10和检索 p99,并与精确查询比较。 - 如果 Agent 支持保存记忆,让新记忆先经过验证和发布流程;更换 embedding 模型时单独规划向量空间与重建索引。