简介:面向自然语言处理初学者与AI项目开发者的智能问答系统学习资料,围绕问题理解、知识获取、答案生成与评估等核心模块,系统梳理了智能问答的整体架构与工作流程。内容重点覆盖分词、文本相似度计算等关键算法,详细讲解基于词典分词、正向/逆向最大匹配法,以及余弦相似度、Jaccard相似度、编辑距离等常见方法,并配有可直接运行的代码示例,便于从理论到实践的平滑过渡。压缩包为82.01MB的rar格式,文档与代码配套,既有系统介绍也有算法原理说明,适合按章节研读与调试。目前已有383人加入学习,读者可借此了解Python、NLTK、Spacy、TensorFlow或PyTorch等工具在问答系统中的应用,通过构建语料库、训练模型、优化问答性能,切实提升自然语言处理与AI工程实践能力。
1. 智能问答项目还没跑起来就被Word卡住:这包代码到底在解决什么
老板丢给我一个压缩包,名字就叫“智能问答项目代码与文档_word 代码”,解压之后一半是.py文件,一半是Word写的说明文档。这类项目现在很常见:公司里有几千份合同、制度、方案,全是Word格式,员工想查条款得一个个翻文件,领导要的是一个能直接回答“验收标准是什么”的智能问答系统。
整套系统能不能跑通,先看有没有把Word文档干净、有序地读出来,而不是先看大模型多聪明。Word不是纯文本,里面段落、表格、页眉页脚混在一起,直接丢给大模型或检索库,结果往往是一团乱麻。我会把链路拆开讲:Word解析、切块、向量检索、答案生成,以及我踩过的坑。适合手里有存量文档、想私有化部署的团队,也适合第一次做RAG的工程师照着复现。
2. 智能问答系统先想清楚文档链路:选型、目录结构与Word解析方案
很多第一次做智能问答系统的人,上来就挑大模型,把文档解析当成一个“读文件”的小事。实际落地时恰恰相反,文档解析决定了下游检索和生成的天花板。常见做法是用LangChain这类框架把读文档、切块、检索串起来,复杂一点的用LangGraph做状态编排,Java团队会参考langchain4j的开发文档,思路都是相通的。
我一般不直接用现成的pipeline,原因很现实:公司文档格式五花八门,通用loader处理Word时经常把表格丢掉、把页眉页脚也读进来,出了问题是个黑匣子,很难定位是解析错还是检索错。自己控制链路之后,每一环都能打印中间结果,哪个环节烂掉一目了然。
2.1 一条问答链路先想清楚:解析、切分、向量化、检索、生成
智能问答系统的数据流,按顺序分成五段。第一段是Word解析,把docx转成markdown或纯文本,保留标题层级和表格结构;第二段是切分,按标题和段落边界切成块,每块500到800字,块与块之间留50到100字重叠;第三段是向量化,用embedding模型把每个块转成向量,存进向量库;第四段是检索,拿用户问题向量去召回最相似的TopK个块;第五段是生成,把召回内容拼进提示词交给大模型回答。
这里最容易被低估的是第一段。Word本质是一个zip包,里面的document.xml把段落、表格、批注、修订记录全混在一起,用普通的纯文本提取,表格会变成一串连续字符,标题层级全部丢失,后续切分和检索都会跟着崩。所以文档结构化解析不是可选项,是必选项。
需要注意的是,这五段是相互影响的。标题层级丢了的文档,切分就找不到边界;切分不合理,检索召回的内容就支离破碎;召回内容不对,再强的生成模型也只能编。我见过太多项目在检索阶段反复调参数,最后发现是解析阶段就把顺序搞错了。
2.2 解析Word的三个候选方案怎么选:python-docx、docx2txt 和 Office 转 HTML
处理Word文档,通常有三个候选方案,它们的定位差异很大。
| 方案 | 能拿到什么 | 依赖 | 适合场景 |
|---|---|---|---|
| python-docx | 段落样式、标题层级、表格单元格、页眉页脚 | python-docx库 | 需要结构化信息,是我的默认选择 |
| docx2txt | 纯文本,速度快 | docx2txt库 | 只做全文检索,不关心标题层级 |
| LibreOffice转HTML | 保留复杂排版,可继续抓取结构 | 外部进程 | 排版极度复杂,比如带文本框、公式的文档 |
我一般优先用python-docx,因为它对齐了“文档结构化解析”的核心诉求:标题在段落样式里能读到,表格能逐格读取,所有内容可以按文档顺序输出成统一的中间格式。docx2txt虽然快,但拿到手的是一坨纯文本,表格和正文的顺序关系完全丢失,不适合做问答检索。
选型时可以先用一个技巧确认docx内部结构:用zipfile直接解压看document.xml的节点排列,能快速判断这份文档到底是简单段落还是嵌套表格加文本框的复杂排版。这个判断直接影响你后续要不要单独写处理逻辑,而不是把时间浪费在试错上。
2.3 项目目录怎么规划:代码、文档、语料、向量库分开放
拿到项目后先别急着写解析代码,把目录结构定下来,后面能少踩很多坑。我会按“代码、原始文档、中间产物、向量库”四类分开,中间产物一定要落盘,因为排查问题时可以直接看,不用重新跑全流程。
project/ ├── docs/ # 原始Word文档和需求文档 │ ├── source/ # 待解析的docx文件 │ └── requirements/ # 需求文档、接口文档 ├── scripts/ # 解析、切分、检索、接口代码 │ ├── parse_docx.py │ ├── split_chunks.py │ ├── build_index.py │ └── api_server.py ├── output/ # 中间产物,可人工检查 │ ├── parsed/ # 解析后的markdown文件 │ ├── chunks/ # 切分后的语料块 │ └── faiss_index/ # 向量库索引 └── eval/ # 评估集与回归脚本 ├── eval.jsonl └── run_eval.py每个解析函数都写文档注释,docstring里说清楚输入输出、返回什么格式。这个习惯在项目后期特别有用,因为切分逻辑改过两轮之后,你自己都可能不记得当初为什么这么处理段落边界。output目录下的中间产物建议用统一的命名规则,比如按原文档名加页码时间戳,方便回溯。
需求文档和接口文档放一起也值得养成习惯。业务方改需求是常态,今天说只要文本,明天说要支持表格,后天说要带来源,没有一份记录变更的文档,你会被来回改到崩溃。
3. 把Word拆成能检索的语料:解析代码、切分参数与结构化输出
这一章是整个智能问答项目的核心工序。前面架构搭得再好,解析代码本身写得糙,后面全白费。我会给一版能直接跑的解析代码,再讲清楚切分参数怎么设、边界情况怎么处理。
3.1 用python-docx按文档顺序抽取段落和表格:一版能跑通的解析代码
先看代码,按文档顺序遍历,把段落和表格转成markdown格式:
from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph from docx.oxml.ns import qn def iter_block_items(parent): """按文档顺序遍历段落和表格节点,保持原始先后关系""" body = parent.element.body for child in body.iterchildren(): if child.tag == qn('w:p'): yield Paragraph(child, parent) elif child.tag == qn('w:tbl'): yield Table(child, parent) def table_to_markdown(table): """表格转成markdown格式,单元格换行替换为空格""" rows = [] for row in table.rows: cells = [c.text.strip().replace('\n', ' ') for c in row.cells] rows.append('| ' + ' | '.join(cells) + ' |') if rows: col_count = rows[0].count('|') - 1 sep = '| ' + ' | '.join(['---'] * col_count) + ' |' rows.insert(1, sep) return '\n'.join(rows) def docx_to_markdown(path): """docx转markdown,标题映射为#级别""" doc = Document(path) lines = [] for block in iter_block_items(doc): if isinstance(block, Paragraph): style = block.style.name if block.style else '' text = block.text.strip() if not text: continue if style.startswith('Heading 1'): lines.append(f'# {text}') elif style.startswith('Heading 2'): lines.append(f'## {text}') elif style.startswith('Heading 3'): lines.append(f'### {text}') else: lines.append(text) elif isinstance(block, Table): lines.append(table_to_markdown(block)) return '\n'.join(lines)这段代码的关键在于遍历doc.element.body的子节点,而不是用doc.paragraphs和doc.tables分开拿。doc.paragraphs只返回段落,doc.tables只返回表格,两者都会丢掉“表格出现在哪两个段落之间”的顺序信息。顺序一旦乱了,上下文关系就没了,检索结果会变得非常奇怪,这是文档结构化解析里最容易翻车的点。
table_to_markdown里把单元格内的换行符替换成空格,是因为单元格里经常有多行文本,不处理会把markdown表格撑跨,解析出来的结果没法看。另外我默认每个表格独立成段,后续切分时整体保留,不对表格内部做切割,原因后面会讲。
3.2 按标题切块:chunk_size、overlap和“标题优先”的取舍
解析完Word之后,得到的是一份markdown格式的完整文本。接下来要把它切成检索用的chunk,切分策略直接影响召回效果。我用的方法是按行扫描,以标题行为边界,同时控制每块长度和重叠区:
def is_heading_line(line): return line.startswith('#') def split_by_heading(md_text, max_chars=600, overlap=60): """按标题切块,max_chars控制块长,overlap控制重叠区,标题开头优先保留""" lines = md_text.split('\n') chunks = [] current = [] current_len = 0 for line in lines: if current and current_len + len(line) > max_chars: # 取当前块末尾几行作为重叠区,避免上下文被硬切断 tail = [] tail_len = 0 for l in reversed(current): if tail_len + len(l) >= overlap: break tail.insert(0, l) tail_len += len(l) + 1 # 重叠区如果以标题开头,去掉标题,防止标题重复出现 if tail and is_heading_line(tail[0]): tail = tail[1:] chunks.append('\n'.join(current)) current = tail current_len = tail_len current.append(line) current_len += len(line) + 1 if current: chunks.append('\n'.join(current)) return chunksmax_chars我一般设600左右,中文按字符数算。overlap设60到80,作用是让相邻两个chunk之间有上下文衔接,检索时不会因为一句话恰好在边界处被切成两半而丢失信息。实际调参时,检索效果差先调overlap,不要一上来就动max_chars,因为块越大,向量化后的语义越分散,召回准确性反而下降。
重叠区以标题开头时要把标题去掉,这个细节很多人会漏。否则同一标题出现在多个chunk里,检索时会被反复命中,回答内容高度重复。另外,解析文档时遇到独立表格块,我会单独走一个分支,不参与上面的字符切分,因为表格行被切开后语义基本丢光,要么整表为一个chunk,要么按表头分组再切。
3.3 文档结构化解析的边界:图片、扫描件和嵌套表格怎么处理
解析Word时最怕的不是段落,而是图片、扫描件和嵌套表格这三类边界情况。Word里的图片本身没有文字,如果一张流程图里写着关键操作步骤,解析完之后这一段就是空白。扫描件更极端,整页都是图片,纯文本解析出来可能只有几十个字符,等于这页白费。
常见做法是接OCR识别。图片类和扫描件类文档先走OCR把文字抽出来,作为附加文本放回原位置。注意OCR结果会有识别错误,直接混入原文会污染检索结果,我一般把OCR文本单独标记,比如加一行[OCR]前缀,检索时看得到来源,评估时也能区分效果。
嵌套表格是另一个坑。python-docx读取嵌套表格时会被拍平,外层表格和里层表格的单元格混在一起,多次读取还可能读到重复单元格。处理方式是预处理阶段先把嵌套表格拆开,或单独抽取内层表格并做标记。页眉页脚也要注意,默认读取不包含,但用模板样式时页眉会混进章节标题,需要在切块前过滤掉,否则检索会命中一堆重复的“公司内部文件”之类字样。
4. 从语料到回答:向量化、召回重排与问答接口落地的参数细节
语料准备好之后,进入向量化和检索环节。这个阶段的技术选择很多,但核心参数就那几个,调明白了系统就稳了一半。这一章讲模型选型、召回参数和接口设计,每一处都能直接抄作业。
4.1 向量化模型选型:中文场景先看检索效果再看速度
embedding模型决定的是“语义相似度”准不准。中文场景下,常见的选择是BGE、M3E这类针对性优化的中文模型,生成侧可以接DeepSeek这类模型。我选型的标准很简单:拿自己业务里的50个真实问题,先跑一遍召回,看答案对应的chunk有没有进TopK,再比较维度、推理速度、内存占用。
文本清洗这一步不要省。解析出的markdown里经常有空行、全角半角混用、重复段落,清洗规则要包含三件事:合并连续空行、统一全角半角空格、去掉完全相同的段落。清洗后的语料再切块和向量化,检索噪声会小很多。
一个容易被忽略的点:标题要保留在chunk里,而且放在开头。因为向量化时模型看到的是整段文本,标题是这段内容最重要的上下文。我见过有人切块后把标题丢弃,只留正文,结果检索召回的片段语义相似但完全不是想要的位置。embedding模型虽然能理解语义,但依赖上下文线索,标题越清晰,召回越准确。
4.2 检索召回:TopK、相似度阈值和重排三个参数怎么调
向量检索我用FAISS,稳定、快、可本地部署。下面是最小可用的检索代码:
import faiss import numpy as np class VectorStore: def __init__(self, dim): # 内积索引,配合向量归一化后等价于余弦相似度 self.index = faiss.IndexFlatIP(dim) self.chunks = [] def add(self, embeddings, chunks): vectors = np.array(embeddings).astype('float32') faiss.normalize_L2(vectors) self.index.add(vectors) self.chunks.extend(chunks) def search(self, query_vec, top_k=10): q = np.array([query_vec]).astype('float32') faiss.normalize_L2(q) scores, idxs = self.index.search(q, top_k) return [(self.chunks[i], float(score)) for i, score in zip(idxs[0], scores[0]) if i >= 0]IndexFlatIP配合normalize_L2使用,内积结果就是余弦相似度,中文场景下比L2距离稳定得多,因为L2对向量模长敏感,而文本向量化后的模长本身没有太多意义。TopK我一般先设10,召回后做重排取前3,而不是直接把TopK设成3,因为重排模型能利用更完整的候选集,找到被embedding排名压下去的正确结果。
相似度阈值不能照抄网上的经验值。不同embedding模型的分数分布差异很大,有的模型不相似的文本也有0.6分,有的模型0.3就算接近。正确做法是把一批真实query的检索分数打印出来,看分布再定阈值。我通常收集20到30条query跑一遍,画出分数区间,取“明显区分开”的位置做阈值,低于阈值的chunk直接丢弃。
重排层建议用交叉编码器,它能把query和chunk拼在一起算相关性,比embedding的双塔结构更准。重排后只取前3个chunk拼进提示词,既能控制上下文长度,也能减少无关信息干扰模型生成。
4.3 问答接口与接口文档:最少要提供哪些字段
智能问答系统最终要给业务方用,接口设计不能只返回一个答案字符串。我用FastAPI写一个最小可用的问答接口,示例代码如下:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class AskRequest(BaseModel): question: str top_k: int = 10 class AskResponse(BaseModel): answer: str sources: list @app.post('/ask') def ask(req: AskRequest): query_vec = embed(req.question) hits = store.search(query_vec, top_k=req.top_k) context = '\n\n'.join( f'【来源:{c.meta["doc"]}】\n{c.text}' for c, _ in hits ) answer = generate(context, req.question) return AskResponse(answer=answer, sources=[c.meta for c, _ in hits])接口文档里至少要写清楚四样东西:请求参数、响应字段、异常情况、限流策略。请求参数里question必填,top_k可选;响应里answer是回答文本,sources里放来源文档名、标题路径、页码这些元信息。这里特别强调sources必须有,业务方拿答案去核对时,没有来源的智能问答系统就是给自己埋雷。
生成环节如果接了DeepSeek这类在线模型,要注意上下文长度的限制,TopK取多了会截断。如果本地部署开源模型,还要处理并发排队。我一般会先把上面的最小链路跑通,再考虑接入LangGraph这类编排框架,不要在第一步就把系统搞复杂,否则出了问题都分不清是编排层的问题还是底层检索的问题。
5. 智能问答落地避坑:Word解析和检索质量的5个真实翻车点
这一章写的都是实际推进项目时的血泪经验。每一条都按“现象、原因、解决”来写,你可以直接对照排查。
5.1 表格与正文的顺序被打乱
现象:解析完的markdown里,所有表格都跑到文档末尾,正文和表格的对应关系全部错乱。问“设备清单在哪一页”时,检索到的chunk里只有表格没有上下文。
原因:用了doc.paragraphs和doc.tables分别读取,先拼接段落再拼接表格,表格原本在文档中哪个位置的信息丢了。这个问题在只处理纯文本时看不出来,一旦文档里表格占比高,检索结果就明显偏离。
解决:用第3章里的iter_block_items方案,按body子节点的出现顺序遍历输出。解析完成后不要急着往下走,人工抽查10个位置,对比原文确认顺序一致。这个检查用不了两分钟,但能避免下游全部白跑。
5.2 切分把一句话切成两半,答案永远不完整
现象:问“验收流程分几步”,返回的答案只有半句,说“根据合同第”,然后戛然而止。打开chunk定位,发现这句话被一块硬边界从中间劈开了。
原因:切分直接按字符数硬切,没有考虑段落边界。中文字符短,600字刚好卡在一句话中间的概率很高,尤其是长句多的合同文本。
解决:切分逻辑改成按行扫描,段落足够短就整段保留,只有超过max_chars的段落才在标点处断开。同时打开overlap,把上一块末尾的60到80字接到下一块开头,保证被边界影响的内容能被重复检索到。
5.3 检索召回一堆无关内容,问A答B
现象:问“项目延期违约责任”,召回的chunk全是“延期”出现过的其他章节内容,合同里的违约条款排名反而很低。看起来模型理解了词,但没理解问题指向的是合同条款。
原因:一是chunk里没有标题上下文,embedding只看到孤立正文;二是没有重排层,纯靠向量相似度排序,容易跑偏;三是相似度阈值设得太低,什么内容都放进来。
解决:切分时把标题拼到chunk开头,让“违约责任”这个章节标识参与向量化。加上交叉编码器重排,把候选集中语义不对的chunk压下去。最后把真实query的分数分布打出来,重新定阈值,而不是沿用默认值。
5.4 扫描件和图片文档解析出来是空串
现象:有的制度文档看起来是Word,打开后每页都是一张扫描图片。解析结果只有几十个字符,检索时根本命不中相关内容。这种情况最容易在文档量大的时候蒙混过关,因为统计上看不清单个文件的解析质量。
原因:Word里根本没有文字层,纯文本提取无能为力。这类文档的来源多半是纸质文件扫描后直接转存的docx。
解决:增加前置检测,解析后统计有效文本长度,低于阈值的文档自动标记为“疑似扫描件”,单独走OCR识别流程。OCR结果建议加[OCR]前缀单独存放,避免识别错误污染正常语料。识别后要人工抽检几页,确认关键字段没被识别错。
5.5 生成模型回答重复或输出符号堆砌
现象:回答本身通顺,但翻来覆去重复同一句话,或者输出一堆项目符号和特殊符号,业务方看了直接截图来问“这系统是不是坏了”。
原因:提示词里没给格式约束,上下文里重复内容多,解码参数没调。模型看到多个chunk里有相似的句子,就容易陷入重复循环。
解决:提示词里明确要求“无法从给定内容中找到答案时直接说明不知道”,并限定回答格式,比如“分点列出,每一点不超过两行”。解码参数上,重复出现时先调低temperature,再适当加大top_p,或者减少chunk重叠区域长度,降低重复内容进入上下文的概率。
这五个问题如果都排查完还不生效,把中间markdown打出来人工读一遍。RAG项目里八成的问题出在语料,不在模型。这听起来有点玄学,但经验就是这样,语料干净了,大部分参数不用怎么调就能跑出能看的结果。
6. 上线前多做一步:答案溯源加评估集,让问答结果可验证
系统能跑通只是第一步,真正让业务方敢用,靠的是答案可验证、改动可回归。我上线前必做两件事,一件是给答案加来源,另一件是建评估集。
答案溯源说起来简单:把chunk的来源信息,包括文档名、标题路径、页码,拼进提示词,要求模型回答时带上来源描述,接口把sources原样返回给前端。这样业务方看到任何回答都能自己去翻原文核对,不用问你“这个答案哪来的”。这一步做完,整个智能问答系统的可信度会明显上一个台阶。
第二件事是评估集回归。从真实业务里挑20到50个问题,每个问题标注期望命中的文档编号,存成jsonl文件。以后每次改解析逻辑、切分参数或检索策略,都跑一遍评估脚本,统计期望文档出现在召回TopK里的比例:
import json hits_count = 0 total = 0 for line in open('eval.jsonl', encoding='utf-8'): item = json.loads(line) hits = store.search(embed(item['question']), top_k=5) hit_ids = {c.meta['id'] for c, _ in hits} if item['expected_id'] in hit_ids: hits_count += 1 total += 1 print(f'召回命中率: {hits_count / total:.2%}')这个命中率不直接等于回答质量,但每次改动后跑一遍,能快速判断是变好还是变坏。我一开始偷懒没做这步,上线后被业务同事拿一个合同条款问住,检索结果完全对不上,才回头补评估集。从那以后,任何解析或检索的改动,都先跑一遍评估再发布,再没因为“改好了A又弄坏了B”这种问题熬夜排查。希望帮到你。
本文还有配套的精品资源,点击获取