# Qdrant 生产环境手册

> 覆盖：部署 → 配置 → 安全 → 调优 → 备份 → 监控 → 升级 → 故障排查
> 基线版本：Qdrant 1.19.x（2026-09 稳定版 1.19.1）｜客户端：qdrant-client 1.19.x
> 开发用法另见 [`Qdrant开发使用指南.md`](./Qdrant开发使用指南.md)，配套配置与脚本在 [`deploy/`](./deploy) 和 [`ops/`](./ops)

---

## 第 0 章 三条版本红线（升级前必读）

Qdrant 的升级路径不是线性的，踩中这几条会直接起不来或数据读不出。

| 红线 | 内容 | 后果 |
|---|---|---|
| **不能跳版本** | 1.17 移除了 RocksDB 存储引擎，改用 Gridstore。**1.15.x 及更早不能直接升到 1.17+** | 存储格式不兼容，启动即失败。必须逐个小版本升（1.15 → 1.16 → 1.17） |
| **旧端点已删** | 1.19 移除了 `/search`、`/recommend`、`/discover` 三个端点（1.10 起由统一 `/query` 取代） | 客户端报 404。升级前先把代码迁到 `/query` |
| **客户端要对齐** | 服务端 1.19 ↔ 客户端 1.19。客户端版本低于服务端太多会缺少新字段支持 | 新特性用不了或参数被静默忽略 |

**版本选择建议**：新部署直接用最新稳定版；已有部署跟随官方节奏一个一个升，不要跨版本。

**检查当前版本**：

```bash
curl -s http://127.0.0.1:6333/ | jq -r '.version'
```

---

## 第 1 章 部署形态怎么选

| 形态 | 适合场景 | 优点 | 代价 |
|---|---|---|---|
| **macOS 单二进制** | 本地开发、跑评测 | 零依赖、零虚拟化开销、`./qdrant` 就跑起来 | 仅开发用，别拿来当生产 |
| **Linux 单二进制 + systemd** | 单机生产、2~8GB 内存小机器 | 最省资源；无容器运行时开销 | 升级要手动换二进制 |
| **Docker / Compose** | 单机生产、需要环境一致性 | 升级=`改 tag`，隔离干净，回滚快 | 容器运行时本身吃内存（Intel Mac 上尤其明显） |
| **K8s + Operator** | 集群、需要自动扩缩容 | 自动化程度最高 | 运维复杂度陡增，小规模纯亏 |
| **Qdrant Cloud** | 完全不想运维 | 免运维、有 SLA | 按量计费；数据出境需评估 |

**给你的建议（结合你现有的 Intel MacBook 16GB + 国内 Linux 服务器）**：

- **本地开发**：用官方 macOS 单二进制。**不要为了跑 Qdrant 装 Docker Desktop**——那东西本身就是你机器发热的主因之一。
  ```bash
  curl -L https://github.com/qdrant/qdrant/releases/latest/download/qdrant-x86_64-apple-darwin.tar.gz | tar -xz
  ./qdrant          # REST 6333，Web UI: http://127.0.0.1:6333/dashboard
  ```
- **生产服务器**：内存 ≤ 4GB 用 systemd 二进制；≥ 8GB 且已经有 Docker 运维习惯就用 Compose。

**一个常见的错误认知**：`qdrant-client` 的 `--local` 嵌入式模式（`QdrantClient(path=...)`）**不是生产方案**。它是纯 Python + SQLite 实现，官方明确不推荐用于生产或压测，且**数据格式与 server 版不兼容**——`./qdrant_local` 目录搬不到生产服务器上。它只适合本地验证和评测。生产必须走 server 模式，数据用脚本重新导入（点 ID 幂等的话重导不会产生重复）。

---

## 第 2 章 单机生产部署

### 2.1 系统准备（Linux）

```bash
# 1) 文件描述符：Qdrant 是 mmap 重度用户，默认 1024 会在高负载下莫名报错
sudo tee /etc/security/limits.d/qdrant.conf <<'EOF'
qdrant soft nofile 65536
qdrant hard nofile 65536
EOF

# 2) 磁盘：向量检索是随机读密集，机械盘性能差一个数量级，能上 NVMe 就上
#    数据目录和快照目录必须分开挂载 —— 同一块盘，盘挂了数据和备份一起没

# 3) 关闭透明大页（THP）—— 会让 mmap 访问出现不可预测的延迟毛刺
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled

# 4) 时间同步（快照/日志时间戳要能对上）
sudo timedatectl set-ntp true
```

### 2.2 目录规划（照抄即可）

```
/opt/qdrant/qdrant                 # 二进制
/etc/qdrant/config.yaml            # 配置
/etc/qdrant/qdrant.env             # 密钥（权限 600）
/var/lib/qdrant/storage/           # 数据（★ 独立盘）
/var/lib/qdrant/snapshots/         # 快照（★ 另一块盘或远端）
/var/lib/qdrant/temp/              # 临时文件
/var/log/qdrant/                   # 日志（用 systemd 时在 journald）
```

### 2.3 首次启动自检（四步，缺一不可）

```bash
# ① 服务活着吗
curl -s http://127.0.0.1:6333/healthz          # 期望: healthz check passed

# ② 就绪了吗（能服务请求）
curl -s http://127.0.0.1:6333/readyz           # 期望: readyz check passed

# ③ 认证生效了吗（没配 key 时这步会返回 200，说明在裸奔）
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:6333/collections
# 期望 401；返回 200 = api_key 没生效，立刻回去查配置

# ④ 写入→检索→删除 全链路通不通
curl -s -X PUT http://127.0.0.1:6333/collections/_smoke \
  -H "api-key: $QDRANT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"vectors":{"size":4,"distance":"Cosine"}}'
curl -s -X PUT "http://127.0.0.1:6333/collections/_smoke/points?wait=true" \
  -H "api-key: $QDRANT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"points":[{"id":1,"vector":[0.1,0.2,0.3,0.4],"payload":{"t":"ok"}}]}'
curl -s -X POST "http://127.0.0.1:6333/collections/_smoke/points/query" \
  -H "api-key: $QDRANT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"query":[0.1,0.2,0.3,0.4],"limit":1}'
curl -s -X DELETE http://127.0.0.1:6333/collections/_smoke -H "api-key: $QDRANT_API_KEY"
```

### 2.4 Docker 部署

直接看 [`deploy/docker-compose.yml`](./deploy/docker-compose.yml)（已含密钥注入、内存上限、健康检查、日志轮转）。配 `.env`：

```bash
QDRANT_API_KEY=$(openssl rand -hex 32)     # 写进 .env，别进 git
QDRANT_READ_ONLY_KEY=$(openssl rand -hex 32)
QDRANT_MEM_LIMIT=4g
```

---

## 第 3 章 配置详解（生产必改的 8 项）

完整模板见 [`deploy/production.yaml`](./deploy/production.yaml)，这里只讲**改了会出事的那些**。

### 3.1 覆盖优先级（先搞清这个，否则改了不生效）

```
内置默认值  <  config.yaml  <  环境变量 QDRANT__XXX__YYY  <  命令行参数
```

例：`QDRANT__SERVICE__API_KEY=abc` 等价于 `service.api_key: abc`，**且优先级更高**。所以密钥用环境变量注入，配置文件进 git 也不会泄密。

### 3.2 必改项清单

| 配置项 | 建议值 | 不改的后果 |
|---|---|---|
| `service.api_key` | 32 字节随机串 | **任何人可读写删你全部向量**（最严重的疏忽） |
| `service.read_only_api_key` | 另一个随机串 | 监控系统得持有写权限，权限过大 |
| `service.host` | 裸机 `127.0.0.1` / 容器 `0.0.0.0` | 裸机绑 0.0.0.0 + 无防火墙 = 公网裸奔 |
| `storage.storage_path` | 独立数据盘 | 系统盘写满会导致整机异常 |
| `storage.snapshots_path` | **另一块盘** | 同盘快照 = 盘挂了备份也挂 |
| `storage.on_disk_payload` | `true` | payload 全进内存，OOM 主因（尤其你这种带大量元数据的语料） |
| `storage.wal.wal_capacity_mb` | 256（写入高则 1024） | WAL 写满会**阻塞写入**，表现为 upsert 超时 |
| `telemetry.enabled` | `true` | 拿不到 `/telemetry` 运行态，排障少一半信息 |

### 3.3 容易误解的三个参数

**`on_disk_payload`** — 设 `true` 时 payload **索引仍在内存**。也就是说它省的是"存原文"的内存，不是索引的内存。所以字段越多、索引越多，省得越少。别指望它解决所有内存问题。

**`indexing_threshold_kb`** — 未索引数据累计到这个量才触发建 HNSW 索引。**批量导入期间临时调大（如 1000000）能显著加快导入**（避免边导边反复重建索引），导完必须调回（比如 20000）并等待优化器跑完，否则检索会一直扫未索引段——**这是"导入后检索变慢"最常见的原因**。

**`default_segment_number`** — 段数约等于可并行搜索的粒度。核多调大能提吞吐，但段越多小文件越多、内存与合并压力越大。2~4 核的机器保持 2，8 核以上可以试 4。**不要设成核数那么多**。

### 3.4 量化：内存不够的第一选择

| 方式 | 压缩比 | 召回损失 | 适用 |
|---|---|---|---|
| `scalar: int8` | ~4× | < 1% | **默认选它**，几乎无痛 |
| `scalar: int8 + always_ram` | ~4× | < 1% | 内存够放量化数据、原始向量留磁盘 |
| `binary` | ~32× | 明显 | 必须配 rescoring/oversampling 才能用 |
| 1.19 TurboQuant 4-bit | ~8× | 小 | 新格式，想压得更狠时试 |

```json
PATCH /collections/{name}
{
  "quantization_config": {
    "scalar": { "type": "int8", "quantile": 0.99, "always_ram": true }
  }
}
```

**关掉量化**：把 `quantization_config` 设为 `null` 即可，不需要重建集合。

**先量化，再考虑加机器**。int8 省 75% 内存且不用重新嵌入；加机器要迁移数据、改架构、多一份运维。

---

## 第 4 章 安全加固

三层都要做，缺一层就等于没做。

### 4.1 网络层（最有效的一层）

```bash
# 裸机：绑回环，绝不 0.0.0.0
#   config.yaml → service.host: 127.0.0.1
#   systemd 单元里 Environment="QDRANT__SERVICE__HOST=127.0.0.1"

# 防火墙：只放行应用服务器
sudo ufw allow from 10.0.0.0/24 to any port 6333 proto tcp
sudo ufw allow from 10.0.0.0/24 to any port 6334 proto tcp
sudo ufw enable
```

**Docker 的坑**：`-p 6333:6333` 会绕过 ufw（Docker 直接写 iptables），必须写成 `-p 127.0.0.1:6333:6333` 或 `-p 10.0.0.5:6333:6333`。这是最常见的"我明明配了防火墙却被扫到"的原因。

### 4.2 认证层

```bash
# 生成两个密钥
openssl rand -hex 32     # 读写 key
openssl rand -hex 32     # 只读 key（给监控/报表）
```

| 角色 | key | 用途 |
|---|---|---|
| 应用读写 | `api_key` | 业务服务 |
| 监控抓取 / 只读报表 | `read_only_api_key` | Prometheus、BI |

**轮换流程**（不停机）：

```bash
# 1. 服务端同时配置新旧两个 key 的能力有限，标准做法是「改配置 + 重启」
NEW=$(openssl rand -hex 32)
sudo sed -i "s#^\(\s*QDRANT__SERVICE__API_KEY=\).*#\1$NEW#" /etc/qdrant/qdrant.env
sudo systemctl restart qdrant                      # 重启约数秒

# 2. 验证：旧 key 401，新 key 200
curl -s -o /dev/null -w 'old:%{http_code}\n' -H "api-key: $OLD" http://127.0.0.1:6333/collections
curl -s -o /dev/null -w 'new:%{http_code}\n' -H "api-key: $NEW" http://127.0.0.1:6333/collections

# 3. 逐个改应用侧配置（.env / 密钥管理服务）
```

更平滑的方案：在反向代理层注入密钥（客户端不感知），轮换时只改代理配置。

### 4.3 传输层（TLS）

开了 api_key 不开 TLS = **明文传输密钥**，等于没设。三个选择：

1. **最省事**：Qdrant 只绑 127.0.0.1，走 SSH 隧道或同机应用直连，不对外暴露端口；
2. **标准做法**：前面挂 Nginx/Caddy 处理 TLS，Qdrant 只听内网；
3. Qdrant 原生 `enable_tls: true` + 证书（集群内部通信也记得开 `cluster.p2p.enable_tls`）。

```nginx
# Nginx 反代要点（8000 → 6333）
location / {
    proxy_pass http://127.0.0.1:6333;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    client_max_body_size 128m;        # 大 batch upsert 会被 413 拦掉
    proxy_read_timeout 300s;          # 快照/大规模导入耗时长
    proxy_buffering off;              # 虽 Qdrant 主要是短请求，但保持关闭更稳
}
```

### 4.4 进程权限与审计

- **容器非 root**：用 `qdrant/qdrant:v1.19.1-unprivileged`，或 `--user=1000:2000`（数据目录需 `chown 1000:2000`）；
- **审计日志（1.17+）**：写本地文件 `<storage_path>/audit/`，**不写 stdout**。容器部署要挂卷 + sidecar 收集，否则重启即丢；
- **/metrics 也要鉴权**：开了 api_key 后抓取要带 key，Prometheus 用 `authorization.credentials`（填只读 key）。

### 4.5 安全自查清单（上线前逐条打勾）

```
[ ] api_key 已设，且不是 "changeme"/"123456" 这类弱值
[ ] curl 无 key 访问 /collections 返回 401
[ ] 6333/6334 未对公网开放（从外部机器 telnet 测试一下）
[ ] Docker 端口绑定写了 127.0.0.1 或内网 IP，不是 0.0.0.0
[ ] read_only_api_key 给监控用，应用不共用同一个 key
[ ] 容器以非 root 运行
[ ] 快照目录与应用数据目录不在同一块盘
[ ] 密钥不在 git 里（git log -S "api_key" 查一遍）
```

---

## 第 5 章 容量规划与调优

### 5.1 内存账簿（先算清账，再决定买什么）

Qdrant 的内存由四块组成：

| 组成 | 公式 | 说明 |
|---|---|---|
| 原始向量 | `n × d × 4` 字节 | n=向量数，d=维度。**唯一真正线性的部分** |
| HNSW 图结构 | `n × (m×2×4 + 8)` ≈ `n × 130` 字节（m=16） | **不随维度变化**，所以极致降维后它会占更大比例 |
| payload | 取决于字段数与索引 | `on_disk_payload: true` 可把原文挪到磁盘 |
| 段与运行时开销 | ~10~20% 余量 | 段元数据、正在合并的副本、请求缓冲 |

**算一遍你的场景（500 万 × 1024 维，bge-m3 同源 dense+sparse）**：

| 配置 | 向量内存 | HNSW 图 | 合计估算 |
|---|---|---|---|
| float32 全内存 | 20.5 GB | 0.65 GB | **~21 GB（16GB 机器直接爆）** |
| int8 量化 + HNSW 在内存 | 5.0 GB | 0.65 GB | **~6 GB（够用）** |
| binary 量化 + rescoring | 0.65 GB | 0.65 GB | ~1.5 GB + 重排开销（需实测召回损失） |

结论：**16GB 内存的机器，500 万块 1024 维必须开 int8 量化**。这不是优化选项，是前置条件。

### 5.2 内存不够时的处理顺序（别跳步）

```
① 开 int8 量化            ← 省 75%，不动数据，不改架构
② on_disk_payload: true   ← 省 payload 原文
③ 减少 payload 索引字段   ← 每个 keyword 索引都常驻内存
④ 1.19 三级内存策略：把冷集合设 cold 层
⑤ 减到单集合 + 提高分片/上 Milvus ← 真到亿级再考虑
```

### 5.3 HNSW 参数怎么调

| 参数 | 作用 | 调大的效果 | 建议 |
|---|---|---|---|
| `m` | 每点连接数 | 召回↑、内存↑、建索引慢 | 16 通用最优；召回不够先调它 |
| `ef_construct` | 建索引时搜索宽度 | 索引质量↑、构建慢 | 100；导入期可降 64 提速，导完重建 |
| `ef`（**查询时传**） | 检索搜索宽度 | 召回↑、延迟↑ | 默认 128。**这是你要调的第一参数**，但注意 `ef` 提升有边际递减，且对已建成索引不需要重建 |

**调优顺序**：先确认过滤字段都有索引 → 再调查询 `ef` → 再考虑改 `m` 重建。

**`full_scan_threshold_kb`**：段小于此阈值直接暴力扫（更准）。小库上它意味着"根本没走 HNSW"，这也是为什么你 35 块的小库延迟只有十几毫秒。

### 5.4 写入性能

```python
# 推荐批量：100~1000 点/批；再大收益递减而内存峰值上升
client.upsert(collection_name=..., points=batch, wait=False)   # 导入期用 False，吞吐翻倍
client.upsert(collection_name=..., points=last_batch, wait=True)  # 最后一批用 True，确保落盘
```

| 手段 | 效果 | 代价 |
|---|---|---|
| 导入期 `indexing_threshold_kb` 调大 | 避免边导边重建索引，快数倍 | 导入期间检索慢（扫未索引段）；导完必须调回 |
| `wait=False` | 不等落盘，吞吐翻倍 | 返回成功≠已可检索 |
| 用 gRPC 而非 REST | 吞吐高、序列化开销小 | 需要 6334 端口 |
| 分多个并行客户端 | 提升并行度 | 过高会互相争抢，观察 CPU |

**导入完成后必做**：

```bash
# 把索引阈值调回正常值并触发优化器
curl -X PATCH http://127.0.0.1:6333/collections/{name} \
  -H "api-key: $KEY" -H 'Content-Type: application/json' \
  -d '{"optimizers_config":{"indexing_threshold":20000}}'

# 看优化器状态，等到所有段都 index 完成
curl -s http://127.0.0.1:6333/collections/{name} -H "api-key: $KEY" \
  | jq '.result.optimizer_status, .result.segments_count, .result.indexed_vectors_count'
```

`indexed_vectors_count` 远小于 `points_count` = **索引还没建完**，此时检索走暴力扫，慢且费 CPU。

---

## 第 6 章 备份与恢复

### 6.1 两种备份，各有职责（都要有）

| 类型 | 备份什么 | 恢复成本 | 适用 |
|---|---|---|---|
| **快照（物理）** | collection / 全实例的二进制状态 | 分钟级 | ★ 灾难恢复、升级前回滚 |
| **源语料（逻辑）** | `chunks.jsonl` + 构建脚本 | 小时级（要重新嵌入） | 换模型、改分块策略、跨版本重建 |

**关键认知**：你的分块语料是可重建的（有 `build_chunks.py`），所以逻辑层天然有备份。**但快照仍必须做**——重新嵌入 500 万块的成本和耗时不能接受作为常规恢复手段。

### 6.2 快照命令

```bash
KEY=your_api_key

# 单个 collection 快照（最常用）
curl -X POST http://127.0.0.1:6333/collections/kb_chunks_v1/snapshots -H "api-key: $KEY"
# → {"result":{"name":"kb_chunks_v1-2026-09-23-...snapshot","size":...}}

# 全实例快照（所有 collection 一起）
curl -X POST http://127.0.0.1:6333/snapshots -H "api-key: $KEY"

# 列出 / 删除
curl -s http://127.0.0.1:6333/collections/kb_chunks_v1/snapshots -H "api-key: $KEY" | jq -r '.result[].name'
curl -X DELETE http://127.0.0.1:6333/collections/kb_chunks_v1/snapshots/<snapshot_name> -H "api-key: $KEY"

# 恢复：把快照导回（可指定为新的 collection 名，便于先验证再切换）
curl -X PUT http://127.0.0.1:6333/collections/kb_chunks_v1/snapshots/recover \
  -H "api-key: $KEY" -H 'Content-Type: application/json' \
  -d '{"location":"file:///var/lib/qdrant/snapshots/kb_chunks_v1-xxx.snapshot"}'

# 从远端恢复（先上传到 Qdrant 能访问的 URL，或放本地路径恢复）
```

### 6.3 定时备份（用 [`ops/qdrant_ops.sh`](./ops/qdrant_ops.sh)）

```bash
# crontab -e
# 每天 03:00 快照 + 保留 7 天
0 3 * * * /opt/qdrant/ops/qdrant_ops.sh backup-all >> /var/log/qdrant/backup.log 2>&1
# 每天 03:10 把快照同步到异地（另一个盘/对象存储）
10 3 * * * rsync -a --delete /var/lib/qdrant/snapshots/ /mnt/backup/qdrant-snapshots/
```

**三条硬要求**：

1. **异地**：快照不能只躺在数据目录旁边，机器整体故障时一起没；
2. **保留策略**：至少 7 天滚动，升级前额外留一份带标签的；
3. **必须演练**：没恢复过的备份等于没有备份。每季度做一次到独立环境的恢复测试，记录实际耗时（这才是真实 RTO）。

### 6.4 RPO / RTO 参考

| 场景 | RPO（丢多少数据） | RTO（多久恢复） | 手段 |
|---|---|---|---|
| 进程崩溃 | 0（WAL 保证） | 分钟级 | systemd 自动重启 |
| 误删 collection | 到上次备份（≤24h） | 10~30 分钟 | 快照恢复 |
| 磁盘损坏 | ≤24h | 30~60 分钟 | 快照 + 重建语料 |
| 整机丢失 | ≤24h | 1~2 小时 | 异地快照 + 语料重建 |

想压低 RPO 就加密快照频率（每小时一次对几百 MB 的库完全可行）。

---

## 第 7 章 监控与告警

### 7.1 三个端点的分工（别搞混）

| 端点 | 内容 | 注意 |
|---|---|---|
| `/metrics` | Prometheus 格式指标 | **自建部署用这个**；开了 api_key 要带 key |
| `/telemetry` | 运行态快照（集合数/向量数/分片等） | 排障时看整体情况很方便 |
| `/sys_metrics` | 集群基础设施指标 | **Qdrant Cloud 专属，自建抓不到**（常见误解） |
| `/healthz` `/livez` `/readyz` | 健康检查三兄弟 | `readyz` 才能代表"可服务" |

### 7.2 必须告警的六件事

| 指标 | 阈值 | 为什么 |
|---|---|---|
| 磁盘使用率 | > 80% | 到 90% 会拒绝写入 |
| 优化器错误 | 出现即告警 | 索引构建失败 → 检索静默变慢 |
| 节点不可达 | 2 分钟 | 基础可用性 |
| 检索 P95 延迟 | > 500ms | 通常是漏建索引或段未合并 |
| 复制因子不足 | 低于目标 | 挂一个节点就可能丢数据 |
| 待处理集群操作堆积 | > 50 持续 10 分钟 | 分片迁移卡住 |

规则文件见 [`deploy/prometheus-alerts.yml`](./deploy/prometheus-alerts.yml)。

**反向告诫**：**不要对 page cache 内存占用告警**。OS 把空闲内存用作缓存是正常且有益的行为（Qdrant 的 mmap 性能就靠它），对它告警会带来大量无效通知。要盯的是进程 RSS 是否逼近上限。

### 7.3 没有 Prometheus 时的轻量方案

你现有的栈是 Bark 推送，够用。用 [`ops/qdrant_ops.sh`](./ops/qdrant_ops.sh) 的 `watch` 子命令，每 5 分钟跑一次，异常才推送：

```bash
*/5 * * * * /opt/qdrant/ops/qdrant_ops.sh watch >> /var/log/qdrant/watch.log 2>&1
```

它检查：服务可达性、磁盘水位、优化器状态、索引完成度、快照新鲜度（超过 36 小时未备份则告警）。**快照新鲜度这一条很多团队不做，结果出事时才发现备份脚本早就静默失败了**。

### 7.4 日志

```yaml
log_level: INFO          # 生产用 INFO；DEBUG 会打印每次请求，只临时开
logger:
  format: json           # 接 ELK/Loki 时开，便于结构化检索
```

排障三板斧：

```bash
journalctl -u qdrant --since "10 min ago"                    # systemd
docker logs --since 10m qdrant 2>&1 | grep -iE "error|warn"  # 容器
journalctl -u qdrant | grep -i optimizer                     # 优化器专门看
```

---

## 第 8 章 日常运维与升级

### 8.1 巡检节奏

| 频率 | 检查项 | 命令 |
|---|---|---|
| 每日 | 服务健康、磁盘水位、优化器无报错、备份成功 | `ops.sh status` |
| 每周 | 集合数/向量数增长趋势、慢查询、快照可恢复性抽查 | `ops.sh collections` |
| 每月 | 内存与容量趋势对比（是否需要加量化/扩盘）、恢复演练 | 手工 |
| 每季 | 完整恢复演练 + 密钥轮换 | `ops.sh restore-test` |

### 8.2 集合级运维动作

| 动作 | 怎么做 | 注意事项 |
|---|---|---|
| 改 alias 指向（版本切换） | `POST /collections/aliases` | 秒级生效，业务代码零改动（见开发指南第 9 章） |
| 加 payload 索引 | `PUT /collections/{name}/index` | 大集合上会触发重建，**在低峰期做** |
| 改索引阈值触发优化 | `PATCH /collections/{name}` | 同上 |
| 删除点 | `POST /collections/{name}/points/delete` | 内存要到优化器 vacuum 后才释放，不是立刻 |
| 增量更新 | 按 `content_hash` 比对后 upsert 变更点 | 点 ID 幂等，重复导入不会堆重复 |

**关于删除的内存回收**：Qdrant 靠优化器在 `deleted_threshold`（默认 20% 垃圾）达到后 vacuum 回收。所以**删完数据内存不会立刻降**，这是设计如此，不是 bug。急着回收就临时把 `deleted_threshold` 调小并等待优化器跑完。

### 8.3 升级流程（照做，别即兴）

```bash
# ---------- 升级前 ----------
qdrant_ops.sh backup-all                        # ① 全量快照
cp /etc/qdrant/config.yaml /etc/qdrant/config.yaml.bak.$(date +%F)   # ② 备份配置
curl -s http://127.0.0.1:6333/ | jq -r .version  # ③ 记录当前版本号

# ④ 读 release notes，确认升级路径合法（能不能跳版本！见第 0 章）
# ⑤ 确认应用侧没用已删除的旧端点（1.19 起 /search /recommend /discover 已删）

# ---------- 升级（Docker）----------
sed -i 's/qdrant:v1.19.0/qdrant:v1.19.1/' docker-compose.yml
docker compose pull && docker compose up -d
docker compose logs -f qdrant        # 观察启动日志有无迁移报错

# ---------- 升级（二进制）----------
systemctl stop qdrant
cp /opt/qdrant/qdrant /opt/qdrant/qdrant.bak.$(date +%F)
curl -L <对应版本二进制> | tar -xz -C /opt/qdrant/
systemctl start qdrant

# ---------- 升级后验证 ----------
curl -s http://127.0.0.1:6333/ | jq -r .version      # 版本对了吗
curl -s http://127.0.0.1:6333/readyz                  # 就绪了吗
# 跑你的 20 题评测集，对比升级前后 Recall —— 这是唯一能证明"没坏"的方法
python3 qdrant_import.py --eval

# ---------- 回滚预案（必须提前想好）----------
# 二进制：换回 .bak 二进制 + 配置；若存储格式已迁移，用升级前快照恢复
# Docker：改回旧 tag；同上
```

**一条经验**：Qdrant 的 `小版本升级（1.19.0 → 1.19.1）` 通常是安全的；**跨小版本（1.18 → 1.19）必读 release notes**；**跨大版本迁移（1.15 → 1.17+）必须逐版本升**。

### 8.4 扩缩容

| 需求 | 手段 | 备注 |
|---|---|---|
| 单机内存不够 | 先量化 → 再考虑加内存 | 量化不用迁数据 |
| 单机磁盘不够 | 扩盘 or 清旧快照 | 快照是最容易忘的磁盘黑洞 |
| 需要高可用 | 上集群：`cluster.enabled: true`，分片 + 副本 | 副本数 ≥2 才有意义 |
| 读并发扛不住 | 加副本 + 读亲和（1.19 `X-Qdrant-Route-Affinity`） | 副本只提读，不提写 |
| 写入吞吐不够 | 分片数 + 批量写 + gRPC | 分片数建集合时定好，之后改要 resharding |

---

## 第 9 章 故障排查手册（症状 → 原因 → 处理）

### 9.1 启动类

| 症状 | 最可能原因 | 排查 | 处理 |
|---|---|---|---|
| 启动即退出，日志提示存储版本不兼容 | **跨版本升级**（尤其 1.15→1.17+） | 日志里的 `storage version` / `RocksDB` 关键字 | 逐个小版本升；已升坏用升级前快照恢复 |
| `Address already in use` | 端口被占（常见：上次进程没死干净） | `lsof -i:6333` / `ss -lntp \| grep 633` | 杀掉旧进程；容器用 `docker ps -a` 找残留 |
| `Permission denied` 打开 storage | 数据目录权限/属主不对 | `ls -ln /var/lib/qdrant` | `chown -R qdrant:qdrant`；用 unprivileged 镜像要 `chown 1000:2000` |
| 启动很久才 ready | 正在恢复 WAL / 重建索引 | 日志 `recovery` 关键字；`/readyz` | 正常，等它跑完；大库可能十几分钟 |
| `too many open files` | nofile 限制 | `ulimit -n`、`cat /proc/$(pidof qdrant)/limits` | 提到 65536（systemd 用 `LimitNOFILE`） |

### 9.2 检索类

| 症状 | 最可能原因 | 排查 | 处理 |
|---|---|---|---|
| **检索结果为空，但不报错** | **向量维度与集合 size 不一致**（最隐蔽的坑） | `GET /collections/{name}` 看 `config.params.vectors.size` | 维度必须严格等于模型输出维度。**写小了不报错但结果全乱** |
| 结果为空 | filter 字段名写错 / 值类型不匹配（字符串 vs 数字） | 去掉 filter 再查，能出结果就是 filter 问题 | 用 `/points/scroll` 抽样确认 payload 里的真实字段名与类型 |
| 结果为空 | 查询的 collection 名被 alias 指向了旧集合 | `GET /aliases` | 确认 alias 指向 |
| 检索慢（几百 ms 以上） | **过滤字段漏建 payload 索引 → 全量扫描** | `GET /collections/{name}` 看 `payload_schema` | 给 filter 用到的字段建索引（开发指南第 3 章） |
| 检索慢 | `indexed_vectors_count` << `points_count` | 同上 | 索引没建完，等优化器或调回阈值触发 |
| 检索慢 | 段太多碎片化 | `segments_count` 偏大 | 调 `default_segment_number`，等优化器合并 |
| 召回不准 | 无稀疏通道，精确词（如"第三十九条"）匹配不上 | 对比纯向量 vs 混合检索结果 | 开 dense+sparse 混合 + RRF 融合 |
| 召回不准 | 代码/表格块缺自然语言摘要 | 看 `vector_text` 是不是纯代码 | 给富内容块补一句语义摘要（实测 R@5 90%→100%） |
| `ef` 调很大仍不准 | 数据本身没入库或 payload 错 | `count` 核对总数 | 先排除数据问题再调参数 |

### 9.3 内存 / 性能类

| 症状 | 最可能原因 | 处理 |
|---|---|---|
| 进程被 OOM Killer 杀掉（`dmesg` 有 oom） | payload 全内存 + 无量化 + 段过多 | 开 int8 量化、`on_disk_payload: true`、给容器/服务设 `MemoryMax` |
| 内存持续上涨不回落 | 优化器未完成合并；或删除点未 vacuum | 等优化器；急则临时调小 `deleted_threshold` |
| CPU 100% 持续 | 优化器在重建索引，或检索在扫未索引段 | 看 `optimizer_status`；导入后记得把 `indexing_threshold` 调回 |
| 写入超时 / 卡住 | WAL 写满、优化器占用、磁盘 IO 饱和 | 调大 `wal_capacity_mb`；错峰写入；检查磁盘 `iostat` |
| 磁盘只涨不降 | 快照堆积 + 旧段未回收 | 清理旧快照（`ops.sh prune-snapshots`）；确认优化器在跑 |
| 快照创建失败 | 磁盘空间不足 / snapshots_path 权限 | `df -h`；`chown`；快照目录独立盘 |

### 9.4 认证 / 权限类

| 症状 | 原因 | 处理 |
|---|---|---|
| 401 Unauthorized | 未带 key / key 错误 | 请求头加 `api-key: xxx` |
| 403 Forbidden | **用只读 key 执行写操作** | 写操作用主 key；监控只用只读 key |
| 本机 curl 通、应用不通 | 应用连的是旧 IP/端口，或被防火墙拦 | 从应用机器 `telnet 10.0.0.5 6334` 测通 |
| Prometheus 抓不到指标 | `/metrics` 也要鉴权 | scrape 配置加 `authorization.credentials` |
| 客户端连不上 gRPC | 只放行了 6333，没放行 6334 | 防火墙同时放行两个端口 |

### 9.5 集群类

| 症状 | 原因 | 处理 |
|---|---|---|
| 节点 `not ready` | 该节点落后于共识，或磁盘满 | 看该节点日志；`GET /cluster` 看状态 |
| 分片迁移卡住 | 网络不通 / 目标磁盘满 | `GET /cluster` 看 `pending_operations`；修网络/磁盘 |
| 共识失同步 | Raft 状态异常 | `POST /cluster/recover`（**先确认没有正在进行的迁移**） |
| 副本数低于目标 | 节点掉线 | 恢复节点；期间可用性降低但不一定不可用 |

---

## 第 10 章 命令速查表

```bash
# 环境
export Q="http://127.0.0.1:6333"
export KEY="你的api_key"
AUTH=(-H "api-key: $KEY")

# ==================== 健康与状态 ====================
curl -s $Q/healthz                                   # 活否
curl -s $Q/readyz                                    # 就绪否
curl -s $Q/ | jq                                      # 版本 + 基本信息
curl -s $Q/telemetry "${AUTH[@]}" | jq                # 运行态遥测
curl -s $Q/metrics "${AUTH[@]}" | grep '^# HELP' | head -40   # 指标清单

# ==================== 集合管理 ====================
curl -s $Q/collections "${AUTH[@]}" | jq -r '.result.collections[].name'
curl -s $Q/collections/{name} "${AUTH[@]}" \
  | jq '{points:.result.points_count, indexed:.result.indexed_vectors_count, segs:.result.segments_count, opt:.result.optimizer_status, size:.result.config.params.vectors.size}'
curl -s $Q/collections/{name}/exists "${AUTH[@]}"
curl -X DELETE $Q/collections/{name} "${AUTH[@]}"

# ==================== 计数与抽样 ====================
curl -s -X POST $Q/collections/{name}/points/count "${AUTH[@]}" \
  -H 'Content-Type: application/json' -d '{"exact":true}' | jq .result.count

curl -s -X POST $Q/collections/{name}/points/scroll "${AUTH[@]}" \
  -H 'Content-Type: application/json' \
  -d '{"limit":3,"with_payload":true,"with_vector":false}' | jq

# ==================== 检索 ====================
curl -s -X POST $Q/collections/{name}/points/query "${AUTH[@]}" \
  -H 'Content-Type: application/json' \
  -d '{"query":[0.1,0.2,0.3],"limit":5,"with_payload":true}' | jq
# 带过滤 + 指定 ef
# -d '{"query":[..],"limit":5,"params":{"hnsw_ef":128},
#      "filter":{"must":[{"key":"chunk_type","match":{"value":"code"}}]}}'

# ==================== 索引与优化 ====================
curl -X PUT $Q/collections/{name}/index "${AUTH[@]}" \
  -H 'Content-Type: application/json' \
  -d '{"field_name":"chunk_type","field_schema":"keyword"}'
curl -s $Q/collections/{name}/index "${AUTH[@]}" | jq        # 列出所有索引
curl -X PATCH $Q/collections/{name} "${AUTH[@]}" \
  -H 'Content-Type: application/json' \
  -d '{"optimizers_config":{"indexing_threshold":20000}}'

# ==================== 快照与备份 ====================
curl -X POST $Q/collections/{name}/snapshots "${AUTH[@]}" | jq .result.name
curl -X POST $Q/snapshots "${AUTH[@]}" | jq .result.name                 # 全实例
curl -s $Q/collections/{name}/snapshots "${AUTH[@]}" | jq -r '.result[].name'
curl -X DELETE $Q/collections/{name}/snapshots/{snap} "${AUTH[@]}"
curl -X PUT $Q/collections/{name}/snapshots/recover "${AUTH[@]}" \
  -H 'Content-Type: application/json' \
  -d '{"location":"file:///var/lib/qdrant/snapshots/xxx.snapshot"}'

# ==================== Alias（版本切换）====================
curl -s $Q/aliases "${AUTH[@]}" | jq
curl -X POST $Q/collections/aliases "${AUTH[@]}" \
  -H 'Content-Type: application/json' \
  -d '{"actions":[{"delete_alias":{"alias_name":"kb_current"}},
                  {"create_alias":{"collection_name":"kb_chunks_v2","alias_name":"kb_current"}}]}'

# ==================== 集群 ====================
curl -s $Q/cluster "${AUTH[@]}" | jq
curl -X POST $Q/cluster/recover "${AUTH[@]}"
```

---

## 附：上线前检查清单

```
【部署】
[ ] 版本写死，未使用 latest
[ ] 数据目录与快照目录在不同物理盘
[ ] nofile 已放开到 65536
[ ] MEM 上限已设（容器 mem_limit / systemd MemoryMax）
[ ] 日志轮转已配（避免磁盘被日志写满）

【安全】
[ ] api_key + read_only_api_key 已设，无 key 访问返回 401
[ ] 端口未对公网开放，Docker 绑定写了 127.0.0.1 或内网 IP
[ ] 容器非 root 运行
[ ] 密钥不在 git 中

【数据】
[ ] 集合已按语义分块入库，payload 索引已建（filter 用到的字段全覆盖）
[ ] 导入后 indexing_threshold 已调回，indexed_vectors_count ≈ points_count
[ ] 量化已按内存情况开启
[ ] 评测集已跑，Recall@5 达标（≥90%）

【运维】
[ ] 定时快照 + 异地同步已配
[ ] 至少完成一次恢复演练（真实计时）
[ ] 监控告警已配（磁盘、优化器错误、服务不可达、快照新鲜度）
[ ] 升级流程与回滚预案已写成文档
```

---

**相关文件**

| 文件 | 说明 |
|---|---|
| [`Qdrant开发使用指南.md`](./Qdrant开发使用指南.md) | 建集合、混合检索、过滤、多租户、快照 API 的代码写法 |
| [`deploy/production.yaml`](./deploy/production.yaml) | 生产配置模板（逐项中文注释） |
| [`deploy/docker-compose.yml`](./deploy/docker-compose.yml) | Docker 部署（含密钥注入与内存上限） |
| [`deploy/qdrant.service`](./deploy/qdrant.service) | systemd 单元 + 安装步骤 |
| [`deploy/prometheus-alerts.yml`](./deploy/prometheus-alerts.yml) | 告警规则 |
| [`ops/qdrant_ops.sh`](./ops/qdrant_ops.sh) | 巡检 / 备份 / 恢复 / 监控脚本 |
| [`dev/qdrant_helper.py`](./dev/qdrant_helper.py) | Python 开发封装（推荐直接抄进项目） |
