基于LangChain与FAISS的本地知识库问答:Word文档处理实战
本地知识库问答这两年确实是热门方向但网上的教程大多拿PDF和纯文本顺手一带一旦遇上Word文件就各种翻车。我最近用LangChain配合FAISS给公司搭了一套本地文档问答系统踩了一路的坑之后终于跑通了今天把完整思路和Word处理的经验都整理出来给大家做个参考。这套系统说白了就是把一堆本地文档导入拆成片段向量化存进FAISS索引然后拿大模型做问答。它能帮你快速从几十上百个文档里找到答案不用一篇篇打开翻也不用把资料发给外部在线服务数据全程留在本地。适合有内部资料查询需求的团队参考也适合正在折腾RAG的开发者看看实战细节。1. 为什么要在本地搭一套文档问答系统1.1 这个项目到底解决什么问题我这边接到的需求其实很朴素公司每周产生大量项目文档、会议记录、报价单格式五花八门Word、PDF、Markdown都有同事每次找资料要么问人要么一份份翻效率极低。最初想着直接把所有文档扔给在线版AI处理但资料涉及内部数据不可能上传到公网服务。所以核心诉求就是三条文档全部存在本地不经过第三方服务能处理Word文件尤其是docx和旧版doc混着来的情况给出答案的同时还能告诉我答案来自哪份文档的哪个位置方便人工核对。顺着这个思路我选择了LangChain做流程编排FAISS做向量检索大模型走本地推理。整个系统跑在一台普通办公电脑上不依赖外部API没有任何数据外泄风险。1.2 技术选型为什么是LangChain加FAISS而不是其他方案先说LangChain。它是一个专门用来编排大模型应用流程的框架把文档加载、文本切分、向量化、检索、Prompt拼接这一整套动作都用现成的组件串起来不用自己从零写胶水代码。很多人吐槽LangChain太重、API变动频繁这个我不反对但对于这种标准的RAGRetrieval-Augmented Generation检索增强生成场景它确实能把搭建时间从几天压缩到几个小时而且社区资料多遇到问题很容易搜到解法。再说FAISS。文档问答需要把查询向量和库里的所有向量做相似度搜索FAISS就是Facebook AI Research开源的高性能向量检索库专门干这个事的。本地跑、无需服务端、支持百万级向量对小团队的文档量来说绰绰有余。同类方案里有的团队用Chroma有的用Milvus但Chroma在超大语料上性能不如FAISSMilvus则需要额外部署服务端对于纯本地单机场景FAISS是性价比最高的。官网地址是faiss.aiCPU版本直接用pip装。有没有考虑过Elasticsearch想过但ES本身是全文检索系统做向量搜索需要额外装插件、维护索引机器配置要求也更高。FAISS一个索引文件就搞定的事情没必要上那么重的方案。2. 系统整体设计一条清晰的处理流水线2.1 文档问答的完整链路整个系统可以拆成两个阶段先建立索引再进行问答。建立索引阶段从指定目录读取所有文档将文档内容拆成固定大小的片段调用Embedding模型把每个片段转成向量用FAISS保存这些向量及对应的原文索引。问答阶段接收用户提问把提问转成向量在FAISS中检索最相似的若干文档片段将命中的片段连同原始问题一起组成Prompt大模型基于Prompt生成回答并标注引用来源。这个流程听起来简单但每个环节都有细节后面会逐个展开。这里先把核心的架构图用文字描述一下文档目录 - 加载器 - 切分器 - Embedding模型 - FAISS索引用户问题 - Embedding模型 - FAISS检索 - Prompt模板 - 本地大模型 - 回答。2.2 Embedding模型的选择思路很多人容易忽略Embedding模型的重要性以为随便选一个就行。实际测试下来检索效果的好坏很大程度取决于Embedding模型是否适合你的文档语言和内容类型。我试过三类方案云端API类Embedding效果稳定但需要联网不适合纯离线场景本地HuggingFace模型比如BAAI的bge-large-zh-v1.5中文效果不错单机也能跑Ollama内置的嵌入模型部署最简单一条命令拉起来就能用效果也不差适合快速验证。最终我在公司内网环境选择了bge-large-zh-v1.5主要看重它在中文语义匹配上的表现而且模型体积适中普通CPU也能处理。如果你的文档以英文为主可以用bge-large-en或其它英文模型。这个选择直接决定了向量检索的上限值得多花点时间测试。3. Word文件处理避坑指南真正的重头戏3.1 docx和doc的差别比你想的大这是我在整个项目里踩坑最多的地方一定单独拿出来说。先明确一个基础概念docx和doc完全不是同一个东西。docx是Office 2007之后使用的格式本质是一个ZIP压缩包里面包含一堆XML文件所以程序可以用python-docx这类库直接解析。而doc是旧版二进制格式结构不公开python-docx读不了直接用文本解析出来的全是乱码。我最初天真地以为用LangChain自带的加载器就能同时处理两种格式结果在DirectoryLoader里指定docx2txt后遇到一个.doc文件就起码报错三次。后来总结出两条路只处理docx的话用python-docx或载荷器直接读干净利落必须处理doc的话先把doc批量转成docx或纯文本再走下一步。怎么批量转Windows环境下可以用pywin32调用本机安装的Office进行转换但这要求每台要运行脚本的机器都装了Office。如果服务器环境没有Office也可以用LibreOffice的命令行模式执行soffice --headless --convert-to docx文件路径实测可行效果稳定。3.2 文件加载直接读还是转换LangChain官方社区里提供的Word文档加载器有好几个Docx2txtLoader、UnstructuredWordDocumentLoader、python-docx封装。我用下来的实际体验是Docx2txtLoader对docx的处理最顺手解析速度快输出内容干净还不会把页眉页脚的内容带进来这对我们这种有大量带模板页眉的文档场景非常重要。UnstructuredWordDocumentLoader功能更强能处理格式和结构但速度偏慢还会额外安装不少依赖包。纯文档问答场景不需要那么复杂的功能用Docx2txtLoader就够了。转换后的文本仍然会有很多噪音比如项目编号、制表符、多余空行。我在代码里会在切分前做一层清洗把连续空行压缩成单行把特殊字符统一标准化这样后面检索的准确率会明显提升。另外一个容易出问题的地方是编码。docx本身是ZIPxml格式二进制方式读取直接废掉但有时候文件后缀是docx、实际内容却通过了其他方式生成内部结构并不规范python-docx打开时直接报错。这种情况我在处理一些同事从老旧OA系统导出的文件时遇到过处理方案就是在加载阶段加上异常捕获加载失败的文件单独放在一个待人工处理的列表里由同事重新另存为标准格式而不是让整个程序因为一两个坏文件而挂掉。3.3 特殊内容处理表格、图片、页眉页脚Word文档里最麻烦的是表格。我之前遇到过一份报价单关键产品的型号和价格全部在表格里Docx2txtLoader默认会自动提取表格中的文本并按行拼接但拼接顺序容易乱表格中段之间的语义也容易断裂。后来我做了个额外处理如果是表格密集的文档先用python-docx读出来按单元格依次抽取文本并标记好表格编号再拼回文档流里这样切分时每个单元格的内容不容易丢失。图片里的文字在Word问答里是个大坑。docx里的图片本质是嵌入的二进制对象文本加载器不会自动做OCR光学字符识别。如果文档中图片数量多且图片里有关键信息需要额外接入OCR工具。我们目前的做法是把含关键信息的图片单独维护成一个说明文档不在这里展开。页眉页脚是最容易被忽略的坑。普通正文解析一般不会带上页眉页脚但使用某些加载器时页眉里的公司名称会重复混进内容里导致检索到的相关片段全是重复的公司名和logo文字。排查方法很简单加载完成后打印几段文本看看内容里是否混入了不该出现的重复文字发现了就想办法把页眉内容过滤掉。4. 动手实现从零搭建本地文档问答系统4.1 环境准备与依赖安装基础环境我用的是Python 3.10实测在3.9和3.11下也能正常跑只是部分依赖编译时可能需要不同版本的工具链。依赖项如下pip install langchain pip install langchain-community pip install langchain-huggingface pip install faiss-cpu pip install python-docx pip install docx2txt pip install pypdf pip install tiktoken如果你的环境无法访问公网PyPI需要离线安装那另一套方案提前在一台能联网的机器上执行pip download把依赖包下载为whl文件拷贝到内网后本地安装。注意FAISS这个包是带编译优化选项的最好直接下载对应Python版本的预编译whl不要在内网临时编译否则可能因为缺少依赖库而失败。LangChain的API更新频率很高不同版本的导入路径会不一样。早期版本可以直接从langchain.vectorstores导入FAISS新版本统一放到了langchain_community.vectorstores里。我下文代码按照当前稳定版的导入方式来写。4.2 编写文档加载与拆分代码先写文档加载部分。目录结构我做了统一约定所有待检索的文档放在data/docs目录下内部按业务线分子目录存放。import os from pathlib import Path from langchain_community.document_loaders import ( Docx2txtLoader, PyPDFLoader, TextLoader, ) from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.schema import Document def load_documents_from_dir(directory: str): docs [] failed_files [] data_path Path(directory) if not data_path.exists(): print(f[警告] 目录不存在: {directory}) return docs, failed_files for file_path in data_path.rglob(*): if not file_path.is_file(): continue ext file_path.suffix.lower() try: if ext .docx: loader Docx2txtLoader(str(file_path)) loaded loader.load() elif ext .pdf: loader PyPDFLoader(str(file_path)) loaded loader.load() elif ext .txt or ext .md: loader TextLoader(str(file_path), encodingutf-8) loaded loader.load() else: continue for doc in loaded: doc.metadata[source] str(file_path) doc.metadata[filename] file_path.name docs.extend(loaded) except Exception as e: failed_files.append((str(file_path), str(e))) print(f[失败] 无法加载 {file_path}: {e}) return docs, failed_files def split_documents(docs, chunk_size500, chunk_overlap80): splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapchunk_overlap, separators[\n\n, \n, 。, , , , , , ], ) return splitter.split_documents(docs)几个细节值得说明。chunk_size我选了500这个数值不是拍脑袋定的。太大单次交给大模型的上下文就长容易混淆多个知识点还费显存太小语义会被切碎检索结果经常不完整。经过对公司样本文档的测试500到800之间效果区别不大500左右在后续生成回答时引用信息更完整。chunk_overlap用于解决切分边界切断语义的问题。两个相邻片段保留一部分重复文本可以避免关键信息正好落在边界上而丢失。切分器的separators参数我是按中文语境调整过的把中文标点也加入分隔符序列这样文本会尽量在完整句子处断开而不是硬生生从中间劈开。4.3 构建FAISS向量库向量库是整个系统的核心存储。实现如下from langchain_huggingface import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS embeddings HuggingFaceEmbeddings( model_nameBAAI/bge-large-zh-v1.5, encode_kwargs{normalize_embeddings: True}, ) vector_store FAISS.from_documents(docs, embeddings) vector_store.save_local(data/faiss_index)normalize_embeddings这个参数是我特别验证过的。bge系列模型推荐在做相似度计算前对向量做归一化处理这样用余弦相似度或者内积计算时结果才准确。不归一化也能跑但检索质量会下降原因在于原始向量的模长分布不均匀未归一化时欧氏距离与语义相似度的关联性会变差。如果你用的是其它Embedding模型建议看模型说明文档确认是否需要设置这个参数。FAISS索引的保存与加载也经历过版本变化。新版本加载本地索引文件时因为反序列化存在安全隐患必须显式加上allow_dangerous_deserializationTrue。我给大家的可用示例vector_store FAISS.load_local( data/faiss_index, embeddings, allow_dangerous_deserializationTrue, )构建索引这一步有几个容易踩坑的细节文档量很大的时候一次性from_documents可能会把内存占满。我当时处理一份300多页的年度报告时内存占用直接飙到3GB以上。建议分批插入循环读取文件夹按文件为单位构建向量库再逐步merge。FAISS会将向量索引完整加载在内存中所以机器内存大小决定了单机可以支持的最大文档量。办公室文档运维场景几千份文档完全没问题。保存索引后建议把原始切分元数据也单独存一份JSON方便后续排查问题也便于后续重新构建。4.4 实现问答链路建好向量库之后问答部分反而简单了。核心就是把用户问题转为向量从FAISS中检索出最相似的片段拼成Prompt丢给大模型。from langchain_core.prompts import PromptTemplate from langchain_community.llms import Ollama retriever vector_store.as_retriever( search_typesimilarity, search_kwargs{k: 5}, ) llm Ollama(modelqwen2.5:7b, temperature0.1) prompt_template PromptTemplate( input_variables[context, question], template你是一个企业内部资料助手。请根据下面的参考资料用中文回答用户的问题。 如果参考资料中没有明确答案请直接说“资料中未找到明确答案”不要编造。 参考资料 {context} 用户问题{question} 回答, ) def answer_question(question: str): retrieved_docs retriever.get_relevant_documents(question) context \n\n.join( f[来源: {doc.metadata.get(filename)}] {doc.page_content} for doc in retrieved_docs ) prompt prompt_template.format(contextcontext, questionquestion) response llm.invoke(prompt) return response这里有几个细节想补充。search_kwargs{k: 5}表示从向量库中检索5个最相关的片段。k值不能太大否则Prompt里塞的上下文过多大模型回答时会挑着答而不是聚焦答案k值太小则可能漏掉关键信息。我用5个片段作为默认值。temperature我设置成0.1尽量让模型忠实于检索到的资料减少自由发挥。如果你需要更保守的回答直接设为0可以更稳定但我个人还是保留了一点点随机性避免多轮问题对同一个问题的回答完全千篇一律。关于本地大模型的选择我用过Qwen2.5 7B、Llama 3.1 8B和ChatGLM3 6B。中文场景下Qwen2.5的表现最稳定指令遵循性好回答也规范。如果你的机器配置够高直接用14B参数版本效果会更明显配置一般就用7B完全能跑起来。5. 常见问题与排查技巧实录5.1 安装和环境问题问题一pip安装faiss-cpu成功后import faiss仍然报ModuleNotFoundError这种情况十有八九是Python环境不一致。有些机器上有多个Python环境pip和python命令指向的是不同的解释器。排查方式在命令行分别执行python --version和pip show faiss-cpu确认路径一致。更稳妥的做法是用虚拟环境我全部项目都用venv或conda管理避免环境污染。问题二离线内网环境装不上langchain和faiss离线安装的关键是要提前把所有依赖都带进内网。执行pip download -r requirements.txt -d ./pkg拉取之后拷进内网执行pip install --no-index --find-links./pkg -r requirements.txt。注意FAISS-CPU的wheel包通常比较大包含底层C库一定要按平台和Python版本下载别在Linux上拉Windows的包。5.2 FAISS运行报错问题三维度不匹配Dimension mismatchFAISS要求全部向量维度一致且数值类型一致。如果用的是HuggingFaceEmbeddings向量维度由模型决定只要同一个模型一般不会出问题。出问题的地方在于加载了别人创建好的索引却用了另一个Embedding模型。解决方式重新构建索引或者保证加载索引时使用的Embedding模型和创建索引时完全一致。问题四save_local和load_local时提示文件不存在FAISS的save_local会生成两个文件一个是index.faiss二进制索引文件另一个是index.pkl元数据文件。两个文件必须放在同一目录下加载时传目录路径即可。我碰到一次是因为手工复制时漏了index.pkl文件稍微注意一下就不会犯这种错误。5.3 检索效果差的排查思路问题五检索出来的内容驴唇不对马嘴问A答B这种情况大概率是Embedding模型与文档语言不匹配或者文档本身格式混乱、文本解析出来是乱码。处理步骤打印几条检索到的片段看原文是否正常如果原文正常但相关性差考虑换一个更适合的Embedding模型如果原文片段过短或过长调整chunk_size参数重新构建索引。问题六检索结果排序不稳定重复执行结果不同很多向量检索模式带了随机性特别是在索引分层聚类的情况下。在测试阶段我会把search_kwargs里加上fetch_k参数如search_kwargs{k: 5, fetch_k: 20}先取20个候选再精排5个稳定性更好。实测下来这个参数在小数据量上效果明显数据量大时也能提升相关性。6. 一点真实的使用心得整个项目从开始到稳定运行用了大概五天时间主要集中在Word处理上。做这类本地知识库问答最大的收获是真正决定系统能用不用的往往不是大模型本身而是文档处理这一层。文档能干净、规范地转成文本后面的向量化、检索、问答就全通了文档处理不好再强的模型也救不回来。另外想提醒一点本地问答系统的答案仍然可能出错千万别把它当作100%可靠的自动报告工具更合适的定位是半自动的“智能搜索摘要生成”。我在代码里特意让每一条回答都带上参考文档名称同事核实起来方便使用信任度就高了不少。最后分享一个小技巧给系统做一个简单的可视化入口比如用Gradio或者Streamlit套一层把上传文档、更新索引、问答聊天三个操作都做成按钮。这样业务同事自己就能用不用每次来敲命令行。我做了一个50行左右的Streamlit页面公司内部用得挺顺手后续也可以在这个基础上扩展多轮对话、引用高亮等功能。