ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从Word课后习题到RAG问答:文档结构化与混合检索实战

从Word课后习题到RAG问答:文档结构化与混合检索实战 简介本资料为《城市规划原理》第四版课后习题的系统解答覆盖城市根本要素、工业前与工业城市对比、中国城市化挑战、城市化规律、古代城市规划思想及经典案例、近现代规划理论、城乡规划体制等十大主题。适合城乡规划、建筑学及相关专业学生期末复习、考研备考或教师备课参考使用。资源为1个doc文档共66KB内容按章节组织包含要点分条、结论归纳与拓展思考便于快速梳理教材脉络。文中结合邺城功能分区、隋唐长安城建设时序、元大都整体规划、希波丹姆模式等经典案例并延伸至田园城市、有机疏散、马丘比丘宪章等理论有助于将教材知识点与城市实际发展问题相衔接。资料当前已有364人学习对需要系统掌握城规原理核心考点、提升论述题答题条理性的读者具有较强实用性。1. 一份“城市规划原理第四版课后习题集解答.doc”到了手上先别急着看答案很多做内容或知识管理的人第一次碰到这类文件时第一反应是打开看排版、看答案准不准。但如果你是那个要把它变成可检索资料、可引用素材、甚至可做问答系统底料的人真正的问题不是「答案写得对不对」而是「这份 doc 里装着多少能被计算机理解的信息」。城市规划原理第四版是国内城乡规划专业使用很广泛的一本教材它的课后习题覆盖面宽名词解释、简答、论述有的题目答案能占一页纸还夹着图表和案例。把这些内容从 Word 里抽出来、结构化成题号与答案的对应关系再送进检索或问答链路就是一个完整且典型的知识资产化流程。这篇博文就顺着这条线往下走从文档解析、题号识别、入库检索到 RAG 问答每一段都有可执行的命令和代码适合正在做知识库、教辅资料结构化或本地文档问答的工程师参考。2. 把“城市规划原理第四版课后习题集解答.doc”抽成干净文本解析路径与 OCR 兜底2.1 先摸清 .doc 的内容形态再决定解析方案拿到这一份 doc 文件后第一步不是写代码而是先回答三个问题文件是真正的二进制 .doc还是改后缀名的 .docx题目和答案是怎样排版的——每一题是「题号题干答案」连排还是「题干在题目区、答案在文末」分离表格、图片、公式占多少比例这三个问题直接决定后面用哪套解析链路。用命令行做快速判断最直接Linux 或 macOS 下执行file就能看到真实格式file 城市规划原理第四版课后习题集解答.doc如果输出显示 Composite Document File V2说明是老版 OLE 格式的 .doc需要先转换如果显示 Microsoft Word 2007说明它其实是 docx。另有一个办法直接把文件后缀改成 .zip 再尝试解压能解出来就是 docx因为这格式本质是一个 zip 容器。判断完格式还要统计文档里图片和表格的占比。LibreOffice 转换时能顺带导出文档统计信息不过更直接的方式是先用反编译工具看内容流。实践里我一般这么做用 LibreOffice 把 .doc 转成 .docx再用 python-docx 做结构化提取这是目前兼容性最好的稳定路线。为什么不直接用 antiword 或 catdoc它们对中文排版和嵌套表格的支持都比较弱遇到城市规划原理这类教材型排版会出现大量丢字、乱码和段落粘连。libreoffice --headless --convert-to docx 城市规划原理第四版课后习题集解答.doc --outdir ./converted转换完成后目录下会生成同名 .docx 文件。注意--headless参数表示不启动界面适合在服务器上批量执行--outdir指定输出目录避免和工作目录混在一起。2.2 用 python-docx 提取段落与表格先拿到“带顺序的全文”转成 docx 后用 python-docx 提取段落和表格注意按文档顺序遍历而不是先读所有段落再读所有表格——后者会打乱题号和答案的相对位置。下面这段代码是从头到尾遍历文档体把段落和表格都收集进一个带类型标记的列表from docx import Document doc Document(./converted/城市规划原理第四版课后习题集解答.docx) body [] for block in doc.element.body: tag block.tag.split(})[-1] if tag p: # 段落可能是题干、答案正文、也可能只是空行 from docx.text.paragraph import Paragraph p Paragraph(block, doc) text p.text.strip() if text: body.append({type: para, text: text}) elif tag tbl: # 表格习题里常见的是“对比型”答案比如卫星城与新城区别 from docx.table import Table tbl Table(block, doc) rows [] for row in tbl.rows: cells [c.text.strip() for c in row.cells] rows.append(cells) body.append({type: table, rows: rows})这段代码的关键在于doc.element.body直接暴露了 XML 层级的 body 子节点按文档物理顺序遍历能同时拿到段落和表格。用tag.split(})[-1]去掉命名空间前缀只判断标签名是p还是tbl这样就不需要依赖 python-docx 的iter_inner_content()那个方法在某些版本里还没合入。拿到body列表后建议先把它序列化成 JSON 存盘方便后续调试——不要每次重新解析一遍 docx尤其是后面还要反复调题号识别规则时import json with open(body.json, w, encodingutf-8) as f: json.dump(body, f, ensure_asciiFalse, indent2)这一步的价值在于把“文档解析”和“内容处理”两个阶段解耦此后所有后续操作都面向 JSON 而不是原文件处理速度更快也方便在失败时回溯是哪一段解析出了问题。2.3 老式 .doc 的常见坑分栏、批注、修订模式与嵌在表格里的图解析过程里最常见的三个坑都和源文档的“编辑历史”有关。第一个坑是分栏。城市规划原理的教辅资料常常用两栏排版来省页数但 doc 转 docx 后python-docx 读出来的段落顺序是「左栏全部内容再右栏全部内容」而不是「从左到右逐行」。这会让题号和答案顺序错乱比如第 5 题的答案跑到了第 8 题前面。遇到这种情况我通常先看 body.json 里的题号序列是否单调递增如果跳号或倒序就说明源文件用了分栏此时应该放弃顺序提取改用「题号锚点定位 独立段落归并」的策略——也就是下一章的切分逻辑。第二个坑是批注和修订。如果这份习题解答是从老师或学长手里流转来的极大概率开着修订模式正文里会有w:ins和w:del标签。python-docx 默认会把删除的内容也读进p.text导致答案里混进“原答案”和“修改后的答案”两套文字。处理方式是解析前先做一次修订接受操作用 LibreOffice 打开后另存为 docx或者直接用脚本清洗 XML 里的修订标签。命令行下最快的办法是libreoffice --headless --convert-to docx:MS Word 2007 XML --infilterMicrosoft Word 97/2000/XP (.doc):AcceptTrackChanges 原文件.doc第三个坑是图片中的文字。习题参考答案里经常有“用地平衡表”的截图或规划图上的标注这些不是文本OCR 才是唯一出路。中文场景我推荐用 PaddleOCR 做简体中文识别尤其在“图纸标注 表格”这种混合版面上准确率明显优于 Tesseract 的中文模型。from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch) result ocr.ocr(page5_table.png, clsTrue) for line in result[0]: print(line[1][0]) # 识别出的文字use_angle_clsTrue会先对图片做方向分类适合扫描版页面旋转的情况langch指定中文模型。OCR 结果不要直接拼进答案正文最好和图片位置一起记录成独立条目后续入库时单独建字段避免 OCR 错字污染文本检索质量。下面是提取异常与对策速查表实战时直接对照处理异常现象根因处理策略题号顺序倒置/跳号源文档分栏布局放弃顺序依赖改按题号锚点聚合同一题答案出现两版文字修订模式未接受转换时 AcceptTrackChanges 或清洗 w:del表格单元格内文本丢失嵌套表格结构递归遍历 tbl.tbl不要只读一层图内文字缺失图片而非文本导出图片后走 PaddleOCR章节标题与正文粘连样式名不统一用字号加粗特征做二次切分3. 把习题解答切分成“题号 题干 答案”的干净单元3.1 为什么不能按页切也不能按段落切拿到干净的 body.json 后下一个任务是切分。很多人第一反应是按页切或者按段落切但课后习题解答这种文本按页切会把一道题的一道答案从中间劈开按段落切又会把「第 5 题」和它底下的题目描述分散到不同单元。正确做法是识别出题号边界把题号、题干、答案作为一个整体聚合起来。城市规划原理第四版的课后题编号方式大致有两种一种是纯数字「1、2、3」章节独立编号另一种是「5-1、5-2」这种复合编号其中 5 表示第五章。两种都要能识别而且要把「第 5 题」和「5-1」区分开不能把后者拆成题号 5 和 1。3.2 用正则先建立题号骨架针对上述两种编号正则规则可以这样写import re # 匹配行首的题号支持 1、 12、 5-1 5.2 等 q_pattern re.compile( r^\s*(?Pchapter\d{1,2})[-\.](?Pqno\d{1,2})[、\.]?\s r|^\s*(?Pqno2\d{1,2})[、\.]\s ) def detect_question(line): m q_pattern.match(line) if not m: return None if m.group(chapter): return {chapter: m.group(chapter), qno: m.group(qno)} return {chapter: None, qno: m.group(qno2)}这个正则的要点是^\s*允许题号前面有缩进——doc 转换后的文本经常保留制表符或全角空格复合编号5-1和5.2都覆盖了后面紧跟的书名号或全角点用字符类[、\.]?兼容。识别到题号后就以此为边界把后续段落归入当前题目直到遇到下一个题号。但纯正则有一个致命问题答案正文里如果引用「如第 3 章所述」行首可能也是「3、」会被误判成新题号。解决方式是不只看正则匹配还要看「这个匹配行后面跟的是不是题干」——通常题号后面跟的是题干句且题干往往以「简述」「试述」「什么是」等词开头。加上这个语义校验能大幅降低误切。3.3 聚合段落按章—节—题号—答案四层建树识别出题号位置后把 body.json 里的段落按顺序归拢units [] current None for item in body: if item[type] ! para: # 表格内容归入当前题的答案 if current is not None: current[answer_tables].append(item[rows]) continue line item[text] q detect_question(line) if q: # 新题开始保存旧题 if current is not None: units.append(current) current { chapter: q[chapter], qno: q[qno], question: line, answer_paras: [], answer_tables: [], } else: if current is not None: if current[question] line: continue # 去掉题号行的重复文本 current[answer_paras].append(line) if current is not None: units.append(current)这段代码里表格answer_tables被单独存放而不是拼进文本是因为后续做混合检索时表格类答案要单独走结构化解析比如转成 Markdown 表格再向量化和散文类答案的处理路径不同。units列表里的每个元素就是一道完整的题包含章节信息、题干、答案段落和表格。3.4 没有题号时用章节关键词做兜底归位并不是所有习题解答文档都有清晰题号有些版本是「每章下面直接写问题没有编号」。这时需要在章节维度做归位。常见做法是把教材目录的关键词做成映射表比如「城市」「城市发展」归入第一章「城乡规划体制」「规划编制」归入第二章「居住区」「公共服务」归入相关章节。下面是一个极简的映射chapter_keywords { 1: [城市, 城市发展, 城市化], 2: [规划体制, 编制, 审批], 3: [居住区, 公共服务, 绿地], 4: [交通, 道路, 公共交通], 5: [历史, 保护, 遗产], } def guess_chapter(text): for ch, kws in chapter_keywords.items(): for kw in kws: if kw in text: return ch return 0guess_chapter返回 0 表示无法判断这类文本单独标记为「未归位」不要硬塞到某一章——错误归位比不归位更致命它会在检索阶段把相关答案从正确位置“带偏”。关键词表可以根据这份习题集实际内容扩充字段从「标题」「答案首句」两处分别做匹配取命中数最多的章节。4. 为“城市规划原理第四版课后习题解答”建检索库词法索引、向量召回与混合排序4.1 检索目标由题型决定名词解释靠词法论述题靠语义结构化成 units 之后下一步是让这些内容能搜得到。这里的搜索并不只有一种形态。「什么是城市人口容量」这种名词解释型问题关键词直接命中题干就能拿到答案但「试述城市化进程对城乡规划的影响」这种论述题学生搜的是「城市化」「城乡规划」「影响」这些概念之间的关系单纯靠字符串匹配召回率会很低必须 escalate 到语义检索。所以我在实际项目中不会只建一种索引而是「词法索引 向量索引」并存检索阶段做融合排序。词法索引负责精确匹配和题号检索向量索引负责语义兜底和长文本召回。4.2 用 Elasticsearch 建词法索引IK 分词 题号/章节字段ES 在这里的角色是题号检索和关键词高亮。索引 mapping 建议这样规划字段字段名类型用途qidkeyword题的全局唯一 ID如 ch3_q12chapterinteger章号qnointeger章内题号questiontext题干原文answer_texttext答案纯文本answer_table_mdtext表格转成的 Markdownsource_pagekeyword来源页码如 P124创建索引时把 answer_text 和 question 都用 IK 分词器让中文专有名词「城乡规划」「居住区」按词而不是按单字切分curl -X PUT localhost:9200/urban_plan_qa -H Content-Type: application/json -d { settings: { analysis: { analyzer: { ik_analyzer: { type: custom, tokenizer: ik_max_word } } } }, mappings: { properties: { qid: {type: keyword}, chapter: {type: integer}, question: {type: text, analyzer: ik_analyzer}, answer_text: {type: text, analyzer: ik_analyzer} } } }ik_max_word会做最细粒度切分把文本拆成尽可能多的词对检索而言召回率高一些如果要偏向精度可以改用ik_smart它切得更粗、噪音更少。这里我选择ik_max_word是因为课后习题的题干常常包含教材里的特定说法比如「郊区化」「逆城市化」粗粒度分词容易把「郊区化」切分成「郊区」「化」丢失原意。4.3 向量化入库用 BGE embedding 把题干和答案分别转向量词法索引之外需要把语义维度也补上。Embedding 模型中文场景目前实践效果比较好的是 BAAI/bge-large-zh-v1.5商用时注意看模型 license。把每个单元的「题干 首段答案」拼成向量入库后面检索时用同样的模型把用户问题向量化做余弦相似度召回。from sentence_transformers import SentenceTransformer import numpy as np model SentenceTransformer(BAAI/bge-large-zh-v1.5) texts [u[question] u[answer_paras][0] for u in units] embeddings model.encode(texts, normalize_embeddingsTrue) for i, emb in enumerate(embeddings): units[i][vector] emb.tolist()注意normalize_embeddingsTrue的参数它把向量归一化成单位向量这样后续算余弦相似度时可以直接用点积替代能省掉一次余弦计算开销。实际项目中可以把向量存进 Milvus 或 ES 的 dense_vector 字段这里为了演示直接存在 units 列表里逻辑上是一致的。4.4 混合排序BM25 与向量分数做 RRF 融合同时拿到词法检索和向量召回的结果后常见策略是线性加权或者 RRFReciprocal Rank Fusion。线性加权的问题是 BM25 分数和余弦相似度不在同一量纲直接加权需要花时间调权重。RRF 的好处是不关心原始分数分布只关心排名位置公式是def rrf_fuse(rank_lists, k60): scores {} for ranks in rank_lists: for rank, doc_id in enumerate(ranks): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank) return sorted(scores.items(), keylambda x: x[1], reverseTrue)k是平滑参数经验值在 50~100 之间取值越大多给“排名靠后但仍出现”的文档更多权重。融合后取前 10 个结果返回。词法和向量两条通道的占比不同题型需要区分名词解释类题目词法命中往往直接对应到答案向量的贡献主要是同义改写论述题则反过来答案往往不是题干原词向量召回的语义相似度才是主要召回手段。实际项目中我会在检索接口传入qtype参数让 RRF 的 k 值和两路结果的截断数随题型变化。题型词法通道截断向量通道截断场景说明名词解释前 5前 20题干短、关键词精确词法召回占比高简答题前 10前 20两者均衡论述题前 3前 30答案与题干措辞差异大语义为主5. 把习题解答接进 RAG 问答一个最小可用的城市规划原理复习助手5.1 为什么不直接拿大模型硬答现在很多人的想法是把整份 doc 丢给大模型让它直接回答课后题。这看似高效实际效果很差。一是 Token 限制完整的课后习题解答文本有几十万字超出上下文窗口二是幻觉大模型对教材中的具体数据比如某个城市的规划面积、某种用地的占比标准会一本正经地编数字而且编得很难识别。RAG 的价值在于把检索到的题号和答案原文作为上下文约束让大模型只能基于检索片段做概括和重组而不是凭空生成。5.2 最小链路代码query → 检索 → 拼 prompt → 返回答案一个最小可用的 RAG 链路如下def answer_question(query, top_k5): # 1. 词法检索 es_hits es_search(query, size50) # 2. 向量检索 q_vec model.encode([query], normalize_embeddingsTrue)[0] vec_hits vector_search(q_vec, top_k50) # 3. RRF 融合 fused rrf_fuse([es_hits, vec_hits], k60)[:top_k] # 4. 组装上下文 context_parts [] for qid, score in fused: unit units_by_id[qid] context_parts.append( f【题号】{unit[chapter]}-{unit[qno]}\n f【题干】{unit[question]}\n f【答案】{.join(unit[answer_paras][:5])} ) context \n\n.join(context_parts) prompt f你是城市规划原理第四版的助教。请严格根据下面的习题解答内容回答问题。 如果提供的片段不足以回答问题请直接说“当前资料中没有找到答案”不要编造。 参考资料 {context} 问题{query} return call_llm(prompt)这个实现里最关键的细节在 prompt 的设计明确写了「当前资料中没有找到答案」这个退路能显著降低模型硬答的概率。同时每个上下文片段都带上了题号让回答里能引用原题出处——这对学生复习很重要他们拿到答案后要能翻回教材核对。5.3 关键参数top_k、chunk 长度和温度RAG 效果不好时先别换模型优先调这几个参数。top_k决定上下文里塞多少题城市规划和别的领域不太一样一道课后题的两问可能对应不同答案比如「简述城市用地分类」和「说明居住用地的构成」其实是同一道题的两个层次。这里 top_k5 通常合适太小会漏太大会让无关碎片稀释注意力。chunk长度则要控制在 300~500 字之间。答案超过 500 字时做滑动窗口切分窗口重叠 50 字避免把一个完整论点切到两个 chunk 里。温度建议调低到 0.1~0.3。这是教辅问答不是创意写作低温度能保证答案风格稳定、措辞贴近原文。但不要把温度设成 0因为有些论述题需要把多个答案片段串联成通顺的段落极低的随机性会让语言变得生硬。5.4 文档更新与版本管理新增修订如何不污染旧题号实际情况中这份「课后习题集解答.doc」几乎一定会更新——可能是学长补了几道题也可能是老师发了修订版。直接覆盖旧索引是最懒也最容易出错的做法新版里删掉的题目不会自动从向量库消失旧版里被修改的答案会在检索时和新版答案同时出现用户不知道该信哪个。我一般会在索引结构里加一个version字段入库时如果检测到题号与已有文档相同就先软删除旧版本标记is_deletedtrue不立刻物理删除再写入新版本。这样检索时默认过滤is_deleted同时保留历史版本用于追溯和比对-- 伪代码同题号不同版本 INSERT INTO qa_units (qid, version, question, answer_text, is_deleted, created_at) VALUES (ch3_q12, 2, ..., ..., false, NOW()) ON CONFLICT (qid, version) DO UPDATE SET is_deleted false; UPDATE qa_units SET is_deleted true WHERE qid ch3_q12 AND version 2 AND is_deleted false;在文档文件层面同样要做版本归档。建议把它放进 Git 仓库doc 是二进制但 Git 能存每次更新提交一次commit message 写「修订第 3 章答案补充用地分类」这类信息。后续谁改过哪道题、改了什么内容都能查出来。实践经验是doc 转出来的文本解析结果不要提交到 Git——它每次转换可能因软件版本不同产生微小差异造成大量无意义 diff要提交的是源 doc 和 JSON 结构化结果解析脚本放到代码仓库里独立维护。6. 验证习题解答检索质量的三个指标与一个可复用脚本做完 RAG 链路最后一步是回答「它到底好不好用」。三个指标能说明大部分问题Hit Rate召回命中率、MRR平均倒数排名和引用准确率。Hit Rate 看的是正确答案是否出现在召回列表里MRR 看的是它排在第几位引用准确率看的是 RAG 回答里引用的题号是否真的和问题相关。实际评测时我建议从这份习题解答中抽 30~50 道题把题干当作查询词题号当作标准答案构造一个 minitest 集import json test_set [ {query: 什么是城市人口容量, gold_qid: ch3_q12}, {query: 试述城市规划的编制程序, gold_qid: ch2_q05}, ] def hit_rate(retriever, test_set, top_k10): hit 0 for item in test_set: results retriever(item[query], top_ktop_k) qids [r[qid] for r in results] if item[gold_qid] in qids: hit 1 return hit / len(test_set) def mrr(retriever, test_set): total 0.0 for item in test_set: results retriever(item[query], top_k50) qids [r[qid] for r in results] if item[gold_qid] in qids: total 1.0 / (qids.index(item[gold_qid]) 1) return total / len(test_set)跑完这三个指标再人工看一眼抽检结果重点看两类问题第一名词解释型题目是否经常把「城市」和「城市化」混为一谈——这说明向量的语义区分度不够考虑换更细粒度模型或在索引里给「题干关键词」单独建字段加权重第二论述题的回答是否引用了与该题相关的前置题目——城市规划和别的问题不同第 4 章的交通问题会引用第 2 章的规划编制知识这是合理的跨章节关联不要当成误召回处理。指标之外我会在检索结果页面上加一个「命中预览」功能把命中的题号和对应答案的第一句展示出来让学生先判断是否相关再展开全文。这个功能和指标评测互补——指标告诉你系统整体有没有变好预览让你亲眼看到某个具体的 query 到底是哪里跑偏了。两者都做了这个习题解答才算真正从一份 .doc 变成了一个可维护、可验证、可继续扩展的知识服务。本文还有配套的精品资源点击获取
返回列表