#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""tooling/aqua_kb/qdrant_loader.py —— Qdrant 批量灌库器
读 phase2b_converter.py 产出的 qdrant_points.jsonl(一行一个 point:id/text/payload),
用与现网 direct_flow_retriever.embed() 同款端点(POST {embed_url} {"model":..., "input":...})
嵌入 text 字段,分批 upsert 到 Qdrant collection。
特性:
- 批量嵌入:input 传数组(--embed-batch 条/批),一次请求嵌入一批文本,
再按批 upsert(实测单条约 2.7 texts/s → 批量约 37 texts/s,约 14x 吞吐)
- checkpoint JSONL:每成功 upsert 一个 assertion_id 记一行 → 断点续跑(kill 后重跑跳过已传)
- 同 id 重放覆盖(Qdrant upsert 语义 + UUID 确定性 id → 幂等)
- 确定性处理顺序(文件路径 + 文件内行序排序)
用法:
python qdrant_loader.py --points
--qdrant http://127.0.0.1:27435 \
--collection aqua_srt_v1_20260930_normalized --batch 200 \
--checkpoint \
[--embed-url http://127.0.0.1:11434/api/embed] [--embed-model bge-m3:567m] \
[--embed-batch 32]
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any, Dict, Iterator, List
import requests
DEFAULT_EMBED_URL = "http://127.0.0.1:11434/api/embed"
DEFAULT_EMBED_MODEL = "bge-m3:567m"
EMBED_INPUT_LIMIT = 2400 # 与 direct_flow_retriever.embed() 一致
def iter_points(paths: List[Path]) -> Iterator[Dict[str, Any]]:
files = sorted(
p for p in paths if p.is_file() and p.name == "qdrant_points.jsonl"
)
for file_path in files:
with open(file_path, "r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
print(f"[loader] WARN bad json {file_path}:{line_number}: {exc}",
file=sys.stderr)
continue
yield record
class EmbedClient:
def __init__(self, url: str, model: str) -> None:
self.url = url.rstrip("/")
self.model = model
self.dim: int | None = None
@staticmethod
def _extract_vector(item: Any) -> List[float] | None:
"""从 embedding 数组元素中提取向量(兼容 embedding 对象与裸数组两种形态)。"""
if isinstance(item, dict):
item = item.get("embedding")
if not isinstance(item, (list, tuple)):
return None
try:
return [float(value) for value in item]
except (TypeError, ValueError):
return None
def embed(self, text: str) -> List[float]:
"""单条嵌入(预热 probe / 失败回退路径)。"""
vector = self.embed_batch([text])
if not vector:
raise RuntimeError("embedding endpoint returned no vector")
return vector[0]
def embed_batch(self, texts: List[str]) -> List[List[float]]:
"""批量嵌入:input 传数组,一次请求嵌入多条(约 60 texts/s)。"""
if not texts:
return []
payload = {
"model": self.model,
"input": [t[:EMBED_INPUT_LIMIT] for t in texts],
}
for attempt in range(4):
try:
response = requests.post(self.url, json=payload, timeout=120)
response.raise_for_status()
data = response.json()
raw = data.get("embeddings")
if not isinstance(raw, list) or not raw:
raise RuntimeError("embedding endpoint returned no vectors")
vectors = [self._extract_vector(item) for item in raw]
if any(v is None for v in vectors):
raise RuntimeError(
f"embedding endpoint returned {len(vectors)} vectors "
f"for {len(texts)} inputs (bad shapes)"
)
if len(vectors) != len(texts):
raise RuntimeError(
f"embedding endpoint returned {len(vectors)} vectors "
f"for {len(texts)} inputs"
)
self.dim = len(vectors[0])
return vectors
except Exception as exc:
if attempt == 3:
raise
time.sleep(2 * (attempt + 1))
raise RuntimeError("unreachable")
def ensure_collection(qdrant_url: str, collection: str, dim: int) -> None:
info = requests.get(
f"{qdrant_url.rstrip('/')}/collections/{collection}", timeout=15
)
if info.status_code == 200:
return
if info.status_code != 404:
info.raise_for_status()
create = requests.put(
f"{qdrant_url.rstrip('/')}/collections/{collection}",
json={
"vectors": {"size": dim, "distance": "Cosine"},
"shard_number": 1,
"replication_factor": 1,
"write_consistency_factor": 1,
"on_disk_payload": True,
},
timeout=30,
)
create.raise_for_status()
print(f"[loader] created collection {collection} (dim={dim}, COSINE)")
def load_checkpoint(checkpoint: Path) -> set:
done: set = set()
if checkpoint.exists():
with open(checkpoint, "r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
done.add(json.loads(line)) # 兼容 json.dumps 写入的带引号行
except json.JSONDecodeError:
done.add(line)
return done
def embed_group(
embed: "EmbedClient", records: List[Dict[str, Any]], embed_batch: int
) -> List[List[float] | None]:
"""按 embed_batch 条/批批量嵌入 records;单条失败时按单条回退重试,仍失败则该条置 None。"""
vectors: List[List[float] | None] = [None] * len(records)
for start in range(0, len(records), embed_batch):
group = records[start:start + embed_batch]
try:
vectors[start:start + embed_batch] = embed.embed_batch(
[r.get("text") or "" for r in group]
)
except Exception as exc:
print(f"[loader] WARN embed batch failed ({exc}); retrying singly",
file=sys.stderr)
for i, record in enumerate(group):
idx = start + i
try:
vectors[idx] = embed.embed(record.get("text") or "")
except Exception:
vectors[idx] = None
return vectors
def upsert_file(
points_path: Path,
qdrant_url: str,
collection: str,
batch_size: int,
*,
checkpoint: Path,
embed: "EmbedClient",
embed_batch: int = 32,
) -> Dict[str, int]:
"""上传单个 qdrant_points.jsonl(断点续跑,checkpoint 追加)。
嵌入按 embed_batch 条/批批量调用 /api/embed,再攒 batch_size 条 upsert;
批内单条失败时逐条回退重试。checkpoint 记录口径不变(assertion_id)。
"""
checkpoint.parent.mkdir(parents=True, exist_ok=True)
done = load_checkpoint(checkpoint)
qdrant = qdrant_url.rstrip("/")
upsert_url = f"{qdrant}/collections/{collection}/points?wait=true"
total = skipped = uploaded = 0
batch: List[Dict[str, Any]] = []
pending: List[Dict[str, Any]] = [] # 待嵌入的未 checkpoint 记录
started = time.time()
with open(checkpoint, "a", encoding="utf-8") as checkpoint_handle:
def flush_batch() -> None:
nonlocal batch
if not batch:
return
response = requests.put(upsert_url, json={"points": batch}, timeout=180)
response.raise_for_status()
for record in batch:
checkpoint_handle.write(
json.dumps(record["payload"]["assertion_id"]) + "\n"
)
checkpoint_handle.flush()
batch = []
def flush_pending() -> None:
nonlocal pending, uploaded
if not pending:
return
vectors = embed_group(embed, pending, embed_batch)
for record, vector in zip(pending, vectors):
if vector is None:
raise RuntimeError(
f"embed failed for assertion "
f"{record['payload']['assertion_id']} "
f"(batch and single retries exhausted)"
)
record["vector"] = vector
batch.append(record)
uploaded += 1
if len(batch) >= batch_size:
flush_batch()
pending = []
for record in iter_points([points_path]):
total += 1
assertion_id = record["payload"]["assertion_id"]
if assertion_id in done:
skipped += 1
continue
pending.append(record)
if len(pending) >= embed_batch:
flush_pending()
flush_pending()
flush_batch()
return {
"uploaded": uploaded,
"skipped": skipped,
"total": total,
"elapsed": round(time.time() - started, 1),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Qdrant bulk loader for aqua KB")
parser.add_argument("--points", required=True, help="dir or qdrant_points.jsonl file")
parser.add_argument("--qdrant", required=True, help="e.g. http://127.0.0.1:27435")
parser.add_argument("--collection", default="aqua_srt_v1_20260930_normalized")
parser.add_argument("--batch", type=int, default=200)
parser.add_argument("--checkpoint", required=True, help="JSONL of uploaded assertion_ids")
parser.add_argument("--embed-url", default=DEFAULT_EMBED_URL)
parser.add_argument("--embed-model", default=DEFAULT_EMBED_MODEL)
parser.add_argument("--embed-batch", type=int, default=32,
help="texts per /api/embed request (batch embedding)")
args = parser.parse_args()
points_arg = Path(args.points)
if points_arg.is_dir():
files = sorted(points_arg.rglob("qdrant_points.jsonl"))
else:
files = [points_arg]
checkpoint = Path(args.checkpoint)
embed = EmbedClient(args.embed_url, args.embed_model)
qdrant = args.qdrant.rstrip("/")
# 先预热一发拿到维度(与现网 bge-m3 一致;collection 不存在则按此维度创建)
probe = embed.embed("aquaculture warm-up probe")
print(f"[loader] embed dim={len(probe)} (model={args.embed_model}, url={args.embed_url})")
ensure_collection(qdrant, args.collection, len(probe))
grand = {"uploaded": 0, "skipped": 0, "total": 0}
started = time.time()
for points_path in files:
result = upsert_file(
points_path,
qdrant,
args.collection,
args.batch,
checkpoint=checkpoint,
embed=embed,
embed_batch=args.embed_batch,
)
for key in grand:
grand[key] += result[key]
print(
f"[loader] {points_path.name}: {result['uploaded']} uploaded, "
f"{result['skipped']} skipped, {result['total']} seen, "
f"{result['elapsed']}s"
)
print(
f"[loader] DONE: {grand['uploaded']} uploaded, {grand['skipped']} skipped, "
f"{grand['total']} seen in {time.time() - started:.1f}s"
)
if __name__ == "__main__":
main()