ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

唐诗三百首数据库构建:从文本清洗到三范式建模

唐诗三百首数据库构建:从文本清洗到三范式建模 简介唐诗三百首数据集是一份结构规整的中文经典诗词数据包适合古典文学爱好者、语文教学场景及入门数据分析练习使用。压缩包共含4个文件分别提供json、xlsx、csv、sql四种常见格式既能直接导入Excel或数据库工具进行查询统计也可用于Python、SQL等环境下的文本分析与可视化练习。资源整体约141KB数据量320条体量轻巧便于快速下载与本地运行。针对每首诗作数据集按统一字段整理能帮助使用者省去手工搜集与清洗的麻烦专注于内容分析或应用开发。目前已有1452人浏览学习下载后可直接按需选取对应格式开展教学、检索比对或编程实操是一份入门友好、即取即用的诗词数据基础素材。1. 一个课设标题背后的完整数据链路从文本到可查询的三范式结构A 同学拿到「数据库-唐诗三百首数据集」这个题目时以为是一趟导入导出的练习课真正动手才发现最难的不是建库而是把一首首带注释、带序、带异体字的古诗折腾成结构化数据。这个标题看上去是在讲一个现成的数据集实际是一条完整的数据落地链路原始文本清洗、表结构设计、数据入库、查询验证每一步都有奇怪的坑等着你。目标读者是三类人准备做数据库课程设计的学生、想用中文文本练 SQL 的从业者、需要一份结构化古诗语料的 NLP 入门者。别把它当成“下载一个 sql 文件导入就完事”的项目自己走一遍清洗与建模收获比直接拿现成结果大得多也更经得起答辩追问。2. 先识原始文本再建表唐诗三百首的表结构设计与清洗落点2.1 原始文本常见的三种形态单 txt、卷目录、带赏析动手之前先搞清楚你手上是什么货色。网上流传的唐诗三百首整理版基本分三类第一种是纯文本版形如“《静夜思》/ 李白 / 床前明月光疑是地上霜……”一组一组用空行隔开这是最好处理的。第二种带卷目录会按“卷一 五言古诗”分节每首诗前面有编号块与块之间仍然可以用空行切。第三种是带赏析和注释的版本每首诗后面跟着几百字的白话译文、创作背景、字词注解清洗成本直线上升。我的建议是优先找第一种或第二种把带赏析的版本扔一边。理由很简单你的目标是建“数据库”不是做大模型语料清洗。赏析和正文混排时正则切分非常容易误删正文或者把注释当正文入库后面校验的时候你根本分不清是数据问题还是脚本问题。拿到文本后先人工打开看前 20 行的结构确定三件事块分隔符是空行还是制表符标题、作者、正文的排列顺序是“标题—作者—正文”还是“作者—标题—正文”有没有“并序”“序曰”这样的特殊标记。这三件事没确认之前不要写任何解析代码这是血泪经验。2.2 建表作者表、诗歌表与生成列的取舍表结构是整个项目的地基。常见的做法是拆三张表author 存诗人信息poem 存诗歌正文category 可以直接做成 poem 的一个字段而不单独建表因为唐诗三百首的体裁分类量级很小枚举值就那几种。为什么作者要单独拆一张表而不是在 poem 里直接存字符串两个原因。第一一个人可能有别名“李白”和“李太白”在原始文本里可能混着出现直接存字符串会把同一个人统计成两个人。第二作者表可以挂生卒年、字号、别名等扩展字段课程设计里“统计每位诗人作品数量”就是标准 JOIN 查询比 GROUP BY 字符串优雅得多。CREATE TABLE author ( author_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, alias_name VARCHAR(100) DEFAULT NULL, dynasty VARCHAR(20) DEFAULT 唐, UNIQUE KEY uk_author_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci; CREATE TABLE poem ( poem_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, author_id INT UNSIGNED NOT NULL, category VARCHAR(20) DEFAULT NULL, content TEXT NOT NULL, content_clean TEXT NOT NULL, char_count INT UNSIGNED GENERATED ALWAYS AS (CHAR_LENGTH(content_clean)) STORED, first_char CHAR(1) GENERATED ALWAYS AS (LEFT(content_clean, 1)) STORED, last_char CHAR(1) GENERATED ALWAYS AS (RIGHT(content_clean, 1)) STORED, UNIQUE KEY uk_title_author_count (title, author_id, char_count), KEY idx_author_id (author_id), KEY idx_category (category), CONSTRAINT fk_poem_author FOREIGN KEY (author_id) REFERENCES author(author_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;注意几个设计决策。content_clean 是去除标点和空白后的纯正文供字数统计、指纹比对、分词用。char_count、first_char、last_char 用生成列自动计算省掉应用层手算的麻烦这是 MySQL 5.7 之后才有的能力老版本数据库需要改为普通字段由脚本填充。category 字段用 VARCHAR 而不是 ENUM是因为原始文本的分类词并不统一——有的写“五言古诗”有的写“五古”有的干脆不标——ENUM 直接拒绝脏数据VARCHAR 可以先收下再回填。2.3 清洗脚本把四段式文本切成标题、作者、正文清洗的核心不是写一个万能解析器而是针对你手里那个版本写一个够用的解析器同时把参数暴露出来。常见做法是先把整本书按空行切成块然后对块内的行做分类。下面这段脚本处理的是“标题行—作者行—正文行”的常见结构并尝试剥离“并序”段落。import re import csv from pathlib import Path RAW_FILE tangshi300_raw.txt OUT_CSV poems_clean.csv TITLE_PREFIXES (卷, 《, 五言, 七言, 乐府, 乐府) def split_blocks(text: str, sep: str \n\n) - list: blocks [] for raw_block in text.split(sep): lines [ln.strip() for ln in raw_block.strip().splitlines() if ln.strip()] if len(lines) 3: continue # 目录行、版权页等碎片直接丢弃 blocks.append(lines) return blocks def is_title_line(line: str) - bool: return line.startswith(TITLE_PREFIXES) or line.startswith(《) def strip_preface(content_lines: list, markers(并序, 序曰, 并序曰)) - list: kept [] in_preface False for ln in content_lines: if any(ln.startswith(m) or ln.startswith( m) for m in markers): in_preface True continue if in_preface and ln.endswith((诗, 云, 曰)): # 序结束的常见标志 in_preface False continue if not in_preface: kept.append(ln) return kept def clean_text(text: str) - str: return re.sub(r[。、“”‘’《》\s], , text) def main(): raw Path(RAW_FILE).read_text(encodingutf-8-sig) blocks split_blocks(raw) rows [] for lines in blocks: if is_title_line(lines[0]): title, author lines[0].strip(《》), lines[1] else: title, author lines[0], lines[1] content_lines strip_preface(lines[2:]) content .join(content_lines) content_clean clean_text(content) if len(content_clean) 4: # 正文太短基本是目录碎片 continue rows.append((title, author, content, content_clean)) with open(OUT_CSV, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([title, author, content, content_clean]) writer.writerows(rows) print(fparsed {len(rows)} poems, skipped {len(blocks) - len(rows)} blocks) if __name__ __main__: main()逻辑说明在几个地方。split_blocks 按空行切块要求块内至少三行过滤掉目录和封面的短碎片这是第一个兜底。is_title_line 只用于判断标题在块内第 0 行还是第 1 行判断依据是开头是否命中常见前缀。strip_preface 是启发式处理靠“并序”等关键词进入否定态遇到以“诗”“云”“曰”结尾的行再复位这不是银弹只能处理规范整理版。参数说明你要改的通常是 RAW_FILE 路径、TITLE_PREFIXES 元组、以及 strip_preface 里的 markers。如果作者的文本是“作者—标题—正文”顺序把 main 里的 if 条件换成 is_author_line 即可。注意 read_text 用的是 utf-8-sig自动吃掉 UTF-8 BOMWindows 记事本另存的文本大多带 BOM直接 utf-8 读会把 \ufeff 带进第一个字段。3. 把三百首诗安全灌进数据库导入顺序、批量写入与三轮校验3.1 两条入库路线Python 逐条插入还是 LOAD DATA 批量导入数据量摆在那里三百首出头两条路都走能通。课程设计里我一般推荐先走 Python 逐条 INSERT 路线逻辑直观出错了能看见具体是哪一行等你想展示批量导入能力再生成 CSV 用 LOAD DATA 灌一遍也不迟。Python 逐条插入的关键是拿到 author_id 而不是直接存作者名。这要求 author 表先导好poem 插入时执行一次子查询或提前把 name 到 id 的映射加载到内存里。建议后者三百条数据的映射就是一个字典别做成嵌套 SELECT性能丢人。import csv import pymysql conn pymysql.connect( host127.0.0.1, userroot, passwordyour_password, databasetangshi, charsetutf8mb4, ) cursor conn.cursor() cursor.execute(SELECT author_id, name FROM author) author_map {name: author_id for author_id, name in cursor.fetchall()} sql INSERT INTO poem (title, author_id, category, content, content_clean) VALUES (%s, %s, %s, %s, %s) with open(poems_clean.csv, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: author_id author_map.get(row[author].strip()) if author_id is None: print(missing author:, row[author], row[title]) continue category None # 第一阶段先留空后面用规则回填 cursor.execute(sql, ( row[title].strip(), author_id, category, row[content], row[content_clean], )) conn.commit() cursor.close() conn.close()代码里 cursor.execute 之前把 author_map 先查询出来发现映射缺失的诗人直接打印出来并跳过避免外键报错中止全过程。这是处理脏数据的第一道防线。charset 参数必须带上 utf8mb4否则连接默认字符集可能不是 utf8mb4中文写入后乱码。批量路线的思路是把 poems_clean.csv 转成作者 id 已经拼好的第二份 CSV然后走 LOAD DATA LOCAL INFILELOAD DATA LOCAL INFILE /tmp/poems_with_id.csv INTO TABLE poem CHARACTER SET utf8mb4 FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY LINES TERMINATED BY \n IGNORE 1 LINES (title, author_id, category, content, content_clean) SET category NULL;LOAD DATA 的每个参数都有讲究。CHARACTER SET utf8mb4 声明文件字符集避免中文乱码。OPTIONALLY ENCLOSED BY 专门处理 content 里可能出现的逗号和换行。IGNORE 1 LINES 跳过 CSV 表头。直接把 SELECT 子查询写进 SET 子句虽然能跑但逐行子查询会把批量导入变成逐行关联性能优势全丢所以正确姿势是提前在 Python 端把 author_id 拼好。3.2 外键约束下的导入顺序先作者后诗歌poem 表带着外键指向 author 表导入顺序就必须先 author 后 poem否则每插一行都报 1452 外键错误。常见做法是找一个现成的诗人清单作为底表逐条插入后打印冲突的作者名。poets [ (李白, 太白), (杜甫, 子美), (王维, 摩诘), (白居易, 乐天), ] insert_author INSERT INTO author (name, alias_name) VALUES (%s, %s) ON DUPLICATE KEY UPDATE alias_name COALESCE(VALUES(alias_name), alias_name) for name, alias in poets: cursor.execute(insert_author, (name, alias))这段代码实际上只应该执行一次后续通过 ON DUPLICATE KEY UPDATE 保证幂等。重点在于底表要覆盖诗歌清洗脚本里出现过的所有作者不在底表里的名字会触发外键缺失你的清洗脚本应该已经通过 author_map.get 返回 None 打印出来了。两条链路对不上时以清洗脚本打印的缺失清单为准去补 author 表。3.3 三轮校验查询数量、分布、抽样导入完成后不要急着写报告跑三轮校验。第一轮是总量和缺失第二轮是作者分布与诗体分布第三轮是抽样逐字核对。-- 第一轮数量与缺失 SELECT COUNT(*) AS total_poems FROM poem; SELECT COUNT(*) AS missing_author FROM poem p LEFT JOIN author a ON p.author_id a.author_id WHERE a.author_id IS NULL; SELECT COUNT(*) AS missing_clean FROM poem WHERE content_clean ; -- 第二轮按作者和诗体分布 SELECT a.name, COUNT(p.poem_id) AS cnt FROM poem p JOIN author a ON p.author_id a.author_id GROUP BY a.name ORDER BY cnt DESC LIMIT 10; -- 第三轮抽查五言绝句的字数分布 SELECT title, char_count FROM poem WHERE content LIKE %月% AND char_count BETWEEN 15 AND 30 ORDER BY char_count ASC LIMIT 20;第一轮的三个查询分别回答“总共导入了多少首”“有没有孤儿记录”“清洗后有没有空文本”。第三轮的 BETWEEN 15 AND 30 不是随便写的五言绝句正文一般是 20 个字不含标点七言绝句是 28 个字五言律诗是 40 个字。把抽查范围收紧一眼就能看出哪些诗因为混入了注释导致字数异常。这一步我吃过大亏有首诗正文只有 20 个字因为“并序”没剥离干净content_clean 变成了 200 多个字字数统计直接废了。4. 这套数据库能回答什么问题课程设计查询、统计口径与全文检索边界4.1 课程设计标配查询诗人排行、诗体分布、律绝字数核对模型建完数据导完下面这些查询基本是答辩必问。按作者统计作品数量是最典型的 JOIN GROUP BY 组合诗体分布检验的是 category 字段回填是否成功这两条查完老师对这一题的观感基本就定了。-- 每位诗人的作品数量排行 SELECT a.name, COUNT(p.poem_id) AS poem_count FROM author a LEFT JOIN poem p ON a.author_id p.author_id GROUP BY a.author_id, a.name ORDER BY poem_count DESC; -- 诗体分布 SELECT category, COUNT(*) AS total, ROUND(100.0 * COUNT(*) / (SELECT COUNT(*) FROM poem), 2) AS percent FROM poem GROUP BY category ORDER BY total DESC;LEFT JOIN 的意思是让作品数为 0 的诗人也显示出来这在作者表比诗歌表多出几条空记录时很关键。percent 计算用的是分数形式 ROUND(100.0 * COUNT() / ...)不是 COUNT() / (SELECT ...) 再乘 100后者在整数除法下会直接变 0这是新手最常见翻车点。统计口径上有一个容易忽略的问题诗体 category 如果第一阶段留空就需要从题目或正文回填。常见做法是正则拆 title命中“五言绝句”“五绝”标成五绝命中“七言律诗”“七律”标成七律完全没命中的标为“古体”或 NULL。这个回填规则建议单独写一段注释在项目文档里答辩时能说清楚统计口径比数据本身更值钱。4.2 中文全文检索为什么 LIKE 不够用、ngram 索引怎么建业务题常问“找出所有包含‘月’的诗”。直接写 WHERE content LIKE %月% 能出结果但表一旦过万行就原形毕露——LIKE 前导通配符让索引失效全表扫描。三百行看不出差别但课程设计里你写“百万级数据量下这条 SQL 会怎样”会是很加分的思考。MySQL 从 5.7 开始支持全文索引中文需要用 ngram 解析器ALTER TABLE poem ADD FULLTEXT INDEX ft_content (content) WITH PARSER ngram; SELECT title, content FROM poem WHERE MATCH(content) AGAINST(月 IN NATURAL LANGUAGE MODE) LIMIT 20;ngram 的默认 token 大小是 2也就是说默认配置下“月”这个单字查询会搜不到任何结果。要么把 ngram_token_size 改成 1 再重建索引要么在查询时接受这个限制。改配置是全局参数需要重启实例代价不小课程设计场景我更建议接受默认 2 词元搜“明月”“故乡”这类双字词效果很好搜单字就用 LIKE 兜底。注意全文索引只支持 MyISAM 和 InnoDB且字符集建议 utf8mb4。建索引后 EXPLAIN 看执行计划确认 type 是 fulltext 而不是 ALL这一步能堵住“索引建了没用上”的质疑。4.3 导出成 NLP 数据集JSON Lines 与分词预处理数据库建完还能往 NLP 方向延伸。poem 表里 content_clean 字段就是现成的纯文本语料导出成 JSON Lines 格式给下游训练脚本用比让算法工程师自己写 SQL 友好得多。import json import jieba import pymysql conn pymysql.connect(host127.0.0.1, userroot, passwordyour_password, databasetangshi, charsetutf8mb4) cursor conn.cursor() cursor.execute(SELECT p.title, a.name, p.category, p.content_clean FROM poem p JOIN author a ON p.author_id a.author_id) with open(tangshi.jsonl, w, encodingutf-8) as f: for title, author, category, content_clean in cursor.fetchall(): tokens jieba.lcut(content_clean) record { title: title, author: author, category: category, content: content_clean, tokens: tokens, } f.write(json.dumps(record, ensure_asciiFalse) \n)导出脚本的核心是 ensure_asciiFalse否则 json.dumps 会把中文变成 \uXXXX 转义序列人和程序都难读。tokens 字段用结巴分词预切好下游做词频统计、情感分类、风格迁移都能少绕一步。这一步做不做取决于你的课程设计有没有跨到 NLP 方向但作为加分项远比在报告里多写两页理论实在。5. 唐诗数据入库的 5 个经典坑现象、原因与解决这些坑不是玄学每一处都是文本本身带进来的。我按“现象—原因—解决”写方便你对号入座。5.1 打开文件全是乱码BOM 与字符集不一致现象清洗脚本读出来的第一行标题带一个奇怪的 或可见的 “\ufeff”数据库查询出来的 title 也带着这个前缀看上去像乱码但不全是。原因Windows 记事本另存的文本UTF-8 编码自带 BOM 头Python 用 utf-8 读时 BOM 会被当成普通字符塞进第一个字段另一个高发点是 MySQL 连接串没设 charset写入时按 latin1 解释中文。解决读取时一律用 encodingutf-8-sigMySQL 连接参数显式写 charsetutf8mb4。建表时也统一 DROP DATABASE 后重建用 utf8mb4_unicode_ci 排序规则别混用 utf8 和 utf8mb4。5.2 五言七言统计对不上分类标签依赖人工定义现象GROUP BY category 出来一堆“五言古诗”“七言绝句”“五绝”“七律”并存甚至同一个体裁出现三种叫法。原因原始整理版来自不同编辑习惯有的用全称有的用简称有的把“五言古诗”和“五古”混用正则按字数切分时又把带“序”的篇目算错。解决先人工抽样 50 首把原始文本里所有体裁叫法列出来建立映射表回填 category 时走映射表而不是正则硬切。映射表放在项目目录里作为资料答辩时就是“口径清晰”的凭证。5.3 李白和李太白被拆成两个人别名未归一现象诗人排行榜里李白出现在两个位置作品被拆成两份没法看总量。原因原始文本中的署名不统一有的诗署名“李白”有的署名“李太白”直接按 author 字段存字符串存储数据库不知道这是同一个人。解决author 表建 alias_name 字段导入时所有作者名字先过一遍别名映射表统一归一到主名。这个映射表可以用清洗脚本里 author_map 的反向逻辑实现或者干脆在 Python 端先把别名替换成主名再写入。5.4 并序和注释混进正文字数统计被拉偏现象某首诗的 char_count 异常大比如五言律诗本该 40 个字结果查出 300 多抽查原文发现“序曰……”整段被塞进了 content。原因原始版本的序与正文之间没有统一分隔符简单按空行切块时序跟正文同处一块strip_preface 函数只处理了以固定关键词开头的场景遇到先行注释就失效。解决不追求一个正则吃遍所有版本。锁定底本后人工把序的起止样例收集十来个把处理规则改成“遇到关键词进入序状态遇到诗题或’诗曰’退出”再不行就手工补一条数据别硬写通用逻辑。5.5 同一首诗在不同版本里字不一样异体字与繁简混排现象检索“裡”和“里”的结果不一样同样一首诗在两个版本里字数差 1 个“云”和“雲”混用导致字频统计不可信。原因公开整理版可能从繁体底本转简体、又从简体被人为转回繁体异体字没有统一数据库层面看起来是数据问题实际上源头在底本选择。解决导入前锁定一个底本不要多版本混用。简繁转换用开源库统一成简体但要小心双字词转换表里存在的特例。锁定底本这个决定要写进项目 README后续任何人复现数据都对得起来源。6. 用「字数指纹 全文索引」把数据集变成一个可交付的查询工具数据导入和查询做完还有最后一关值得做给数据集上完整性校验的“指纹锁”。比分对目录清单更高效的玩法是字数指纹——每首诗的 content_clean 算出“第一个字 总字数 最后一个字”这三元组在正常诗集里几乎不会重复。跑一遍分组统计立刻能发现重复导入、截断、注释混入三类问题。SELECT CONCAT(first_char, :, char_count, :, last_char) AS fingerprint, COUNT(*) AS duplicate_times, GROUP_CONCAT(title ORDER BY poem_id SEPARATOR | ) AS poem_titles FROM poem GROUP BY fingerprint HAVING duplicate_times 1;HAVING duplicate_times 1 查出来的每一行都值得点开看看。如果 fingerprint 相同但 title 不同大概率是同一首诗在原始文本里出现了两次可能是不同分类下重复收录如果 title 也相同那就是清洗脚本把同一首诗写了两次。两类问题处理方式不同前者要决定保留哪个分类后者直接删一条重复记录即可。指纹校验通过后这套表结构已经能支撑一个最小的唐诗查询接口。poem 表有全文索引、有作者表外键、有干净的 content_clean 字段写一个简单的查询服务只需要二三十行代码接收关键词MATCH AGAINST 全文索引查正文JOIN author 表带出作者名再拼一个诗体筛选条件。三百首诗的数据库做成这样已经不是课设作业而是能拿出来演示“数据入库到检索闭环”的完整方案。我自己在这个项目上翻过车第一次用的是带赏析的版本清洗脚本越改越长最后并序混进正文导致统计对不上花了一整个晚上逐条核对才发现是 strip_preface 没覆盖到位。后来养成两个习惯拿到文本先手工标记 20 首“标准答案”再写脚本以及永远保留 content_clean 这个清洗后字段绝不直接拿原始 content 做统计。这两个习惯后来救了我好几次。如果你也从这份唐诗三百首数据集开始练手建议把“指纹校验”这一步放进你自己的流程里三百首的规模跑一次只要几秒钟换来的是对所有查询结果的信心。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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