Files
PlantAI-rag-new/tooling/aqua_kb/qdrant_loader.py
T
adminandClaude Fable 5 f0a1711ebc fix(aqua): batch_runner path sanitize, loader batch embed, alias expansion
- batch_runner: sanitize paper_id to disk dir name (was skipping all
  papers whose paper_id contains :/()); keeps explicit result path
  compat; verified 5/5 no-skip on paren-DOI papers
- qdrant_loader: batch embed via Ollama array input (32/batch, 14x:
  2.7 -> 36.9 texts/s, 24.5k points ~10min vs 2.5h); per-batch fail
  falls back to single; checkpoint format unchanged
- entity_alias_aqua: +Streptococcus agalactiae (GBS), RAS
  (forbidden_expansions), Danio rerio; merged vernacular/Chinese
  aliases for O. niloticus / O. mykiss / S. salar / L. vannamei;
  g02 gap entities now normalize correctly
- note for P3: ingest manifest MUST carry paper sidecar (pilot
  manifest_pilot.json does not - would emit paper_id="result" dirt)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 01:06:38 +08:00

312 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <dir|file> --qdrant http://127.0.0.1:27435 \
--collection aqua_srt_v1_20260930_normalized --batch 200 \
--checkpoint <file.jsonl> \
[--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()