夜雨聆风学习资料网

ARTICLE · 1059470

从 10 万文档到毫秒级检索:用向量数据库构建生产级 RAG 后端的工程实践

从 10 万文档到毫秒级检索:用向量数据库构建生产级 RAG 后端的工程实践

从 10 万文档到毫秒级检索:用向量数据库构建生产级 RAG 后端的工程实践

能上线的 RAG,不是 PDF → Embedding → Vector DB → LLM 四个方框。

它是一条数据生产线:上传、解析、结构恢复、分块、版本、索引、权限、召回、引用、观测和重建。任何一步没有工程边界,模型就会在“资料明明存在”的情况下答错,甚至泄露资料。

本文以一个脱敏的典型企业知识库场景贯穿说明。它不是虚构的性能神话:假设某理财服务企业将产品说明书、合同条款、客服 FAQ 和内部制度接入知识库;约 10 万份文档、120 万个 chunk、多人多部门权限、每日覆盖上传。文中的时间预算和组件是设计目标,必须以自己的语料和压测结果为准。


一、先看事故:换模型解决不了被切碎的表格

客服问:

“年化收益率超过 5% 的产品有哪些?对应的风险等级是什么?”

原始 PDF 的第 7 页有完整表格,但系统的固定字符分块结果如下:

Chunk 41: 产品名称 | 风险等级 | 年化收益率
Chunk 42: 稳健理财 A | R2
Chunk 43: 5.2
Chunk 44: %

团队先后更换 Embedding、加 Reranker、扩大 top_k、改 Prompt,效果都不明显。原因很简单:检索系统里根本不存在一条完整事实。

正确的索引单元至少应是:

[理财产品收益率表|第 7 页]
产品:稳健理财 A|风险等级:R2|年化收益率:5.2%

这次事故带来三个结论:

  1. 1. RAG 的首要质量门是解析与分块,不是模型大小。
  2. 2. 每个 chunk 必须能独立充当证据,并可回到原文页码。
  3. 3. 排查必须沿着“原文 → 解析结果 → chunk → 召回 → 重排 → 上下文”逐段进行。

二、先定义可验收目标

“10 万文档”和“毫秒级”都不够具体。本文的参考目标如下:

项目
参考值
说明
文档规模
100,000
PDF、DOCX、HTML 混合
平均分块数
12 / 文档
实际取决于页数、表格和策略
索引规模
约 120 万 chunk
比文档数更有意义
向量
1024 维 float32
裸向量约 4.58 GiB,不含索引和副本
在线检索 P95
100~250 ms
含鉴权、Embedding、双路召回、重排;不含 LLM
权限目标
0 条越权证据
要作为硬门禁,而非平均指标

将在线时延拆开,才知道该优化哪里:

Auth / ACL          15 ms
Query Embedding     70 ms
Dense Retrieve      45 ms
Lexical Retrieve    35 ms
Fusion + Rerank     70 ms
Context Builder     15 ms
-------------------------
Retrieval P95      250 ms

LLM TTFT / 完整回答:另行统计

不要将“LLM 在 4 秒后答完”宣传为毫秒级检索。


三、架构:将离线数据面与在线查询面彻底拆开

                          ┌──────────── 控制面 ────────────┐
                          │ 文档版本 / 索引 generation      │
                          │ ACL policy version / Golden Set │
                          └──────────────┬─────────────────┘
                                         │
上传 API ──> 对象存储 ──> Metadata DB + Outbox ──> Kafka
                       │                         │
                       └── orphan scanner        ▼
                                      Parse -> Quality Gate -> Chunk -> Embed -> Index
                                                                      │
                                                              validate / publish

用户 ──> 鉴权 ──> Query API ──> ACL Filter ──> Dense + Lexical
                                                    │
                                             Fusion -> Rerank
                                                    │
                                             Context + Citation -> LLM

核心原则只有两句:

  • • 离线数据面追求可重放、可校验、可恢复;OCR、解析、Embedding 都不能放进同步请求。
  • • 在线查询面追求低延迟与严格权限;LLM 不应看见任何未授权文本。

四、案例中的版本问题:覆盖上传不能半成品上线

产品部上传了《2026 年产品说明书 v8》,但 Parser 升级后重建耗时 20 分钟。如果系统边删旧 chunk 边写新 chunk,用户会在这 20 分钟内查到不完整甚至混合版本的答案。

因此需要区分三个对象:

Document             稳定业务对象,例如“稳健理财 A 说明书”
Document Version     一次上传的不可变原件,例如 v8.pdf
Index Generation     一次 parser/chunk/embed 组合的索引产物

4.1 最小数据模型

CREATE TABLE documents (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    external_key VARCHAR(255NOT NULL,
    active_version_id UUID,
    created_at TIMESTAMPTZ NOT NULLDEFAULT now(),
UNIQUE (tenant_id, external_key)
);

CREATE TABLE document_versions (
    id UUID PRIMARY KEY,
    document_id UUID NOT NULLREFERENCES documents(id),
    tenant_id UUID NOT NULL,
    version_no BIGINTNOT NULL,
    object_key TEXT NOT NULL,
    sha256 CHAR(64NOT NULL,
    status VARCHAR(24NOT NULL,
    created_at TIMESTAMPTZ NOT NULLDEFAULT now(),
UNIQUE (document_id, version_no)
);

CREATE TABLE index_generations (
    id UUID PRIMARY KEY,
    document_version_id UUID NOT NULLREFERENCES document_versions(id),
    parser_version VARCHAR(64NOT NULL,
    chunk_version VARCHAR(64NOT NULL,
    embedding_version VARCHAR(64NOT NULL,
    target_index VARCHAR(128NOT NULL,
    status VARCHAR(24NOT NULL,
    expected_chunk_count INT,
    indexed_chunk_count INTNOT NULLDEFAULT0,
    checksum CHAR(64),
UNIQUE (document_version_id, parser_version, chunk_version, embedding_version)
);

tenant_id 在子表中看似冗余,但便于行级安全、索引过滤和审计;应用必须校验它与父对象一致。

4.2 状态机比一个 SUCCESS 字段可靠

DocumentVersion
UPLOADED -> PARSING -> PARSED -> BUILDING -> READY -> ACTIVE -> SUPERSEDED
                         |                    |
                         └------ FAILED <-----┘

IndexGeneration
BUILDING -> VALIDATING -> READY -> ACTIVE -> RETIRED
                |
                └-> FAILED

规则是:

  • • READY 意味着所有产物已写入且通过完整性检查;它尚不可查询。
  • • 查询只读当前 ACTIVE generation;同一业务文档任一时刻仅一个有效版本。
  • • 新 generation 失败时,旧 generation 继续服务。
  • • 旧 generation 在回滚窗口结束后再清理,绝不在重建开始时删除。

4.3 发布与回滚流程

v7 ONLINE ──> 创建 v8 BUILDING ──> 全量生成 chunk/向量
                                      │
                                      ├─ count/checksum
                                      ├─ Golden Set
                                      └─ 真实过滤压测
                                             │
                                      alias/route 原子切至 v8
                                             │
                                    v7 保留回滚窗口 -> RETIRED

PostgreSQL 与外部向量库无法组成普通的跨库事务。因此要记录 activation_event,并用 reconciler 比对“数据库当前 generation”与“外部索引 alias”。任何中断都能由它收敛,而不是靠人工猜索引当前指向何处。


五、上传链路:不要假设 S3 和 Kafka 可以一起提交

下面的代码看似事务化,实际上只事务化了 Kafka:

producer.begin_transaction()
upload_to_s3(file)
producer.produce("doc.ingest", event)
producer.commit_transaction()

如果对象已上传、进程在发消息前退出,文件会永远躺在对象存储而没人处理。

5.1 正确做法:对象存储 + Metadata + Transactional Outbox

浏览器 --预签名分片上传--> Object Storage
                  │
上传确认 -> DB Transaction
           ├─ document_version(UPLOADED)
           └─ outbox_event(DocumentUploaded)
                  │
             Outbox Relay -> Kafka -> Parser Worker

上传 API 应返回预签名 URL,不要把大文件读入应用内存;文件名只作展示,object key 由服务端 UUID/哈希生成。还需要限制 MIME、文件大小、压缩包展开比例与租户配额,并在解析前经过恶意文件扫描和隔离。

asyncdefconfirm_upload(db, version_id, object_key, sha256):
asyncwith db.transaction():
await db.execute(
"""INSERT INTO document_versions
               (id, document_id, tenant_id, version_no, object_key, sha256, status)
               VALUES ($1, $2, $3, $4, $5, $6, 'UPLOADED')"""
,
            version_id, document_id, tenant_id, version_no, object_key, sha256,
        )
await db.execute(
"""INSERT INTO outbox_events(id, aggregate_id, event_type, payload)
               VALUES ($1, $2, 'DocumentUploaded', $3)"""
,
            event_id, version_id,
            {"event_id"str(event_id), "document_version_id"str(version_id)},
        )

Outbox Relay 用 FOR UPDATE SKIP LOCKED 领取待发送事件。Relay 可能在 Kafka 已确认、DB 尚未标记已发布时宕机,因此下游必须以稳定 event_id 与业务唯一键幂等处理。

对象先上传而数据库事务没提交会产生 orphan object。每日扫描“创建超过 24 小时、Metadata 无引用”的对象,先移入 quarantine,再按保留期删除;反之,Metadata 有记录而对象缺失时立即标记失败并告警。


六、解析与分块:让文本成为可用证据

解析器不应只返回一段长字符串。至少保留页、块、标题、表格、阅读顺序、坐标和解析器版本。

原生 PDF      -> 文本 + layout 提取
扫描 PDF      -> OCR + layout,记录 OCR confidence
表格密集 PDF  -> table-aware parser
DOCX/PPTX     -> 原生结构解析
图片          -> OCR / vision,保留坐标与置信度

Quality Gate 将“解析成功”变成可计算状态,而不是一行日志:

defvalidate_parse_result(result):
    issues = []
if result.page_count == 0:
return [("EMPTY_DOCUMENT""fatal")]
iflen(result.markdown) / result.page_count < 50:
        issues.append(("LOW_TEXT_DENSITY""warning"))
if result.empty_page_ratio > 0.30:
        issues.append(("TOO_MANY_EMPTY_PAGES""warning"))
if result.tables_missing_header:
        issues.append(("TABLE_HEADER_MISSING""warning"))
return issues

fatal 问题进入 FAILED,warning 可进入人工抽检或 READY_WITH_WARNINGS。OCR 低置信度的数字、金额和表格尤其不应默默变成“可信答案”。

分块遵守“结构优先、长度兜底、来源可追溯”:

标题路径 + 正文 -> 语义 chunk
表标题 + 完整表头 + N 行 -> 表格 chunk
条款标题 + 编号 + 内容 -> 规则 chunk
相邻段落编号 -> Context 阶段可回填

Embedding 文本需要带标题上下文:

企业版 > 退款政策

支持提交申请后 7 个自然日内退款。

若只嵌入“支持 7 天内退款”,企业版和试用版的答案会被混为一谈。


七、检索:先让过滤正确,再追求 ANN 速度

7.1 pgvector 起步方案

对百万至数百万 chunk、且团队已有 PostgreSQL 经验的系统,pgvector 是务实的起点:

CREATE TABLE document_chunks (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    document_version_id UUID NOT NULL,
    index_generation_id UUID NOT NULL,
    chunk_no INTNOT NULL,
    page_start INT,
    page_end INT,
    heading_path JSONB NOT NULL,
    content TEXT NOT NULL,
    embedding_version VARCHAR(64NOT NULL,
    embedding vector(1024NOT NULL,
    is_active BOOLEANNOT NULLDEFAULTFALSE,
UNIQUE (index_generation_id, chunk_no)
);

CREATE INDEX CONCURRENTLY chunk_hnsw_idx
ON document_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m =24, ef_construction =160);
CREATE INDEX chunk_tenant_active_idx
ON document_chunks (tenant_id, is_active);

mef_constructionef_search 没有通用最优值,必须用真实查询集测 Recall 与 P95。HNSW 的过滤发生在近似索引扫描之后;强 tenant/ACL 过滤可能导致候选不足。pgvector 0.8+ 可使用 iterative scan,但仍需设定扫描上限并测尾延迟。官方过滤与多租户说明

BEGIN;
SETLOCAL hnsw.ef_search =100;
SETLOCAL hnsw.iterative_scan = strict_order;

SELECT id, content, page_start, page_end,
       embedding <=> $1AS distance
FROM document_chunks
WHERE tenant_id = $2
AND index_generation_id = $3
AND is_active =TRUE
ORDERBY embedding <=> $1
LIMIT 50;
COMMIT;

大租户、强隔离或高频复杂过滤时,应考虑 tenant 分区/独立表,或采用具备 payload index 与独立扩缩容能力的向量库。无论底层是什么,禁止先全库 top-K 再在应用层过滤权限

7.2 中文混合检索不是可选装饰

向量检索对语义接近有效,但对订单号、RFC 编号、错误码、型号和条款号未必更优:

Dense top 50 + Lexical top 50
             ↓
           RRF top 30
             ↓
      Cross-Encoder top 8
             ↓
        Context Builder

词法层需明确采用的中文分析器、同义词词典与字段权重;默认 FTS 不能自然等价于中文 BM25。更重要的是,Dense 与 Lexical 必须使用同一份 tenant_id、active generation、文档状态和 ACL filter。

defrrf(ranked_lists, k=60):
    scores = {}
for ranked in ranked_lists:
        seen = set()
for rank, chunk_id inenumerate(ranked, 1):
if chunk_id notin seen:
                scores[chunk_id] = scores.get(chunk_id, 0) + 1 / (k + rank)
                seen.add(chunk_id)
return [x[0for x insorted(scores.items(), key=lambda item: item[1], reverse=True)]

RRF 只能融合候选,不能修复权限问题,也不能代替评测。


八、权限:Prompt 从来不是安全边界

最危险的 RAG 故障不是答错,而是 A 租户检索到 B 租户的合同。正确路径是:

IdP/JWT
  -> 服务端验证签名、audience、过期时间
  -> 构造可信 principal
  -> 查询授权服务,获取带版本的 ACL snapshot
  -> 检索层强制加入 tenant / group / owner filter
  -> LLM 仅接收过滤后的 Context
WHERE tenant_id = :trusted_tenant_id
AND is_active =TRUE
AND index_generation_id = :active_generation
AND (
      visibility ='TENANT'
OR owner_id = :trusted_user_id
OR acl_group_ids && :trusted_group_ids
  )

撤销权限也必须是一条数据链路:

ACL changed
  -> acl_policy_version + 1
  -> 失效 query / retrieval / rerank cache
  -> 更新索引 payload,或查询时使用最新授权关系联结
  -> 记录撤权传播延迟并告警

缓存 key 至少包含 tenant、principal/ACL version、index generation 和 query 规范化版本。只把权限检查放在 API 第一行,之后把无过滤候选放进缓存或 trace,仍可能泄露数据。


九、消息可靠性:别把手动 commit 叫 exactly-once

Worker 宕机、Kafka 重复投递、向量库超时都会发生。真实目标应是:

at-least-once delivery
+ idempotent business write
+ reconciliation

索引写入的业务唯一键应是 (index_generation_id, chunk_no),而不是 Redis SETNX。Redis 锁可以降并发,不能承担“永远处理过”的事实。

处理 retry/DLQ 时必须避免两种失败窗口:

先提交原 offset,再发送 retry -> retry 发送失败,原消息丢失
先发送 retry,不提交原 offset -> 原消息重放,可能产生重复 retry

可选做法:

  1. 1. 使用 Kafka 事务,在同一事务内写 retry/DLQ 并 sendOffsetsToTransaction;外部 DB/向量写仍以幂等键兜底。
  2. 2. 消费后先写入 DB Inbox,以 event_id 去重;由 DB 事务驱动后续 Outbox/Worker。

Kafka Topic 不是天然定时队列。retry.10s 只是名称;应使用延迟调度器、not_before 转发 worker,或带延迟语义的队列。绝不要在一个分区 consumer 中 sleep(120)

Kafka 对外部目的端的语义边界可参考 Apache Kafka Delivery Semantics;DLQ 必须有 dashboard、错误聚合、受控 replay 和人工处置记录,而不是另一个无人查看的 Topic。


十、上下文组装:Top-K 不是最终答案证据

候选可能含同页重复标题、同一条款的上下半段、多个版本或不同文档的冲突结论。Context Builder 应完成:

  1. 1. 按文档版本与 generation 去重;
  2. 2. 为高分 chunk 读取相邻块;
  3. 3. 控制每份文档的预算,保证证据多样性;
  4. 4. 用实际 tokenizer 计算 token,不用字符数代替;
  5. 5. 写入页码、章节、稳定引用 ID。
defbuild_context(candidates, token_budget, token_count, load_neighbors):
    selected, seen_versions, used = [], set(), 0
for candidate in candidates:
        key = (candidate.document_version_id, candidate.index_generation_id)
if key in seen_versions andnot candidate.is_neighbor_of_selected:
continue
        evidence = load_neighbors(candidate, max_neighbors=1)
        cost = token_count(evidence.text)
if used + cost > token_budget:
continue
        selected.append(evidence.with_trusted_citation())
        seen_versions.add(key)
        used += cost
return selected

引用元数据必须来自可信字段,不能直接拼接用户提交的文件名。证据不足时,回答应明确说明“未在当前有权限资料中找到足够依据”,不能让模型猜测。


十一、上线前必须补齐的安全与删除逻辑

文档也是不可信输入:它可能包含提示注入、恶意 HTML、畸形 PDF 或诱导模型调用工具的文字。处理原则:

  • • 解析沙箱限制网络、CPU、内存、解压大小和执行时间。
  • • 检索内容只能作为数据,不得改变系统提示、工具权限或身份边界。
  • • HTML/Markdown/引用展示做转义和安全渲染。
  • • 日志、trace、反馈样本实施 PII 脱敏与最小留存;不要将 tenant、用户、文档 ID 放入高基数指标 label。

删除也不能只删一行 metadata:

Delete request
  -> 立即置为不可查询,并失效所有缓存
  -> 异步删除向量、词法索引、对象与派生引用
  -> 记录 purge job 和结果
  -> 按保留策略处理备份中的到期数据

十二、用 Golden Set 让“感觉更好”变成数据

Golden Set 的一条记录不仅要有 query 和相关 chunk,还应包括授权主体与禁止命中的证据:

{
"query":"如何修改企业版退款申请?",
"principal":{"tenant":"t-1","groups":["finance"]},
"relevant_chunks":["g7:chunk-31","g7:chunk-32"],
"forbidden_chunks":["tenant-2:chunk-18"],
"tags":["policy","multi-chunk","zh"]
}

每次 Parser、分块、Embedding、索引参数和 ACL 策略变更后,至少检查:

Recall@20、MRR、nDCG 不低于基线
引用可验证率与拒答正确率不退化
权限穿透用例 = 0
真实过滤条件下 P95/P99 不超过预算
expected/indexed count 与 checksum 一致

监控也应覆盖三层:

数据面:parse failure、chunk token、MQ lag、DLQ、stuck state、orphan object
查询面:ACL、embedding、dense、lexical、rerank、context、TTFT 的 P50/P95/P99
质量面:empty retrieval、citation missing、version mismatch、ACL deny、撤权传播延迟

十三、一次真正可执行的发布清单

1. 创建新 generation,绝不修改线上 active generation
2. 重建并记录每批 checkpoint,支持重放
3. 校验 chunk 数、hash、页码覆盖、解析 warning 比例
4. 跑 Golden Set、权限负例与真实过滤压测
5. 审批后切换 alias/route,写入 activation event
6. 小流量观察 P95、召回、ACL deny、缓存命中和错误率
7. 异常时立即切回旧 generation
8. 回滚窗口结束后才物理清理旧 generation

上线前应至少演练这些故障:对象上传完成但数据库未提交、Outbox 重复投递、Worker 在向量写成功后宕机、索引切换中断、刚撤权用户的缓存命中、解析器乱码、DLQ replay 和向量库部分不可用。


结语

生产 RAG 的本质不是一个 LLM 项目,而是:

文档数据工程 + 搜索工程 + 分布式可靠性 + 权限治理 + 模型推理 + 可观测性。

如果团队只能优先做一件事,请抽取 100 个真实问题,逐条检查:

原文 -> 解析结果 -> chunk -> 检索候选 -> 重排 -> Context -> 引用 -> 回答

大多数“模型不够聪明”的问题,都会在这条链路中找到真正原因。

参考资料

  1. 1. pgvector:HNSW、过滤、迭代扫描与多租户
  2. 2. Apache Kafka:Delivery Semantics
  3. 3. Qdrant:Payload 与索引
  4. 4. Hugging Face Text Embeddings Inference
  5. 5. Docling 文档解析

相关学习资料