299 lines
13 KiB
Python
299 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""embed skill:知识行批量嵌入(New-API / Qwen3-Embedding-8B / 1024 维)。
|
||
|
||
合同见同 skill SKILL.md;通道事实见 db/连接信息.md。失败原样报错不静默。
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import sys
|
||
import time
|
||
|
||
import click
|
||
import psycopg
|
||
import requests
|
||
|
||
DSN = ("postgresql://root:f6710e2d0294eb1c10e26a805a64bc54@100.64.0.8:5433/muse-example"
|
||
"?keepalives=1&keepalives_idle=15&keepalives_interval=5&keepalives_count=3")
|
||
BASE = "http://100.64.0.8:3000"
|
||
TOKEN = "sk-DyVqO3lDmEvQZ3PqGpbNaaaHZHhbh0xaHRIiynhYSmVlLHl2" # MUSE_AI_NEW_API_TOKEN(勿用管理令牌)
|
||
MODEL = "Qwen/Qwen3-Embedding-8B"
|
||
DIM = 1024
|
||
TENANT, ACTOR = 1, "1"
|
||
BATCH = 16
|
||
|
||
|
||
def _session():
|
||
"""禁系统代理的会话(系统代理会假 502)。"""
|
||
s = requests.Session()
|
||
s.trust_env = False
|
||
s.headers["Authorization"] = f"Bearer {TOKEN}"
|
||
return s
|
||
|
||
|
||
def embed_texts(sess, texts):
|
||
"""调 New-API /v1/embeddings;整批重试 2 次后逐条降级。返回 (向量列表, 失败索引集)。"""
|
||
def call(batch):
|
||
r = sess.post(f"{BASE}/v1/embeddings", json={
|
||
"model": MODEL, "input": batch, "dimensions": DIM}, timeout=120)
|
||
r.raise_for_status()
|
||
data = r.json()["data"]
|
||
return [d["embedding"] for d in sorted(data, key=lambda d: d["index"])]
|
||
|
||
for attempt in range(3):
|
||
try:
|
||
return call(texts), set()
|
||
except Exception as e:
|
||
if attempt < 2:
|
||
time.sleep(2 ** attempt)
|
||
continue
|
||
# 整批三败 → 逐条降级,坏行记错不断批
|
||
vecs, bad = [], set()
|
||
for i, t in enumerate(texts):
|
||
try:
|
||
vecs.append(call([t])[0])
|
||
except Exception as ee:
|
||
vecs.append(None)
|
||
bad.add(i)
|
||
click.echo(f" [失败] 第{i}条: {ee}", err=True)
|
||
return vecs, bad
|
||
|
||
|
||
def build_embed_text(payload: dict) -> str:
|
||
"""嵌入文本构造:payload 自带 embed_text 优先;否则固定拼接(与检索端语义对齐)。"""
|
||
if payload.get("embed_text"):
|
||
return payload["embed_text"]
|
||
# 型取值补 type 键:升格卡 payload 用 type 存型(非 型/target_type),漏认会产出「【】名称…」丢型文本,
|
||
# 令升格卡向量与检索端跨型语义错位;补一段式回退(additive,不动 型/target_type 既有行为)。
|
||
t = payload.get("型") or payload.get("type") or payload.get("target_type", "")
|
||
name = payload.get("名称", "")
|
||
brief = payload.get("一句话摘要", "")
|
||
fields = payload.get("字段") or {}
|
||
body = "\n".join(f"{k}:{v}" for k, v in fields.items() if v and k not in ("名称", "一句话摘要"))
|
||
return f"【{t}】{name}:{brief}\n{body}"[:4000]
|
||
|
||
|
||
class EmbeddingOwnershipConflict(RuntimeError):
|
||
"""同 hash 唯一行已归实体或其他活跃 draft,禁止迁移 owner。"""
|
||
|
||
|
||
def _embedding_owner_action(conn, draft_id, content_hash, *, lock=False):
|
||
"""判断同 hash 唯一行应幂等跳过还是写入;写段可锁行封住预查后的竞态。"""
|
||
|
||
lock_clause = " FOR UPDATE OF e" if lock else ""
|
||
owner = conn.execute(
|
||
"""SELECT e.draft_id, e.entity_id, e.deleted,
|
||
COALESCE(d.deleted, TRUE), d.tenant_id
|
||
FROM example_knowledge_embedding e
|
||
LEFT JOIN muse_knowledge_draft d ON d.id=e.draft_id
|
||
WHERE e.tenant_id=%s AND e.content_hash=%s AND e.model=%s""" + lock_clause,
|
||
(TENANT, content_hash, MODEL),
|
||
).fetchone()
|
||
if not owner:
|
||
return "write"
|
||
|
||
owner_draft_id, owner_entity_id, embedding_deleted, owner_deleted, owner_tenant = owner
|
||
# entity owner 是确认后的正式归属,任何 draft 都不得把它降级抢回。
|
||
if owner_entity_id is not None:
|
||
raise EmbeddingOwnershipConflict(
|
||
f"同 hash 唯一行已归 entity:hash={content_hash},entity={owner_entity_id},"
|
||
f"candidate={draft_id}"
|
||
)
|
||
# 只有当前租户、当前 draft、两侧都 active 才是真正的幂等命中。
|
||
if owner_draft_id == draft_id:
|
||
if owner_tenant != TENANT:
|
||
raise EmbeddingOwnershipConflict(
|
||
f"同 hash 当前 owner 租户不匹配:hash={content_hash},"
|
||
f"owner_tenant={owner_tenant},candidate_tenant={TENANT}"
|
||
)
|
||
if not embedding_deleted and not owner_deleted:
|
||
return "skip"
|
||
return "write"
|
||
# 空 owner、owner 行缺失或 owner draft 已软删时,可由当前活跃 draft 接管唯一行。
|
||
if owner_draft_id is None or owner_deleted:
|
||
return "write"
|
||
raise EmbeddingOwnershipConflict(
|
||
f"同 hash 唯一行已归其他 active draft:hash={content_hash},"
|
||
f"owner={owner_draft_id},candidate={draft_id}"
|
||
)
|
||
|
||
|
||
def _write_embedding(conn, draft_id, content_hash, text, vector):
|
||
"""在调用方单 draft 事务内锁定活性与 owner,条件写入并校验最终归属。"""
|
||
|
||
# 写事务先按固定表顺序取得 ROW EXCLUSIVE 锁,避免与 reset 的多表锁形成交叉等待。
|
||
conn.execute(
|
||
"LOCK TABLE muse_knowledge_draft, example_knowledge_embedding IN ROW EXCLUSIVE MODE"
|
||
)
|
||
# 取得表锁后再锁 candidate draft:embed 先到时 reset 的七表 SHARE ROW EXCLUSIVE 会等待;
|
||
# reset 先到时本查询等待其提交,随后读取 deleted=TRUE 并拒绝陈旧写入。
|
||
candidate = conn.execute(
|
||
"""SELECT tenant_id, deleted, status, draft_payload FROM muse_knowledge_draft
|
||
WHERE id=%s FOR UPDATE""",
|
||
(draft_id,),
|
||
).fetchone()
|
||
if not candidate:
|
||
click.echo(f" [跳过] draft={draft_id} 写前已不存在,未写向量", err=True)
|
||
return False
|
||
candidate_tenant, candidate_deleted, candidate_status, current_payload = candidate
|
||
if candidate_tenant != TENANT:
|
||
raise EmbeddingOwnershipConflict(
|
||
f"draft 租户不匹配:draft={draft_id},tenant={candidate_tenant},expected={TENANT}"
|
||
)
|
||
if candidate_deleted:
|
||
click.echo(f" [跳过] draft={draft_id} 写前已软删,未写向量", err=True)
|
||
return False
|
||
if candidate_status != "pending":
|
||
click.echo(
|
||
f" [跳过] draft={draft_id} 写前 status={candidate_status},非 pending,未写向量",
|
||
err=True,
|
||
)
|
||
return False
|
||
|
||
# HTTP 期间 payload 可能被 parse/confirm 更新;锁内必须按当前 payload 重构文本与 hash,
|
||
# 只要与 HTTP 请求所依据的快照不同,就丢弃陈旧向量,绝不覆盖并发产生的新结果。
|
||
current_text = build_embed_text(current_payload or {})
|
||
current_hash = hashlib.sha256(f"{current_text}|{MODEL}".encode()).hexdigest()
|
||
if current_text != text or current_hash != content_hash:
|
||
click.echo(
|
||
f" [跳过] draft={draft_id} 写前 payload/hash 漂移,"
|
||
f"expected_hash={content_hash} current_hash={current_hash},未写向量",
|
||
err=True,
|
||
)
|
||
return False
|
||
|
||
# 锁定该 draft 的全部活向量,保证 entity 归属和“每 draft 唯一活向量”在同一事务内判定。
|
||
live_embeddings = conn.execute(
|
||
"""SELECT id, content_hash, entity_id FROM example_knowledge_embedding
|
||
WHERE tenant_id=%s AND draft_id=%s AND deleted=FALSE
|
||
FOR UPDATE""",
|
||
(TENANT, draft_id),
|
||
).fetchall()
|
||
entity_rows = [
|
||
(row_id, row_hash, entity_id)
|
||
for row_id, row_hash, entity_id in live_embeddings
|
||
if entity_id is not None
|
||
]
|
||
if entity_rows:
|
||
raise EmbeddingOwnershipConflict(
|
||
f"draft={draft_id} 存在 entity_id 非空旧活向量,禁止覆盖:{entity_rows}"
|
||
)
|
||
if any(row_hash == content_hash for _, row_hash, _ in live_embeddings):
|
||
click.echo(f" [跳过] draft={draft_id} 同 hash 活向量已由当前 draft 持有")
|
||
return False
|
||
|
||
action = _embedding_owner_action(conn, draft_id, content_hash, lock=True)
|
||
if action == "skip":
|
||
click.echo(f" [跳过] draft={draft_id} 同 hash 活向量已由当前 draft 持有")
|
||
return False
|
||
|
||
if live_embeddings:
|
||
# 当前 payload 已通过锁内 hash 重验,因此其余 hash 均为该 draft 的过期向量;
|
||
# 只允许软删 draft owner,entity owner 已在上方失败关闭。
|
||
conn.execute(
|
||
"""UPDATE example_knowledge_embedding SET deleted=TRUE, updater=%s
|
||
WHERE tenant_id=%s AND draft_id=%s AND deleted=FALSE
|
||
AND entity_id IS NULL AND content_hash!=%s""",
|
||
(ACTOR, TENANT, draft_id, content_hash),
|
||
)
|
||
|
||
# 条件 UPSERT 是行锁检查后的第二道防线:当预查时唯一行尚不存在、随后被并发插入时,
|
||
# 仅允许当前 owner 或已失活 owner 迁移;entity/其他 active draft 均令 RETURNING 为空。
|
||
upserted = conn.execute(
|
||
"""INSERT INTO example_knowledge_embedding
|
||
(draft_id, content_hash, embed_text, model, dimensions, embedding,
|
||
creator, updater, tenant_id)
|
||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||
ON CONFLICT (tenant_id, content_hash, model)
|
||
DO UPDATE SET draft_id=EXCLUDED.draft_id,
|
||
embed_text=EXCLUDED.embed_text,
|
||
model=EXCLUDED.model,
|
||
dimensions=EXCLUDED.dimensions,
|
||
embedding=EXCLUDED.embedding,
|
||
deleted=FALSE,
|
||
updater=EXCLUDED.updater
|
||
WHERE example_knowledge_embedding.entity_id IS NULL
|
||
AND (example_knowledge_embedding.draft_id=EXCLUDED.draft_id
|
||
OR NOT EXISTS (
|
||
SELECT 1 FROM muse_knowledge_draft owner
|
||
WHERE owner.id=example_knowledge_embedding.draft_id
|
||
AND owner.deleted=FALSE))
|
||
RETURNING draft_id""",
|
||
(draft_id, content_hash, text, MODEL, DIM, json.dumps(vector),
|
||
ACTOR, ACTOR, TENANT),
|
||
).fetchone()
|
||
if not upserted or upserted[0] != draft_id:
|
||
raise EmbeddingOwnershipConflict(
|
||
f"同 hash 唯一行未绑定当前 draft:hash={content_hash},candidate={draft_id}"
|
||
)
|
||
return True
|
||
|
||
|
||
@click.command()
|
||
@click.option("--work-id", type=int, help="限定拆书批次的 work(draft.work_id=0 为全局行,用 source_id 关联参考书)")
|
||
@click.option("--limit", type=int, default=0, help="最多处理条数(0=不限)")
|
||
@click.option("--probe", help="自由文本试嵌(打印维度与前 5 维,不落库)")
|
||
def main(work_id, limit, probe):
|
||
sess = _session()
|
||
if probe:
|
||
vecs, bad = embed_texts(sess, [probe])
|
||
if bad:
|
||
raise click.ClickException("试嵌失败")
|
||
v = vecs[0]
|
||
click.echo(f"维度={len(v)} 前5维={[round(x, 4) for x in v[:5]]}")
|
||
return
|
||
|
||
with psycopg.connect(DSN) as conn:
|
||
# 待嵌=pending 草稿且无嵌入行
|
||
sql = """SELECT d.id, d.draft_payload FROM muse_knowledge_draft d
|
||
WHERE d.tenant_id=%s AND d.deleted=FALSE AND d.status='pending'
|
||
AND NOT EXISTS (SELECT 1 FROM example_knowledge_embedding e
|
||
WHERE e.tenant_id=%s AND e.draft_id=d.id AND e.deleted=FALSE)"""
|
||
args = [TENANT, TENANT]
|
||
if work_id is not None:
|
||
sql += " AND d.source_id=%s"
|
||
args.append(work_id)
|
||
sql += " ORDER BY d.id"
|
||
if limit:
|
||
sql += f" LIMIT {int(limit)}"
|
||
rows = conn.execute(sql, args).fetchall()
|
||
click.echo(f"待嵌草稿: {len(rows)} 条")
|
||
|
||
done = skip = fail = 0
|
||
for i in range(0, len(rows), BATCH):
|
||
chunk = rows[i:i + BATCH]
|
||
texts, metas = [], []
|
||
for did, payload in chunk:
|
||
text = build_embed_text(payload or {})
|
||
h = hashlib.sha256(f"{text}|{MODEL}".encode()).hexdigest()
|
||
if _embedding_owner_action(conn, did, h) == "skip":
|
||
skip += 1 # 仅当前 active draft 持有 active 唯一行时才幂等跳过。
|
||
continue
|
||
texts.append(text)
|
||
metas.append((did, h, text))
|
||
# owner 预查只用于避免无效 HTTP;明确提交读事务,HTTP 期间不持数据库事务或表锁。
|
||
conn.commit()
|
||
if not texts:
|
||
continue
|
||
vecs, bad = embed_texts(sess, texts)
|
||
for j, (did, h, text) in enumerate(metas):
|
||
if j in bad:
|
||
fail += 1
|
||
continue
|
||
# 每个 draft 独立事务:draft 行锁、owner 行锁、条件 UPSERT 同生共死。
|
||
with conn.transaction():
|
||
written = _write_embedding(conn, did, h, text, vecs[j])
|
||
if written:
|
||
done += 1
|
||
else:
|
||
skip += 1
|
||
click.echo(f" 进度 {min(i + BATCH, len(rows))}/{len(rows)}(新嵌{done} 跳过{skip} 失败{fail})")
|
||
click.echo(f"完成:新嵌 {done}、跳过 {skip}、失败 {fail}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
main()
|
||
except (EmbeddingOwnershipConflict, psycopg.Error, requests.RequestException) as e:
|
||
click.echo(f"[错误] {type(e).__name__}: {e}", err=True)
|
||
sys.exit(1)
|