ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

本地LLM驱动的书目超作品簇构建:FRBR语义聚合实战

本地LLM驱动的书目超作品簇构建:FRBR语义聚合实战 检索系统里有一类问题表面上叫“查全率不高”实际上查的是元数据质量同一部作品被不同版本的 MARC 记录拆散读者搜不到图书馆员也整理不动。比如你在图书馆资源发现系统里搜索“红楼梦”如果结果是一长串杂乱的书目记录用户就要自己去判断“人民文学出版社 2020 年版”与“某出版社白话本”之间到底是什么关系。更理想的结果是一个聚合后的作品卡片用户先看到一部完整的“超作品簇”展开后才是原著、校注本、白话本、英文节译本、有声书等不同版本。把这种“同源书目记录”聚合到一起过去主要靠编目员手工做或者靠 ISBN、题名、责任者等字段做规则聚类。现在本地 LLM 提供了一个新的选项也正是 “Building Bibliographic Superwork Clusters for Discovery with Local LLMs” 这类方案试图回答的问题能不能用本地部署的大语言模型把版本归属判断、作品亲缘判断这类语义决策变得更通用、更便宜、更可控我的判断是这个问题真正的难点不在“把模型跑起来”而在于把图书馆行业积累的 FRBR / 编目知识转变成模型能理解的决策任务并且用可控的工程流程承接住模型的输出。如果你正在做图书情报系统、机构知识库、资源发现系统或者任何一个需要做“实体消歧 同类聚合”的搜索产品这篇文章会给你一条可以直接参考的落地路径。1. 为什么“同一本书”在系统里会变成一堆乱账1.1 版本关系乱象图书馆行业把“书”理解成一条条独立的 MARC 记录。一条记录可以对应一本纸质书、一个电子书文件、一份缩微胶卷甚至是一张光盘。它默认服务的是馆藏管理而不是用户发现。这种设计在过去几十年是稳定的可一旦面向读者做检索问题就暴露了同一部作品出简体版、繁体版、校注版、白话版、节选版、导读版、英文版每换一个出版社、每换一次 OCR 或排版都可能生成一条新记录。用户视角下的“一本书”和编目系统里的“一条记录”根本不是同一个层级。读者想要的是“这本书”系统返回给他的却是几十个不整齐的“片断”。当这些片断里还包含下卷单独出版、纪念版改名、不同译者重新起名等情况书名匹配也就失灵了例如《红楼梦》在不同版本里叫《石头记》《脂砚斋重评石头记》英文名可能是《A Dream of Red Mansions》或《The Story of the Stone》。它们内容相关但字符串层面的相似度并不高。1.2 超作品簇在做什么“超作品”这个概念在图书馆领域有不同的称呼比如 superwork、work family、FRBR group 等。FRBR 是图书馆界定义的一套描述书目关系的模型它把书目对象分成作品、表达、载体表现、单件四个层次。但标准模型到了真实系统里经常要面对大量模糊记录缺失作者、重复 ISBN、书名随意、版本说明不统一。因此本文说的“超作品簇”可以理解为一个业务分组层把内容上同源、或者存在明确衍生关系的记录归到一个可在发现系统中折叠、展开的聚合结果。它不要求你把每条记录都精确映射到 FRBR 的某个层级而是先做“哪些记录应该出现在同一个作品卡片下”这一层判断。围绕这个目标可以用规则可以用向量也可以让本地 LLM 辅助做语义裁决。2. 本地 LLM 在这个任务里的真实价值与边界2.1 为什么传统聚类方式不够传统做法通常分三层精确匹配ISBN 相等、题名归一化相等、责任者相等多个键同时满足就直接归并。模糊匹配编辑距离、Jaccard、BM25或者把 MARC 字段拼成文本后计算相似度。聚类DBSCAN、连通分量、层次聚类等把相似记录聚到一起。这套管线适合处理“干净的、重复明显的”数据。一旦进入学术书、古籍、翻译作品、多卷书问题就会变得很难判断。“同名异书”很常见两本都叫《Spring》的书很可能毫无关系同一个书名一个作者写于 1980 年另一个写于 2022 年不应合并到同一簇。“异名同书”也很常见题名不同、译者不同但原作者相同内容表达同源。规则要在这些情况里兼顾往往需要维护几十条互相打架的规则最后变成了一个编目规则黑洞。2.2 本地 LLM 应该承担什么角色一个经常被误解的地方是本地 LLM 不是来替代整条聚类流程的更不是来当“自动编目员”的。它的优势在于语义理解能阅读两条记录字段结合题名、责任者、出版年、版本说明、载体项判断两条记录之间的版本关系。这是传统字符串算法不太擅长而大语言模型比较擅长的部分。但 LLM 也有明显的短板无法同时处理千万条记录长上下文和全库比较都不现实。存在幻觉风险证据不足时也会顺着你的提示词“努力找理由合并”。本地小模型的结构化输出不如云端大模型稳定。所以更务实的架构是先用规则和向量检索召回候选再用本地 LLM 做“关系裁决”最后让编目员审核关键样本。这样既控制了模型调用量和成本又把决定权留在人这一端。本地部署的意义不只是省 API 费用更重要的是数据留在内部环境记录不必送到第三方模型同时可以针对自己的数据反复调 prompt。3. 整体架构候选召回 规则预筛 本地 LLM 裁决3.1 一条可复用的流水线从工程视角看这个系统可以拆成 7 个步骤输入书目记录通常是 MARC、MARCXML 或统一后的 JSON。字段解析与特征提取提取题名、责任者、出版社、出版年、版本说明、ISBN、载体形态、语种。规范化去掉书名冗余、统一中西文标点、切分多责任者、提取作者生卒年、归一化 ISBN。候选生成用 embedding 相似度、责任者块、题名块等召回可能相关的记录对避免全量两两比较。LLM 裁决对候选对做关系判断输出 similar 或 different 以及理由。聚类合并用并查集或连通分量把已经判定为同一超作品关系的记录合并成簇。人工审核回写生成审核列表编目员修正模型判断修正结果进入 golden set用于后续评测和 prompt 调优。这个流水线有一个核心思路不要把 LLM 用在第 4 步候选生成的大规模计算上也不要把所有判断压力都推给人工审核。LLM 只处理候选对每个候选对都是一次独立的、有结构的判断题。3.2 为什么不是全量 pair 都交给 LLM假设你有 10 万条记录理论上 pairwise 组合有 50 亿个。哪怕本地推理再便宜这样也不现实。而且绝大多数记录彼此完全无关喂给模型只会增加幻觉概率。一般做法是把候选对数量控制在每个记录往周围最多扩展 3050 个“最可能相关”的邻居。候选生成阶段可以很笨但召回必须高。我用过比较可靠的一套组合是ISBN 完全相同时直接视为候选甚至可以跳过模型强行合并。作者规范化 key 相同的记录进入同一个 block在 block 内用 embedding 找 top-k。没有作者的记录用“题名归一化前若干字符 出版年区间”分块。有明确“丛编项”的可以额外进入一个丛编块做候选。候选对生成后再让 LLM 判断。这样做本地小模型的工作负载被压到一个很小的范围内质量会明显更稳定。4. 环境准备跑本地 LLM 前先把数据理解清楚4.1 软件与依赖下面这套示例面向 Python 环境适合先在一批几百到几千条记录的小数据集上跑通流程。依赖以常见开源组合为主Python 3.10 或更高版本。pymarc解析 MARC 文件。sentence-transformers生成句向量。scikit-learn 或 numpy计算余弦相似度。OpenAI SDK这里主要是调用本地模型的 OpenAI 兼容接口。Ollama 或 llama.cpp提供本地模型服务。我用 Ollama 举例因为它本地部署最简单换 LM Studio、vLLM 也同理关键是接口和模型权重不同。需要说明的是具体版本以你的实际环境为准上面不绑定某个固定版本号。模型层建议准备一个 7B8B 参数级别的中英文指令模型并把量化权重放到本地。多语言语料场景建议准备一个多语言 embedding 模型不要只依赖基于 BERT 的中文小模型。pip install pymarc sentence-transformers scikit-learn openai本地模型服务的启动方式取决于你用的部署工具。以 Ollama 为例核心是先把模型权重拉取到本地并启动服务ollama pull qwen2.5:7b-instruct ollama serve如果生产环境完全离线可以把官方模型文件拷贝到离线机器后导入模型权重和 API 服务不影响我们的聚类代码。4.2 数据结构不管原始数据是 MARC 还是 Excel建议先统一成 JSON。每条记录至少保留几个关键字段字段含义对聚类的作用id稳定唯一标识用于聚合后找回原始记录title正题名作品语义匹配subtitle副题名区分同名不同内容author_key规范化责任者 key生成候选分块publisher出版者判断是否为同一种载体表现pub_year出版年判断版次差异edition版本说明区分“修订本”“新1版”等isbnsISBN 列表强匹配键language语种判断译本和表达层次extent载体形态区分节选、全本、多卷册需要特别提醒MARC 记录里 ISBN 经常不只有 020 字段一个有些旧记录会写错 ISBN有些记录同一个 ISBN 被重复使用反而对应多个不同版本。因此 ISBN 能作为强召回键但不能作为唯一合并依据。5. 代码实战从 MARC 记录到结构化 JSON实际数据源如果是 MARC第一步是解析。pymarc 的读取方式很直接下面的示例会做一层字段映射。这里的代码从一个典型 MARC 文件读取记录转成统一 JSON字段映射只覆盖最常用位置245 是题名100/700 是责任者260 是出版信息250 是版本说明020 是 ISBN008 里前 3537 位是语种代码。# parse_records.py import json import re from pathlib import Path from pymarc import MARCReader def clean_value(raw): 清理 MARC 子字段中的多余空格和结尾标点。 if not raw: return raw re.sub(r[\s\u3000], , raw) raw raw.rstrip( /:;) return raw.strip() def record_language(record): 从 008 固定长字段中截取语种代码位置按 MARC 21 定义。 field_008 record[008] if field_008 and len(field_008.data) 38: return field_008.data[35:38].strip() return def get_isbns(record): 收集 020 字段的 ISBN去掉装帧说明等后缀。 isbns [] for field in record.get_fields(020): isbn field[a] if field[a] else if isbn: isbn isbn.split()[0].strip() if isbn: isbns.append(isbn) return sorted(set(isbns)) def record_to_json(record, source, local_id): field_001 record[001] rec_id field_001.data if field_001 else field_245 record[245] title clean_value(field_245[a]) if field_245 and field_245[a] else subtitle clean_value(field_245[b]) if field_245 and field_245[b] else field_100 record[100] author_100 clean_value(field_100[a]) if field_100 and field_100[a] else authors_700 [] for field_7xx in record.get_fields(700): if field_7xx[a]: authors_700.append(clean_value(field_7xx[a])) field_260 record[260] publisher clean_value(field_260[b]) if field_260 and field_260[b] else pub_year clean_value(field_260[c]) if field_260 and field_260[c] else field_250 record[250] edition clean_value(field_250[a]) if field_250 and field_250[a] else field_300 record[300] extent clean_value(field_300[a]) if field_300 and field_300[a] else return { id: rec_id or local_id, source: source, title: title, subtitle: subtitle, title_norm: , author_100: author_100, authors_700: authors_700, author_key: , publisher: publisher, pub_year: pub_year, edition: edition, extent: extent, isbns: get_isbns(record), language: record_language(record), } def load_marc_to_json(mrc_path, output_path): rows [] with open(mrc_path, rb) as fh: reader MARCReader(fh) for idx, record in enumerate(reader): if record is None: continue rows.append( record_to_json( record, sourcestr(mrc_path), local_idfrec_{idx:06d}, ) ) # 生成 title_norm 和 author_key方便后续分块 for row in rows: row[title_norm] re.sub(r[\W_], , row[title].lower()) author row[author_100] or (row[authors_700][0] if row[authors_700] else ) author re.sub(r[,].*$, , author) row[author_key] re.sub(r[\W_], , author).lower() with open(output_path, w, encodingutf-8) as f: json.dump(rows, f, ensure_asciiFalse, indent2) print(fparsed {len(rows)} records - {output_path}) if __name__ __main__: load_marc_to_json(Path(data/sample.mrc), Path(data/records.json))这段代码虽然简单却决定了后续所有流程的质量。如果解析阶段丢失了版本说明、副题名、多责任者后面的候选生成和 LLM 裁决都会失去重要特征。跑的时候要格外留意MARC 文件可能存在坏记录pymarc 在读取时遇到record is None时不要直接终止继续往下读即可。建议先人工打印 35 条输出结果检查 title、author_key、publisher 是否落在正确字段。很多时候不是代码写错而是你手上的 MARC 文件字段使用习惯和标准不一样比如 260 不存在而用 264 字段或 245 的 $a 带了题名后缀。这种现象在联合目录数据里非常普遍。6. 核心代码候选生成与 rule-based 预筛拿到 JSON 记录后首先要做候选生成。一个可行方案是先按责任者分块块内用句向量算 top-k 相似对同时把 ISBN 完全相同的记录无条件加入候选对。这样做的直觉是真正的“超作品簇”通常共享原作者即使题名翻译变了作者 key 也可能一致而真正同一版本的书ISBN 通常也相同。# candidates.py import json from collections import defaultdict from itertools import combinations from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity def build_search_text(record): 构造用于 embedding 的文本关键是让标题和责任者占主要权重。 authors .join(record.get(authors_700, [])) return .join( [ record.get(title, ), record.get(subtitle, ), record.get(author_100, ), authors, record.get(publisher, ), record.get(pub_year, ), record.get(edition, ), ] ) def build_candidates(records, top_k30, model_nameBAAI/bge-small-zh-v1.5): 返回候选对的索引列表 [(i, j), ...]i j。 texts [build_search_text(r) for r in records] model SentenceTransformer(model_name) embeddings model.encode(texts, normalize_embeddingsTrue) similarity cosine_similarity(embeddings) candidates set() # 相同 ISBN 的无条件候选 isbn_map defaultdict(list) for idx
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进