💻 源码
"""体检 __003__faiss_embedding.py 存下来的两个文件是否可用。

要测的是「这套索引现在能不能拿来做实体标准化」,不是「文件能不能打开」,所以除了
加载,还要真的查一次,并且和 Neo4j 里的实体对一遍。分七项:

  1. 两个文件都在、能加载,index 和 pkl 各自自洽
  2. 向量条数 == 映射条数,且 id 是 0..N-1 连续无缺(faiss 只认下标,映射缺一个
     id 就会在检索时 KeyError,或者更糟 —— 错位返回别人的名字)
  3. 索引维度 == 当前 embedding 模型维度(换过模型就必须重建,否则 search 直接报错)
  4. 自检索:拿索引里已有的实体名去查,top1 必须是它自己且距离≈0。这一项才真正证明
     向量和映射是对齐的 —— 前两项只能证明数量对得上
  5. 归一化一致性:建库时用了 normalize_embeddings=True,查询若不归一化,L2 距离
     就不在同一尺度上。这里两种都跑一遍做对比,明确该怎么查
  6. 语义检索:用不在库里的口语词(「拉肚子」)查,看回来的是不是相关实体
  7. 与 Neo4j 对账:映射里的名字是否还都在库里、库里的名字是否都进了索引 ——
     _003_ 重新导入过数据但没重建索引的话,这一项会亮

用法(在项目根目录执行):
    python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index
    python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index --skip-neo4j
    python -X utf8 -m _003_create_neo4j_database.__004__test_faiss_index --query 拉肚子 失眠
"""

import argparse
import os
import pickle
import random

import faiss
import numpy as np

from common.path_utils import get_file_path

INDEX_PATH = get_file_path("_003_create_neo4j_database/nero4j_embedding_faiss.index")
MAPPING_PATH = get_file_path(
    "_003_create_neo4j_database/nero4j_embedding_faiss_id2text.pkl"
)

# 默认的语义检索用例:故意用不在实体表里的口语说法,看能不能召回对应的中医术语
DEFAULT_QUERIES = ["拉肚子", "睡不着觉", "头疼", "补气血的药", "咳嗽有痰"]

# 每项检查的结果都塞进这里,最后统一汇总,中间失败不早退(除非没法继续)
results = []


def check(name, ok, detail=""):
    results.append((name, ok, detail))
    print(f"  [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
    return ok


# ======================
# 1. 加载
# ======================
def load_files():
    print("\n[1] 加载文件")
    for path in (INDEX_PATH, MAPPING_PATH):
        if not os.path.exists(path):
            check(f"文件存在: {os.path.basename(path)}", False, "文件不存在")
            print("\n先跑 __003__faiss_embedding.py 生成索引。")
            return None, None
        size_mb = os.path.getsize(path) / 1024 / 1024
        check(f"文件存在: {os.path.basename(path)}", True, f"{size_mb:.1f} MB")

    try:
        index = faiss.read_index(INDEX_PATH)
    except Exception as exc:  # faiss 抛的是 RuntimeError,也可能是别的
        check("faiss.read_index 可加载", False, f"{type(exc).__name__}: {exc}")
        return None, None
    check(
        "faiss.read_index 可加载",
        True,
        f"{type(index).__name__}, ntotal={index.ntotal}, d={index.d}",
    )

    try:
        with open(MAPPING_PATH, "rb") as f:
            id2text = pickle.load(f)
    except Exception as exc:
        check("pickle 可加载", False, f"{type(exc).__name__}: {exc}")
        return index, None
    check("pickle 可加载", True, f"{type(id2text).__name__}, {len(id2text)} 条")

    return index, id2text


# ======================
# 2. 数量与 id 连续性
# ======================
def check_alignment(index, id2text):
    print("\n[2] 向量与映射对齐")
    check(
        "向量数 == 映射数",
        index.ntotal == len(id2text),
        f"index.ntotal={index.ntotal}, 映射={len(id2text)}",
    )

    keys = set(id2text)
    expected = set(range(index.ntotal))
    missing = expected - keys
    extra = keys - expected
    # id 不是 0..N-1 的话,search 返回的下标就可能查不到名字,或者取到错位的名字
    check(
        "映射 id 覆盖 0..ntotal-1",
        not missing and not extra,
        f"缺 {len(missing)} 个, 多 {len(extra)} 个"
        if (missing or extra)
        else "连续无缺",
    )

    empty = [k for k, v in id2text.items() if not isinstance(v, str) or not v.strip()]
    check("映射值都是非空字符串", not empty, f"异常 {len(empty)} 条")

    # 实体名重复不算错(Disease 和 Symptom 同名会各出现一次),但值得报出来:
    # 重复意味着同一个名字占了两个向量,检索时会连着返回两条一样的结果
    names = list(id2text.values())
    dup = len(names) - len(set(names))
    check("映射值无重复", dup == 0, f"重复 {dup} 条" if dup else "全部唯一")


# ======================
# 3. 维度 & 4/5. 自检索
# ======================
def check_dimension(index, model):
    print("\n[3] 维度匹配")
    # get_sentence_embedding_dimension 在新版 sentence-transformers 已改名,
    # 优先用新名字,取不到再退回旧名字,兼容两个版本
    getter = getattr(model.model, "get_embedding_dimension", None) or getattr(
        model.model, "get_sentence_embedding_dimension"
    )
    dim = getter()
    ok = dim == index.d
    check(
        "模型维度 == 索引维度",
        ok,
        f"模型={dim}, 索引={index.d}" + ("" if ok else " → 换过模型,必须重建索引"),
    )
    return ok


def check_self_retrieval(index, id2text, model, sample=20, seed=0):
    """拿索引里已有的名字去查,top1 必须是它自己。

    这是整个脚本最关键的一项:数量对得上只能说明两个文件规模一致,只有自检索命中
    才能证明第 i 个向量对应的确实是 id2text[i] 这个名字(没有错位)。
    """
    print(f"\n[4] 自检索(随机抽 {sample} 个已有实体,top1 应命中自己)")
    rng = random.Random(seed)
    ids = rng.sample(range(index.ntotal), min(sample, index.ntotal))
    texts = [id2text[i] for i in ids]

    # 建库时用的是 normalize_embeddings=True,查询保持一致
    vecs = model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
    distances, indices = index.search(np.asarray(vecs, dtype="float32"), 1)

    hit = sum(1 for want, got in zip(ids, indices[:, 0]) if want == got)
    max_dist = float(distances[:, 0].max())
    check("top1 全部命中自己", hit == len(ids), f"{hit}/{len(ids)}")
    # 同一段文本重新编码后向量应当一模一样,L2 距离接近 0;明显大于 0 说明
    # 建库和现在用的不是同一个模型(或同模型不同配置)
    check(
        "自检索距离 ≈ 0",
        max_dist < 1e-4,
        f"最大距离={max_dist:.2e}",
    )

    miss = [
        (id2text[w], id2text.get(int(g)))
        for w, g in zip(ids, indices[:, 0])
        if w != int(g)
    ]
    if miss:
        print("    未命中的样本(期望 → 实际):")
        for want, got in miss[:5]:
            print(f"      {want} → {got}")


def check_normalization(index, id2text, model, sample=10, seed=1):
    """确认向量是单位长度,以及 normalize_embeddings 这个开关到底有没有影响。

    建库时传了 normalize_embeddings=True,直觉上查询也必须传,否则距离尺度对不上。
    但 bge-large-zh-v1.5 的 SentenceTransformer 结构最后一层就是 Normalize,出来的
    向量本身已经是单位长度,这个开关对它是空操作 —— 所以两种查法结果完全一样。
    这里把两种都跑一遍并断言「结果一致」,如果哪天换了个不自带 Normalize 的模型,
    这一项会立刻失败,提醒必须显式归一化。

    单位长度还有个用处:此时 L2 距离和余弦相似度是单调等价的(d² = 2-2cos),
    所以 IndexFlatL2 的排序结果就等于按余弦相似度排,距离范围落在 [0, 2]。
    """
    print("\n[5] 归一化一致性(建库用了 normalize_embeddings=True)")
    rng = random.Random(seed)
    ids = rng.sample(range(index.ntotal), min(sample, index.ntotal))
    texts = [id2text[i] for i in ids]
    arr = np.asarray(ids)

    norm = model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
    raw = model.encode(texts, convert_to_numpy=True, normalize_embeddings=False)
    d_norm, i_norm = index.search(np.asarray(norm, dtype="float32"), 1)
    d_raw, i_raw = index.search(np.asarray(raw, dtype="float32"), 1)

    hit_norm = int((i_norm[:, 0] == arr).sum())
    hit_raw = int((i_raw[:, 0] == arr).sum())
    raw_norms = np.linalg.norm(np.asarray(raw), axis=1)
    print(
        f"    归一化查询: 命中 {hit_norm}/{len(ids)}, 平均距离 {d_norm[:, 0].mean():.4f}"
    )
    print(
        f"    未归一化查询: 命中 {hit_raw}/{len(ids)}, 平均距离 {d_raw[:, 0].mean():.4f}"
    )
    print(
        f"    不传归一化时的向量模长: {raw_norms.min():.4f} ~ {raw_norms.max():.4f}"
        "(≈1 说明模型自带 Normalize 层)"
    )

    check("归一化查询能命中自己", hit_norm == len(ids), f"{hit_norm}/{len(ids)}")
    # 模型自带 Normalize 时这一项必然通过;换成不自带的模型就会失败,
    # 那时候就必须在检索侧显式 normalize_embeddings=True
    check(
        "开关不影响结果(模型自带 Normalize)",
        hit_raw == hit_norm and np.allclose(raw_norms, 1.0, atol=1e-3),
        f"模长≈{raw_norms.mean():.4f}",
    )


# ======================
# 6. 语义检索
# ======================
def check_semantic(index, id2text, model, queries, top_k=5):
    """用不在实体表里的口语说法查,看能不能召回对应的中医术语。

    这一项没有硬性判据 —— 召回质量得人看。脚本只保证「能查、有结果、id 都能翻回
    名字」,把结果打出来供判断。这正是实体标准化(用户说「拉肚子」→ 图里的「腹泻」)
    在 _004_ 里要依赖的能力。
    """
    print(f"\n[6] 语义检索(top{top_k},查询词不在实体表里)")
    vecs = model.encode(queries, convert_to_numpy=True, normalize_embeddings=True)
    distances, indices = index.search(np.asarray(vecs, dtype="float32"), top_k)

    unmapped = 0
    for query, dist_row, idx_row in zip(queries, distances, indices):
        hits = []
        for dist, idx in zip(dist_row, idx_row):
            name = id2text.get(int(idx))
            if name is None:
                unmapped += 1
                name = f"<id {idx} 无映射>"
            hits.append(f"{name}({dist:.3f})")
        print(f"    {query} → {' | '.join(hits)}")

    check("检索结果 id 都能翻回名字", unmapped == 0, f"{unmapped} 个 id 查不到映射")


# ======================
# 定点用例
# ======================
# 口语说法 → 期望能召回的图中实体(只要 topK 里出现任意一个就算通过)。
# 这几条是实体标准化的典型场景:用户嘴里的词和图里的术语对不上,只能靠向量召回。
# 断言写成「至少命中一个」而不是「top1 必须是某个词」—— 同义术语在图里往往有好几个
# 粒度不同的写法(腹泻/泄泻/泻痢),谁排第一取决于模型,锁死 top1 会让测试很脆。
STANDARDIZE_CASES = [
    ("拉肚子", ["腹泻", "泄泻", "泻痢"]),
    ("睡不着觉", ["失眠", "睡眠不好", "不能入睡"]),
    ("咳嗽有痰", ["咳嗽痰多", "咳嗽多痰", "咳痰"]),
]


def check_cases(index, id2text, model, top_k=10, cases=STANDARDIZE_CASES):
    """定点用例:口语词能不能召回图里对应的实体名。

    比 [6] 严格 —— [6] 只看「有没有结果」,这里明确写死了期望值,回归时能发现
    换模型 / 重建索引导致的召回退化。
    """
    print(f"\n[7] 实体标准化定点用例(top{top_k} 内应出现期望实体)")
    queries = [q for q, _ in cases]
    vecs = model.encode(queries, convert_to_numpy=True, normalize_embeddings=True)
    distances, indices = index.search(np.asarray(vecs, dtype="float32"), top_k)

    for (query, expected), dist_row, idx_row in zip(cases, distances, indices):
        names = [id2text.get(int(i)) for i in idx_row]
        found = [e for e in expected if e in names]
        top1 = f"{names[0]}({dist_row[0]:.3f})"
        check(
            f"「{query}」召回 {'/'.join(expected)}",
            bool(found),
            f"top1={top1}, 命中={found or '无'}",
        )
        # 把完整 topK 打出来,方便人工看召回质量而不只是看通过与否
        detail = " | ".join(f"{n}({d:.3f})" for n, d in zip(names, dist_row))
        print(f"      top{top_k}: {detail}")


def show_case_detail(index, id2text, query, top_k=10, skip_neo4j=False):
    """把单个查询的相似实体连标签一起列出来。

    带上 Neo4j 里的标签才知道召回的是症状还是疾病还是功效 —— 做实体标准化时,
    「拉肚子」应该落到 Symptom/Disease,如果 top 几全是 Effect 就说明用错了方向。
    """
    print("\n" + "=" * 60)
    print(f"用例明细:「{query}」的相似实体(top{top_k})")
    print("=" * 60)

    vec = model_encode_one(query)
    distances, indices = index.search(vec, top_k)

    labels = {}
    if not skip_neo4j:
        try:
            from common.neo4j_manager import neo4j_client

            names = [id2text.get(int(i)) for i in indices[0]]
            rows = neo4j_client.run_cypher(
                "UNWIND $names AS nm MATCH (n {name: nm}) "
                "RETURN nm AS name, collect(DISTINCT head(labels(n))) AS labels",
                {"names": names},
            )
            labels = {r["name"]: r["labels"] for r in rows}
        except Exception as exc:
            print(f"(标签查询失败,只显示名字: {type(exc).__name__})")

    print(f"{'排名':<5}{'实体名':<18}{'L2距离':<10}{'余弦相似度':<12}标签")
    print("-" * 60)
    for rank, (dist, idx) in enumerate(zip(distances[0], indices[0]), 1):
        name = id2text.get(int(idx), f"<id {idx}>")
        # 向量是单位长度,所以 d² = 2 - 2cos,可以反推余弦相似度
        cos = 1 - float(dist) / 2
        tag = "/".join(labels.get(name, [])) or "-"
        print(f"{rank:<5}{name:<18}{float(dist):<10.4f}{cos:<12.4f}{tag}")


def model_encode_one(text):
    """单条编码,show_case_detail 用。模型是全局单例,这里直接取。"""
    from common.embedding_model import embedding_model

    vec = embedding_model.encode(
        [text], convert_to_numpy=True, normalize_embeddings=True
    )
    return np.asarray(vec, dtype="float32")


# ======================
# 7. 与 Neo4j 对账
# ======================
def check_against_neo4j(id2text):
    """索引是不是当前 Neo4j 的快照。

    _003_ 重新导入过数据、但没重跑 __003__faiss_embedding.py 的话,这一项会露出来:
    图里新增的实体检索不到,索引里的旧实体又对不上任何节点。
    """
    print("\n[8] 与 Neo4j 对账")
    try:
        from common.neo4j_manager import neo4j_client

        node_names = neo4j_client.get_all_node_names()
    except Exception as exc:
        check("Neo4j 可连接", False, f"{type(exc).__name__}: {exc}")
        return

    graph = set(node_names)
    indexed = set(id2text.values())
    missing = graph - indexed  # 图里有、索引里没有 → 检索不到
    stale = indexed - graph  # 索引里有、图里没了 → 召回了不存在的实体

    print(f"    Neo4j 去重实体名 {len(graph)} 个,索引 {len(indexed)} 个")
    check("图中实体都已入索引", not missing, f"缺 {len(missing)} 个")
    if missing:
        print(f"      例: {list(missing)[:5]}")
    check("索引中无失效实体", not stale, f"多 {len(stale)} 个")
    if stale:
        print(f"      例: {list(stale)[:5]}")
    if missing or stale:
        print("    → 图和索引不同步,重跑 __003__faiss_embedding.py")


def main():
    parser = argparse.ArgumentParser(
        description="检查 nero4j_embedding_faiss.index / _id2text.pkl 是否可用"
    )
    parser.add_argument(
        "--query",
        nargs="*",
        default=DEFAULT_QUERIES,
        help="语义检索用的查询词,可给多个",
    )
    parser.add_argument("--top-k", type=int, default=5, help="语义检索返回条数")
    parser.add_argument(
        "--detail",
        default="拉肚子",
        help="末尾单独列出这个查询的相似实体(带 Neo4j 标签),默认「拉肚子」;传空串关闭",
    )
    parser.add_argument(
        "--sample", type=int, default=20, help="自检索抽样条数,默认 20"
    )
    parser.add_argument(
        "--skip-neo4j",
        action="store_true",
        help="跳过与 Neo4j 的对账(连不上库时用)",
    )
    args = parser.parse_args()

    print("=" * 60)
    print("FAISS 索引可用性检查")
    print("=" * 60)

    index, id2text = load_files()
    if index is None or id2text is None:
        print("\n文件都没加载起来,后面的检查没法做。")
        raise SystemExit(1)

    check_alignment(index, id2text)

    # 模型加载慢(几百 MB 权重 + 可能上 GPU),放在文件检查之后:
    # 文件本身有问题的话,没必要等模型
    print("\n加载 embedding 模型...")
    from common.embedding_model import embedding_model

    dim_ok = check_dimension(index, embedding_model)
    if dim_ok:
        check_self_retrieval(index, id2text, embedding_model, sample=args.sample)
        check_normalization(index, id2text, embedding_model)
        check_semantic(index, id2text, embedding_model, args.query, top_k=args.top_k)
        check_cases(index, id2text, embedding_model, top_k=args.top_k)
    else:
        print("  维度不匹配,跳过所有检索项 —— 先重建索引。")

    if not args.skip_neo4j:
        check_against_neo4j(id2text)

    # 明细放在汇总前面:这是给人看的部分,跑完最想看到的就是它
    if dim_ok and args.detail:
        show_case_detail(
            index,
            id2text,
            args.detail,
            top_k=args.top_k,
            skip_neo4j=args.skip_neo4j,
        )

    print("\n" + "=" * 60)
    failed = [r for r in results if not r[1]]
    print(f"共 {len(results)} 项检查,失败 {len(failed)} 项")
    for name, _, detail in failed:
        print(f"  FAIL {name} — {detail}")
    if not failed:
        print("索引可用:向量与映射对齐,模型维度一致,检索正常。")
    print("=" * 60)
    raise SystemExit(1 if failed else 0)


if __name__ == "__main__":
    main()