用LangChain+FAISS打造企业Word文档智能问答系统实战
2026/9/17 1:52:33 网站建设 项目流程

最近有个内部知识库的项目,需求很简单也很实际:一堆Word文档散落在共享盘里,领导想让人输入一句话,就能从这堆文档里捞到准确答案,还附带原文出处。我第一反应就是用LangChain加向量检索来做——这类本地文档问答系统在RAG(检索增强生成)里算是最成熟的落地场景了。调研了一圈,最后敲定FAISS做向量存储,毕竟对文档量在百万以内的中小型企业场景,单独为了这个上Elasticsearch或者Milvus有点重了。

这篇文章我会把完整的搭建过程、踩过的Word解析的坑、还有参数调优的经验全部摊开讲。项目本身不复杂,但细节极其密集,尤其是Word文件的处理,网上能搜到的资料很少能讲透。适合刚接触LangChain想动手做点东西的开发者,也适合在企业里被“文档太多找不到东西”折磨的运维和业务同学参考,代码量不大,但每一步都有值得琢磨的地方。

1. 整体方案设计与技术选型思路

1.1 为什么选LangChain加FAISS这个组合

先说结论:这个组合是当前做中小规模本地知识库性价比最高的方案,没有之一。

LangChain的价值不在于它的代码有多优雅,而在于它把RAG链路里的“文档加载、文本拆分、向量化、检索、LLM调用”这几个环节抽象成了标准接口。你不需要自己去写一套文档解析器,也不需要纠结不同向量库的API差异。换存储后端的时候,只改两行代码的事。

FAISS则是Meta开源的一款向量检索库,底层用C++实现,暴露Python接口,检索速度是真的快。十万条向量做余弦相似度搜索,单机毫秒级返回结果。关键在于它不需要额外部署服务,就是一个本地索引文件,这对企业内网没有容器环境、不允许随便装服务端组件的场景来说太友好了。

整套系统的数据流是这样的:Word文档通过加载器读进来,拆成带语义边界的文本块,然后用Embedding模型把每块文本转成向量,存入FAISS索引。查询的时候,问句同样转成向量,在FAISS里筛出最相近的Top-K个文本块,跟原始问句一起拼进Prompt交给大模型,让它基于这些片段做归纳总结并标注来源。简单说就是给大模型配备了一个“外部记忆”,让它不用胡编乱造,也有据可依。

1.2 对比其它方案我踩过哪些弯

聊备选方案前先放句话在这儿:没有最好的方案,只有当前阶段最合适的。

我最早试过直接用text-embedding-ada-002配合Chroma,Chroma确实上手快,安装后自动持久化,但碰到几十个文档批量灌入的时候,写入速度明显拖后腿,而且它默认的持久化目录一多就特别乱。后来换了FAISS,干净利落,.index文件一个搞定,备份迁移都方便。

也有同事强烈推荐用Milvus,我去调研过,功能确实强大,支持增量更新、标量过滤、混合检索,但前提是你得有个能跑Docker的服务器,还得维护一套etcd、MinIO之类的依赖。如果文档量只有几万段落,纯属杀鸡用牛刀。FAISS用户态就能跑,环境问题少太多。

Embedding模型这一环,我一开始图省事用了sentence-transformers/all-MiniLM-L6-v2,几行代码就能跑通,但中文场景效果明显拉胯,很多专业术语召回完全不对。换成shibing624/text2vec-base-chinese之后,同一份测试集召回率提升非常明显。关于模型选型这个,后面我会单独开一节详细讲。

1.3 技术栈全景和适用范围

最终敲定的技术栈长这样:

  • Python 3.9+(推荐3.10,3.11稍有兼容问题)
  • LangChain 0.1.x(注意0.2版本API变动较多,锁版本)
  • FAISS CPU版(faiss-cpu,普通电脑CPU处理十万级向量毫无压力)
  • embedding模型:text2vec-base-chinese或者bge-small-zh-v1.5
  • LLM:OpenAI GPT-4o / 通义千问 / 本地Ollama(根据数据敏感度选)
  • Word解析:python-docx + 自制预处理脚本

这套方案能解决的问题域很明确:文档量在一万份以内、内容以中英文Word为主、对首字响应速度要求不超过5秒、需要答案可溯源的项目。如果未来向量量级到百万以上,再迁Milvus或者Elasticsearch,LangChain的抽象能屏蔽掉迁移成本的大部分。

2. 环境准备与依赖安装细节

2.1 Python环境创建和版本锁定的必要性

作为这行的老手,我用pyenv加virtualenv一年到头要踩的坑头一个就是依赖地狱。这里强烈建议从一开始就用虚拟环境,别直接装全局。

python3.10 -m venv venv source venv/bin/activate pip install --upgrade pip

依赖安装用requirements.txt锁定主版本,LangChain版本迭代太快,半个月一个版本,API说变就变,锁不住版本后面跑着跑着就会莫名其妙报错。

langchain==0.1.20 langchain-community==0.0.38 langchain-openai==0.1.7 faiss-cpu==1.8.0.post1 python-docx==1.1.0 sentence-transformers==2.7.0

2.2 FAISS离线安装的正确姿势

看到有人贴这个关键词“faiss离线安装”,我就明白了,八成是遇到了服务器没法连外网的情况。这个过程有标准解法,先把依赖包装好,再移到目标机器上装。

联网机器执行:

pip download faiss-cpu==1.8.0.post1 -d ./faiss_packages pip download python-docx==1.1.0 -d ./faiss_packages

然后把目录拷到目标机器,执行:

pip install --no-index --find-links=./faiss_packages faiss-cpu python-docx

如果目标机器有网络但很慢,用国内镜像源会快非常多:

pip install faiss-cpu -i https://pypi.tuna.tsinghua.edu.cn/simple

这里有个大坑:faiss-cpunumpy版本有要求,老版本要numpy<1.26,新版本在某些源上装下来会静默升级numpy,导致系统里其他程序全挂。我建议装完faiss-cpu后马上跑一句import faiss验证,有问题马上查numpy版本。

2.3 验证环境是否就绪

环境装完,先别急着写功能,跑一个最小化验证脚本,确保全部串联通畅:

import faiss from langchain.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings(model_name="shibing624/text2vec-base-chinese") vec = embeddings.embed_query("测试一下嵌入效果") print(len(vec)) # 输出768等于成功 index = faiss.IndexFlatL2(768) index.add([vec]) print(index.ntotal) # 输出1等于成功

这个脚本能跑通,说明向量库和embedding管线都是通的,后面出问题就可以放心排查逻辑层。

3. Word文件解析的避坑全指南

3.1 为什么Word解析反而是整条链路最容易翻车的环节

这个项目踩坑最惨的地方不是LangChain、不是FAISS,是Word解析。

大多数教程拿个PDF或者TXT当示例,代码跑通了给人感觉很简单。但企业真实环境里的Word文档,从来不是规规整整的纯文本。我接手的那批文档至少包含:多级标题、表格、页眉页脚、文本框、图片注释、项目符号列表,甚至还有扫描嵌入的图片,这些都可以让简单解析工具彻底翻车。

LangChain自带了一个Docx2txtLoader,试过的都知道,这个加载器对微软Word生成的标准docx还凑合,但中文场景下问题很多。表格内容会被吞掉大半,段落顺序可能错乱,碰到模板生成的文档直接崩。更别提.doc老格式,Docx2txtLoader连读都读不了。

所以我的方案是:放弃通用工具,直接用python-docx自己解析结构。

3.2 python-docx完整解析方案

先立规矩——.doc老格式不能直接用python-docx处理,要先转成.docx。LibreOffice的命令行是无头转换最稳的方案:

soffice --headless --convert-to docx --outdir ./converted ./原始文件.doc

转完之后,按区块提取正文和表格:

from docx import Document def parse_docx(path): doc = Document(path) blocks = [] # 遍历文档body的孩子节点,区分段落和表格 from docx.document import Document as _Document from docx.table import Table from docx.text.paragraph import Paragraph from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P parent_elm = doc.element.body for child in parent_elm.iterchildren(): if isinstance(child, CT_P): para = Paragraph(child, doc) text = para.text.strip() if text: blocks.append(("paragraph", para.style.name, text)) elif isinstance(child, CT_Tbl): table = Table(child, doc) blocks.append(("table", "Table", table)) return blocks

这段代码的关键在于直接遍历body元素的孩子节点,保持文档原始顺序。用doc.paragraphsdoc.tables分开取,顺序会乱,因为表格和段落是交错的。

3.3 表格和嵌套结构怎么处理

Word表格是重灾区。我把表格切成“行级文本”,每一行转成一个自然句子,并加上表头信息作为前缀:

def table_to_text(table): rows = table.rows headers = [cell.text.strip().replace('\n', ' ') for cell in rows[0].cells] header_line = " | ".join(headers) lines = [] for row in rows[1:]: cells = [cell.text.strip().replace('\n', ' ') for cell in row.cells] lines.append(f"{header_line}\n{' | '.join(cells)}") return lines

加入表头前缀这个操作非常关键。表格脱离表头之后,单看一行数字完全不知道什么意思,比如“张三 | 3000 | 2000”如果不带字段名,检索时根本无法匹配语义。经验是宁可重复表头增加token开销,也不能丢失列的语义信息。

3.4 多级标题与列表符号的处理逻辑

多级标题直接用python-docx读style.name识别,例如“Heading 1”“Heading 2”,中文模板里可能是“标题 1”“标题 2”。列表项会有List BulletList Number风格。

处理策略很简单:

  • 正文段落和标题之间保留清晰的标记,比如用Markdown风格的#
  • 列表项从第二项开始可以不重复全量信息,但切块时要跟上下文合并,避免只有孤零零一行

这里有个前后处理细节,Word里中文段落经常带一大堆全角空格、制表符、自动编号生成的多余空格。统一处理掉,不然嵌入模型会把无意义符号当语义,直接拉低召回效果。

import re def clean_text(text): text = text.replace('\u3000', ' ') text = re.sub(r'[ \t]+', ' ', text) text = re.sub(r'\n{3,}', '\n\n', text) return text.strip()

4. 文本切块策略与Embedding模型选型

4.1 固定长度切块为什么不适合Word文档

很多教程喜欢用RecursiveCharacterTextSplitter按固定长度切块,这路子想得太简单了。Word文档有天然的结构边界:章节标题、段落、表格。按固定长度硬切会把一个完整的段落截成两半,语义断裂之后,检索召回的正确率直接下降两成,生成答案的质量就更不用说了。

我的做法是“结构优先,长度限制兜底”。先按标题结构组织内容,每个二级标题下的所有段落作为一个大块,如果大块超长再递归切,但切分时优先在段落边界断句,尽量避免在句子中间截断:

from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";"], keep_separator=True )

关键就在separators这个参数,中文场景务必把句号、问号、分号加进去。默认的separator只对英文友好,中文语料直接无视,很容易在奇怪的字符处断开。

4.2 中文Embedding模型的痛苦选择

Embedding模型是整个系统里直接影响效果天花板的部件。我用过几款,对比相当明显:

  • text-embedding-ada-002:效果好,但数据出网,企业内部文档基本不可接受
  • shibing624/text2vec-base-chinese:中文理解好,768维向量,体积适中,本地跑得动
  • BAAI/bge-small-zh-v1.5:检索效果更好,但FAISS建库需要归一化处理(细节后面讲)
  • paraphrase-multilingual-MiniLM-L12-v2:多语言但中文效果中规中矩

真实场景给的建议:数据敏感、希望在本地推理,选text2vec-base-chinese起步,或者直接上bge-large-zh-v1.5这个档位的。显存不够的前提下,bge-small-zh-v1.5加归一化也能打。千万避免用官方教程里常见的all-MiniLM-L6-v2跑中文,那不是能不能用的问题,是效果能烂到让你怀疑人生的问题。

4.3 向量维度与FAISS索引类型的匹配

选完模型马上要面对一个容易栽的坑:向量维度。

text2vec-base-chinese输出768维,bge-small-zh也是768维,但有些模型比如bge-large是1024维。FAISS创建索引时必须指定维度,后面插入向量维度不一致直接报错或插入的向量全部无效:

dimension = 768 index = faiss.IndexFlatL2(dimension)

还有归一化问题。用内积(IP)索引的时候,必须先对嵌入向量做归一化;而用IndexFlatL2就没有这个要求。很多教程混着写,最容易翻车。我的做法是简单粗暴,全项目统一用IndexFlatL2,保证阈值判断和检索逻辑一致:

from langchain.vectorstores import FAISS vectorstore = FAISS.from_documents( documents=texts, embedding=embeddings )

这样LangChain会内部调用embedding模型生成向量并把add操作封装好。如果你想自己控制索引细节,可以手动构造Document列表再调用方法,效果相同。

5. 核心链路实现:从Word到可问答的完整代码

5.1 文档加载、解析、切块的流水线实现

一个能打的生产级函数,一定不是一个函数搞定所有事,而是拆成解耦的几个步骤。

# load_and_split.py import os from typing import List from langchain.schema import Document from docx import Document as DocxDocument from docx.table import Table from docx.text.paragraph import Paragraph def parse_docx_blocks(filepath: str): """返回文档里的结构块列表:每个块是 (type, style, content)""" doc = DocxDocument(filepath) blocks = [] for child in doc.element.body.iterchildren(): # ... 上文的遍历逻辑 pass return blocks def blocks_to_documents(blocks: List[tuple], source: str) -> List[Document]: """将结构块拼装成 LangChain Document 对象""" docs = [] current_h2 = "" buffer = [] def flush(): if buffer: full_text = "\n".join(buffer) docs.append(Document(page_content=full_text, metadata={"source": source, "section": current_h2})) buffer.clear() for block_type, style, content in blocks: if block_type == "paragraph" and style.startswith("Heading"): flush() # 遇到新标题先落盘上一节的内容 current_h2 = content.strip() else: buffer.append(content if isinstance(content, str) else str(content)) flush() return docs

这个函数的核心价值是:用“标题切块”替代“固定长度切块”,让每个Document天然保持章节语义。metadata里的source字段在后面做引用溯源时是救命稻草。

5.2 批量建库与持久化到FAISS

解析完所有文档之后,把所有Document合并,交给FAISS批量建库:

from langchain.embeddings import HuggingFaceEmbeddings from langchain.vectorstores import FAISS def build_vectorstore(all_documents: List[Document], persist_dir="db/"): embeddings = HuggingFaceEmbeddings(model_name="shibing624/text2vec-base-chinese") vectorstore = FAISS.from_documents(documents=all_documents, embedding=embeddings) vectorstore.save_local(persist_dir) return vectorstore

首次加载embedding模型会比较慢,从HuggingFace下载权重可能耗时几分钟,这个等待是正常的,不是卡死了。下载过后保存到本地的~/.cache/huggingface,后面加载会快很多。

需要增量添加文档的场景,加载旧索引然后直接add:

vectorstore = FAISS.load_local(persist_dir, embeddings) vectorstore.add_documents(new_documents) vectorstore.save_local(persist_dir)

5.3 建立检索问答链路

到这里,系统的核心阶段才算真正亮出最终的问答链路,直接用LangChain的检索QA链:

from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="gpt-4o-mini", temperature=0.1, openai_api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL") # 可以兼容各种兼容OpenAI协议的网关 ) prompt_template = """你是企业知识库助手。请根据给定的上下文材料回答问题。如果材料中没有相关信息,请直接回答“材料中未找到相关信息”,不要编造内容。 上下文材料: {context} 问题:{question} 回答(并附上相关文档来源):""" qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=vectorstore.as_retriever(search_kwargs={"k": 4}), chain_type="stuff", return_source_documents=True, chain_type_kwargs={"prompt": PromptTemplate(template=prompt_template, input_variables=["context", "question"])} ) answer = qa_chain.invoke({"query": "公司差旅报销的标准流程是什么?"}) print(answer["result"]) for doc in answer["source_documents"]: print(doc.metadata["source"], doc.metadata.get("section", ""))

return_source_documents=True必须加,不然用户没法溯源,领导只会说“这个答案是不是你编的?”。加上之后每个答案都能指出是哪份文档哪一节的内容,可信度完全不一样。

如果企业要求严谨,可以在prompt里再加一条“若上下文中存在矛盾信息,请指出”,大模型能把这个约束执行得很好。

5.4 把本地Ollama当作隐私版的LLM后端

前面提到数据出网问题。OpenAI API的确很方便,但企业内部政策往往不允许文档内容出网。我的另一个方案是用本地Ollama加载一个7B或13B的开源模型,比如qwen2.5:7bllama3.1:8b,通过OpenAI兼容接口接入LangChain:

llm = ChatOpenAI( model="qwen2.5:7b", openai_api_key="ollama", base_url="http://localhost:11434/v1", )

实测下来,7B模型在处理“基于给定的上下文总结答案”这类任务上是完全够用的。因为Prompt里的context已经提供答案素材,LLM需要的只是归纳和润色,不需要掌握大量世界知识。13B更稳,回复质量会明显提升,但速度和资源消耗也翻倍。日常小范围用,7B的性价比最高。

6. 检索质量调优与相似度阈值控制

6.1 Top-K的选择和相似度阈值

Top-K的选择直接决定答案质量。K值太小,上下文可能不完整;K值太大,会混入很多无关噪声,反而把大模型带偏。

我的经验是:

  • 文档结构规整、标题清晰:K=4最合适
  • 文档内容日常松散、碎片化严重:K=6
  • 需要答案覆盖多个维度的业务问题:K=8

控制噪声的关键是结合相似度阈值过滤。FAISS返回相似度分数,L2距离越小表示向量越接近,但不同embedding模型的分数区间差异极大。我的做法是查看具体分布再定阈值:

retriever = vectorstore.as_retriever(search_kwargs={"k": 8}) docs = retriever.invoke("差旅报销") for doc, score in zip(docs, vectorstore.similarity_search_with_score("差旅报销", k=8)): print(f"score: {score:.4f}, source: {doc.metadata['source']}")

算完之后基本能看出来:跟自己问题密切相关的片段score一般小于0.3(L2),无关的会在0.8以上。在代码里加个过滤逻辑:

docs = [doc for doc, score in zip(docs, vectorstore.similarity_search_with_score("差旅报销", k=8)) if score < 0.5]

这样就算一次取回8条文档,经过阈值过滤后留下的基本都是有效内容。有效压低幻觉。

6.2 多轮对话的支持与query改写

有了知识库之后,用户不会只问一句话,会开始追问,“那如果超过额度呢?” 这个问题字面上看跟“差旅报销”没关系,但从上下文看,它应该带着“差旅报销标准、额度”这个语境去找答案。

简单粗暴的方案是把聊天历史拼到Prompt里让LLM处理,但在检索阶段,不带上下文的query照样召回不了正确内容。最好做一步query改写:

from langchain_core.prompts import ChatPromptTemplate rewrite_prompt = ChatPromptTemplate.from_template( """根据历史对话,将用户最新的提问改写成独立且完整的检索问题。 历史对话: {chat_history} 用户新问题:{question} 改写后的检索问题:""" )

用LLM改写完再走检索,命中率提升非常显著。这个模块对体验的提升是质变级的,问“那如果超过额度呢”这种问题的时候,用户感到被理解的程度完全不一样。

6.3 元数据过滤让结果更精准

企业知识库文档一多有部门、时间、文档类型之分。LangChain的FAISS支持metadata过滤:

retriever = vectorstore.as_retriever( search_kwargs={ "k": 4, "filter": {"department": "财务部"} } )

一次性把范围限定到财务相关的文档里,不仅干扰少,召回准确率也高。FAISS实现metadata过滤的原理是先按向量召回足够多的候选,再在内存里过滤,所以在filter条件下K值可以适当加大。我用的时候通常K会调高一倍。

7. 常见报错与应急排查手册

7.1 Word相关报错的处理心得

报错现象根因解决方案
PackageNotFoundError传的是.doc老格式先用LibreOffice转成.docx
目录为空或段落全无文档里内容在文本框/图形里换用非纯文本工具或用Office VBA宏导出文本
AttributeError: 'NoneType' object has no attribute 'text'某些空段落或特殊嵌入对象导致解析时加if para.text is None: continue
表格顺序乱先单独取paras再取tables改用body级遍历,以原始顺序为准

7.2 向量库异常与依赖冲突

ImportError: cannot import name 'FAISS' from 'langchain.vectorstores'

这个问题出现的原因非常一致:装的是新版本LangChain,旧代码从langchain.vectorstores导入。0.1.x时期向量库实现还在主包里,之后被移到了langchain_community。解决方案两种:降级到0.1.x,或者改导入路径:

from langchain_community.vectorstores import FAISS
ModuleNotFoundError: No module named 'faiss'

这个反而好办,直接装faiss-cpu。千万别装faiss那个包,这俩不是一个东西。faiss是从源码编译的版本,装起来要翻车,faiss-cpu是预编译的wheel,直接装上就能用。

7.3 召回效果差不是玄学,有排查路径

如果答案质量拉胯,先别急着重训模型,按顺序排查:

  1. 检查切块大小。chunk_size=500和chunk_size=2000的检索效果天差地别。对Word类半结构化文档,切太大召回不准,切太小上下文残缺,需要调。
  2. 检查query本身。跟场景心句相近的query可以试着手动拼接几个关键词,看recall是否上升,上升说明改写模块没写好。
  3. 检查embedding模型的领域适配度。通用模型跟垂直领域模型,效果差距极大。企业法律文档可以试试law-ai/InLegalBERT这类垂直模型,医疗项目找医疗预训练模型。
  4. 检查FAISS索引的len(index)和文档数是否对得上,排除一半向量没插入的情况。

7.4 faiss离线安装与GPU/CPU版踩坑

有几次在客户的离线服务器上装faiss,先用pip download,再离线安装,发现安装时提示numpy依赖不满足SystemError。排查到最后确定,原有的numpy版本太低,不匹配faiss-cpu需要的numpy>=1.22,但是系统内其他依赖锁死了numpy版本,一升级就全崩。

这种情况建议建一个全新的虚拟环境,装完faiss-cpu后,缺什么依赖再手动补,不要去动系统环境。

CPU版和GPU版的差异也要说明白。faiss-cpu对十万级向量完全足够,纯CPU一秒钟查询几万次,没有必须上GPU的理由。只有向量量级到千万以上再考虑faiss-gpu,但那是另一个复杂度级别的题目了,涉及显存规划、分片索引,普通项目完全不需要。

8. 实战效果复盘与一点私人建议

这套系统上线之后,我处理的文档集大约是两千份Word文档,切出来四万多个Document,建好的FAISS索引文件约380MB。检索平均响应时间不到100毫秒,整条链路包括LLM生成回答在内,首字响应稳定在2秒左右。实际使用中,业务同事对回答本身还比较认可,但真正让大家产生信赖感的,是答案下面跟着的文档来源,能点击跳转原文,这个体验跟“黑盒AI”完全不同。

个人经验想说两个点。

第一,工程上做这类系统,最大的风险不是大模型选哪家,而是数据清洗环节。Word解析的坑,百分之八十都出在格式不够标准上。如果有条件推动文档规范管理,比调什么参数都有效。

第二,FAISS加LangChain这套架构确实是好,但FAISS本质是静态索引,不支持增量删除和实时更新。文档一多,重建索引的时间会越来越长。如果未来文档量继续增长,得提前考虑用Milvus Lite或者Qdrant这种支持动态更新的方案,LangChain的抽象层已经替你把迁移成本降到了最低,真到那天,半天时间就能换过去。

最后分享一个调试阶段特别好用的小技巧:在RetrievalQA跑答案之前,先用vectorstore.similarity_search_with_score单独跑一遍检索,把召回结果全部打印出来看看命中率。很多时候答案不理想,压根不是大模型的问题,而是检索阶段就没把该找的段落拿回来。检索排好时序,问题排查效率能翻一倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询