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>
This commit is contained in:
@@ -5,11 +5,15 @@ Litopenaeus vannamei:
|
|||||||
- L. vannamei
|
- L. vannamei
|
||||||
- Penaeus vannamei
|
- Penaeus vannamei
|
||||||
- whiteleg shrimp
|
- whiteleg shrimp
|
||||||
|
- white leg shrimp
|
||||||
- Pacific white shrimp
|
- Pacific white shrimp
|
||||||
|
- Pacific whiteleg shrimp
|
||||||
- white shrimp
|
- white shrimp
|
||||||
- vannamei shrimp
|
- vannamei shrimp
|
||||||
- 凡纳滨对虾
|
- 凡纳滨对虾
|
||||||
|
- 凡纳对虾
|
||||||
- 南美白对虾
|
- 南美白对虾
|
||||||
|
- 白对虾
|
||||||
- 对虾
|
- 对虾
|
||||||
forbidden_expansions:
|
forbidden_expansions:
|
||||||
- Penaeus monodon
|
- Penaeus monodon
|
||||||
@@ -45,6 +49,11 @@ Oreochromis niloticus:
|
|||||||
- tilapia
|
- tilapia
|
||||||
- 罗非鱼
|
- 罗非鱼
|
||||||
- 尼罗罗非鱼
|
- 尼罗罗非鱼
|
||||||
|
- 红罗非鱼
|
||||||
|
forbidden_expansions:
|
||||||
|
- Oreochromis mossambicus
|
||||||
|
- O. mossambicus
|
||||||
|
- 莫桑比克罗非鱼
|
||||||
|
|
||||||
Oncorhynchus mykiss:
|
Oncorhynchus mykiss:
|
||||||
type: species
|
type: species
|
||||||
@@ -55,6 +64,7 @@ Oncorhynchus mykiss:
|
|||||||
- steelhead trout
|
- steelhead trout
|
||||||
- steelhead
|
- steelhead
|
||||||
- 虹鳟
|
- 虹鳟
|
||||||
|
- 虹鳟鱼
|
||||||
|
|
||||||
Salmo salar:
|
Salmo salar:
|
||||||
type: species
|
type: species
|
||||||
@@ -64,6 +74,7 @@ Salmo salar:
|
|||||||
- Atlantic salmon
|
- Atlantic salmon
|
||||||
- salmon
|
- salmon
|
||||||
- 大西洋鲑
|
- 大西洋鲑
|
||||||
|
- 大西洋鲑鱼
|
||||||
- 三文鱼
|
- 三文鱼
|
||||||
forbidden_expansions:
|
forbidden_expansions:
|
||||||
- Oncorhynchus
|
- Oncorhynchus
|
||||||
@@ -176,6 +187,51 @@ Apostichopus japonicus:
|
|||||||
- 仿刺参
|
- 仿刺参
|
||||||
- 海参
|
- 海参
|
||||||
|
|
||||||
|
Streptococcus agalactiae:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Streptococcus agalactiae
|
||||||
|
- S. agalactiae
|
||||||
|
- group B Streptococcus
|
||||||
|
- group B streptococcus
|
||||||
|
- GBS
|
||||||
|
- 无乳链球菌
|
||||||
|
- B族链球菌
|
||||||
|
forbidden_expansions:
|
||||||
|
- Guillain-Barré syndrome
|
||||||
|
- Guillain-Barre syndrome
|
||||||
|
- 格林巴利
|
||||||
|
|
||||||
|
Danio rerio:
|
||||||
|
type: species
|
||||||
|
aliases:
|
||||||
|
- Danio rerio
|
||||||
|
- D. rerio
|
||||||
|
- Brachydanio rerio
|
||||||
|
- zebrafish
|
||||||
|
- zebra fish
|
||||||
|
- zebra danio
|
||||||
|
- 斑马鱼
|
||||||
|
|
||||||
|
RAS:
|
||||||
|
type: culture_system
|
||||||
|
retrieval_label: recirculating aquaculture system
|
||||||
|
aliases:
|
||||||
|
- RAS
|
||||||
|
- recirculating aquaculture system
|
||||||
|
- recirculating aquaculture systems
|
||||||
|
- recirculation aquaculture system
|
||||||
|
- 循环水养殖系统
|
||||||
|
- 循环水养殖
|
||||||
|
- 工厂化循环水养殖
|
||||||
|
forbidden_expansions:
|
||||||
|
- Renin-angiotensin system
|
||||||
|
- renin angiotensin system
|
||||||
|
- RAS-related protein
|
||||||
|
- Ras GTPase
|
||||||
|
- RAS signaling
|
||||||
|
- reticular activating system
|
||||||
|
|
||||||
growth performance:
|
growth performance:
|
||||||
type: phenotype
|
type: phenotype
|
||||||
aliases:
|
aliases:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -37,6 +38,15 @@ from typing import Any, Dict, List, Optional
|
|||||||
import phase2b_converter
|
import phase2b_converter
|
||||||
import qdrant_loader
|
import qdrant_loader
|
||||||
|
|
||||||
|
# 与抽取链落盘一致的目录名净化:非 [0-9a-zA-Z._-] 一律替换为 `_`(保留 doi: 前缀)。
|
||||||
|
# 例:doi:10.1016/0044-8486(81)90060-0 → doi_10.1016_0044-8486_81_90060-0
|
||||||
|
_SANITIZE_RE = re.compile(r"[^0-9a-zA-Z._-]")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_paper_key(value: str) -> str:
|
||||||
|
"""把 paper_id 规范成与磁盘一致的目录名形态(md_batch/<sanitized>/result.json)。"""
|
||||||
|
return _SANITIZE_RE.sub("_", str(value))
|
||||||
|
|
||||||
|
|
||||||
def load_ids(path: Path) -> set:
|
def load_ids(path: Path) -> set:
|
||||||
ids: set = set()
|
ids: set = set()
|
||||||
@@ -99,12 +109,16 @@ class Runner:
|
|||||||
or entry.get("result") or json.dumps(entry, sort_keys=True))
|
or entry.get("result") or json.dumps(entry, sort_keys=True))
|
||||||
|
|
||||||
def resolve_result(self, entry: Dict[str, Any]) -> Path:
|
def resolve_result(self, entry: Dict[str, Any]) -> Path:
|
||||||
|
# 兼容 1:manifest 行显式给出 result 路径(相对 result-root 或绝对路径)
|
||||||
if entry.get("result"):
|
if entry.get("result"):
|
||||||
path = Path(entry["result"])
|
path = Path(entry["result"])
|
||||||
if not path.is_absolute():
|
if not path.is_absolute():
|
||||||
path = Path(self.args.result_root) / path
|
path = Path(self.args.result_root) / path
|
||||||
return path
|
return path
|
||||||
key = self.paper_key(entry)
|
# 默认:result-root/<sanitized(paper_id)>/result.json
|
||||||
|
# sanitize 与抽取链落盘目录名一致(doi:/、/、() 等 → _),
|
||||||
|
# 直接用原始 paper_id 拼路径会因含 :/() 而找不到文件。
|
||||||
|
key = sanitize_paper_key(self.paper_key(entry))
|
||||||
return Path(self.args.result_root) / key / "result.json"
|
return Path(self.args.result_root) / key / "result.json"
|
||||||
|
|
||||||
# ---------- steps ----------
|
# ---------- steps ----------
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
嵌入 text 字段,分批 upsert 到 Qdrant collection。
|
嵌入 text 字段,分批 upsert 到 Qdrant collection。
|
||||||
|
|
||||||
特性:
|
特性:
|
||||||
|
- 批量嵌入:input 传数组(--embed-batch 条/批),一次请求嵌入一批文本,
|
||||||
|
再按批 upsert(实测单条约 2.7 texts/s → 批量约 37 texts/s,约 14x 吞吐)
|
||||||
- checkpoint JSONL:每成功 upsert 一个 assertion_id 记一行 → 断点续跑(kill 后重跑跳过已传)
|
- checkpoint JSONL:每成功 upsert 一个 assertion_id 记一行 → 断点续跑(kill 后重跑跳过已传)
|
||||||
- 同 id 重放覆盖(Qdrant upsert 语义 + UUID 确定性 id → 幂等)
|
- 同 id 重放覆盖(Qdrant upsert 语义 + UUID 确定性 id → 幂等)
|
||||||
- 确定性处理顺序(文件路径 + 文件内行序排序)
|
- 确定性处理顺序(文件路径 + 文件内行序排序)
|
||||||
@@ -15,7 +17,8 @@
|
|||||||
python qdrant_loader.py --points <dir|file> --qdrant http://127.0.0.1:27435 \
|
python qdrant_loader.py --points <dir|file> --qdrant http://127.0.0.1:27435 \
|
||||||
--collection aqua_srt_v1_20260930_normalized --batch 200 \
|
--collection aqua_srt_v1_20260930_normalized --batch 200 \
|
||||||
--checkpoint <file.jsonl> \
|
--checkpoint <file.jsonl> \
|
||||||
[--embed-url http://127.0.0.1:11434/api/embed] [--embed-model bge-m3:567m]
|
[--embed-url http://127.0.0.1:11434/api/embed] [--embed-model bge-m3:567m] \
|
||||||
|
[--embed-batch 32]
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -58,25 +61,54 @@ class EmbedClient:
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.dim: int | None = None
|
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]:
|
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):
|
for attempt in range(4):
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(self.url, json=payload, timeout=120)
|
||||||
self.url,
|
|
||||||
json={"model": self.model, "input": text[:EMBED_INPUT_LIMIT]},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
embeddings = data.get("embeddings") or []
|
raw = data.get("embeddings")
|
||||||
if embeddings:
|
if not isinstance(raw, list) or not raw:
|
||||||
vector = embeddings[0]
|
raise RuntimeError("embedding endpoint returned no vectors")
|
||||||
elif "embedding" in data:
|
vectors = [self._extract_vector(item) for item in raw]
|
||||||
vector = data["embedding"]
|
if any(v is None for v in vectors):
|
||||||
else:
|
raise RuntimeError(
|
||||||
raise RuntimeError("embedding endpoint returned no vector")
|
f"embedding endpoint returned {len(vectors)} vectors "
|
||||||
self.dim = len(vector)
|
f"for {len(texts)} inputs (bad shapes)"
|
||||||
return [float(value) for value in vector]
|
)
|
||||||
|
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:
|
except Exception as exc:
|
||||||
if attempt == 3:
|
if attempt == 3:
|
||||||
raise
|
raise
|
||||||
@@ -122,6 +154,29 @@ def load_checkpoint(checkpoint: Path) -> set:
|
|||||||
return done
|
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(
|
def upsert_file(
|
||||||
points_path: Path,
|
points_path: Path,
|
||||||
qdrant_url: str,
|
qdrant_url: str,
|
||||||
@@ -130,8 +185,13 @@ def upsert_file(
|
|||||||
*,
|
*,
|
||||||
checkpoint: Path,
|
checkpoint: Path,
|
||||||
embed: "EmbedClient",
|
embed: "EmbedClient",
|
||||||
|
embed_batch: int = 32,
|
||||||
) -> Dict[str, int]:
|
) -> Dict[str, int]:
|
||||||
"""上传单个 qdrant_points.jsonl(断点续跑,checkpoint 追加)。"""
|
"""上传单个 qdrant_points.jsonl(断点续跑,checkpoint 追加)。
|
||||||
|
|
||||||
|
嵌入按 embed_batch 条/批批量调用 /api/embed,再攒 batch_size 条 upsert;
|
||||||
|
批内单条失败时逐条回退重试。checkpoint 记录口径不变(assertion_id)。
|
||||||
|
"""
|
||||||
checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
||||||
done = load_checkpoint(checkpoint)
|
done = load_checkpoint(checkpoint)
|
||||||
qdrant = qdrant_url.rstrip("/")
|
qdrant = qdrant_url.rstrip("/")
|
||||||
@@ -139,6 +199,7 @@ def upsert_file(
|
|||||||
|
|
||||||
total = skipped = uploaded = 0
|
total = skipped = uploaded = 0
|
||||||
batch: List[Dict[str, Any]] = []
|
batch: List[Dict[str, Any]] = []
|
||||||
|
pending: List[Dict[str, Any]] = [] # 待嵌入的未 checkpoint 记录
|
||||||
started = time.time()
|
started = time.time()
|
||||||
with open(checkpoint, "a", encoding="utf-8") as checkpoint_handle:
|
with open(checkpoint, "a", encoding="utf-8") as checkpoint_handle:
|
||||||
def flush_batch() -> None:
|
def flush_batch() -> None:
|
||||||
@@ -154,17 +215,35 @@ def upsert_file(
|
|||||||
checkpoint_handle.flush()
|
checkpoint_handle.flush()
|
||||||
batch = []
|
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]):
|
for record in iter_points([points_path]):
|
||||||
total += 1
|
total += 1
|
||||||
assertion_id = record["payload"]["assertion_id"]
|
assertion_id = record["payload"]["assertion_id"]
|
||||||
if assertion_id in done:
|
if assertion_id in done:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
record["vector"] = embed.embed(record.get("text") or "")
|
pending.append(record)
|
||||||
batch.append(record)
|
if len(pending) >= embed_batch:
|
||||||
uploaded += 1
|
flush_pending()
|
||||||
if len(batch) >= batch_size:
|
flush_pending()
|
||||||
flush_batch()
|
|
||||||
flush_batch()
|
flush_batch()
|
||||||
return {
|
return {
|
||||||
"uploaded": uploaded,
|
"uploaded": uploaded,
|
||||||
@@ -183,6 +262,8 @@ def main() -> None:
|
|||||||
parser.add_argument("--checkpoint", required=True, help="JSONL of uploaded assertion_ids")
|
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-url", default=DEFAULT_EMBED_URL)
|
||||||
parser.add_argument("--embed-model", default=DEFAULT_EMBED_MODEL)
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
points_arg = Path(args.points)
|
points_arg = Path(args.points)
|
||||||
@@ -210,6 +291,7 @@ def main() -> None:
|
|||||||
args.batch,
|
args.batch,
|
||||||
checkpoint=checkpoint,
|
checkpoint=checkpoint,
|
||||||
embed=embed,
|
embed=embed,
|
||||||
|
embed_batch=args.embed_batch,
|
||||||
)
|
)
|
||||||
for key in grand:
|
for key in grand:
|
||||||
grand[key] += result[key]
|
grand[key] += result[key]
|
||||||
|
|||||||
Reference in New Issue
Block a user