前言 前面那篇 RAG 入门讲了基本原理和最小 Demo。这篇从实际部署的角度,做一个能真正给团队用的文档问答机器人。
完整的流程包括:多种格式文档导入 → 智能切片 → 向量化存储 → 检索优化 → 大模型回答 → Web 界面。
一、项目架构
flowchart TD
subgraph Ingestion["文档导入与切片 (离线/后台)"]
D["企业多源文档 (PDF / Word / MD / TXT)"] --> L["多格式文档加载器 (Doc Loader)"]
L --> S["语义与结构智能切片器 (Text Splitter)"]
S --> E["Embedding 模型 (OpenAI / BGE)"]
E --> V[("向量数据库 ChromaDB")]
S --> B["BM25 词频倒排索引"]
end
subgraph Retrieval["混合检索与重排序 (实时在线)"]
Q["用户提问 Query"] --> VR["向量语义检索 (Top 10)"]
Q --> BR["BM25 关键词检索 (Top 10)"]
V -.-> VR
B -.-> BR
VR --> Fuse["RRF 倒数秩融合 (Ensemble)"]
BR --> Fuse
Fuse --> RR["Cross-Encoder Rerank 重排序"]
RR --> Filter["Top 3 精准切片上下文"]
end
subgraph Generation["大语言模型生成"]
Filter --> Prompt["构建结构化 Prompt (Context + Question)"]
Q --> Prompt
Prompt --> LLM["大语言模型 (DeepSeek / Qwen)"]
LLM --> UI["Gradio Web 问答界面 (带精确文档引用)"]
end
二、环境与依赖 1 2 3 4 pip install langchain langchain-community langchain-openai pip install chromadb pypdf docx2txt unstructured rank-bm25 pip install flashrank pip install gradio
三、文档加载(支持多种格式)
避坑注意 :不同文档 Loader 的初始化参数差异很大。例如 PyPDFLoader 与 Docx2txtLoader 是通过底层二进制解析库提取内容的,不能 像文本加载器那样传入 encoding="utf-8",否则会直接抛出 TypeError: __init__() got an unexpected keyword argument 'encoding'。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import osfrom langchain_community.document_loaders import ( TextLoader, PyPDFLoader, UnstructuredMarkdownLoader, Docx2txtLoader, CSVLoader, UnstructuredHTMLLoader ) def load_all_docs (docs_dir ): """加载目录下所有支持的文档格式(安全适配各格式 Loader)""" all_docs = [] for filename in os.listdir(docs_dir): filepath = os.path.join(docs_dir, filename) if not os.path.isfile(filepath): continue ext = os.path.splitext(filename)[1 ].lower() try : if ext == ".txt" : loader = TextLoader(filepath, encoding="utf-8" ) elif ext == ".pdf" : loader = PyPDFLoader(filepath) elif ext == ".docx" : loader = Docx2txtLoader(filepath) elif ext == ".csv" : loader = CSVLoader(filepath, encoding="utf-8" ) elif ext == ".md" : loader = UnstructuredMarkdownLoader(filepath) elif ext == ".html" : loader = UnstructuredHTMLLoader(filepath) else : continue docs = loader.load() for doc in docs: doc.metadata["source" ] = filename all_docs.extend(docs) print (f"[OK] {filename} : 成功加载 {len (docs)} 个原始段落" ) except Exception as e: print (f"[FAIL] {filename} 加载失败: {e} " ) return all_docs
四、智能切片 不同的文档类型需要不同的切片策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 from langchain.text_splitter import RecursiveCharacterTextSplitterdef create_splitters (): """根据文档类型返回不同的切片器""" return { "default" : RecursiveCharacterTextSplitter( chunk_size=800 , chunk_overlap=150 , separators=["\n\n" , "\n" , "。" , ";" , ". " , " " ] ), "code" : RecursiveCharacterTextSplitter( chunk_size=1500 , chunk_overlap=100 , separators=["\n\n" , "\n" , " " , "" ] ), } def smart_split (documents ): """智能切片:对不同类型的文档用不同策略""" splitters = create_splitters() all_chunks = [] for doc in documents: source = doc.metadata.get("source" , "" ) if any (ext in source for ext in [".py" , ".js" , ".java" , ".go" , ".ts" ]): splitter = splitters["code" ] else : splitter = splitters["default" ] chunks = splitter.split_documents([doc]) all_chunks.extend(chunks) return all_chunks
五、向量化 + 混合检索 + Rerank 重排序 纯向量检索容易受语义宽泛干扰,而 BM25 只能匹配字面。工业级最佳实践是:BM25 + 向量检索初步召回(Top 10)→ Rerank 重排序模型交叉注意力精排(Top 3) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 from langchain_community.vectorstores import Chromafrom langchain_openai import OpenAIEmbeddingsfrom langchain.retrievers import EnsembleRetrieverfrom langchain_community.retrievers import BM25Retrieverfrom langchain.retrievers import ContextualCompressionRetrieverfrom langchain.retrievers.document_compressors import FlashrankRerankembeddings = OpenAIEmbeddings(model="text-embedding-3-small" ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db" ) vector_retriever = vectorstore.as_retriever(search_kwargs={"k" : 10 }) bm25_retriever = BM25Retriever.from_documents(chunks) bm25_retriever.k = 10 ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever], weights=[0.4 , 0.6 ] ) compressor = FlashrankRerank(top_n=3 ) final_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=ensemble_retriever )
六、生成回答(现代 LCEL 架构) 在 LangChain 0.2+ 中,官方已弃用旧版 RetrievalQA,推荐使用清晰声明式的 LCEL(LangChain Expression Language) 管道构建问答链:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 from langchain_openai import ChatOpenAIfrom langchain_core.prompts import ChatPromptTemplatefrom langchain.chains.combine_documents import create_stuff_documents_chainfrom langchain.chains import create_retrieval_chainllm = ChatOpenAI( model="deepseek-chat" , temperature=0.2 , openai_api_key="sk-xxx" , base_url="https://api.deepseek.com" ) system_prompt = ( "你是一个严谨的企业内部文档智能助手。请严格根据提供的参考文档回答用户问题。\n" "【规则】:\n" "1. 如果参考文档中没有相关事实,请直接回答:'抱歉,在现有内网知识库文档中未找到相关说明。',严禁胡编乱造!\n" "2. 保持回答客观、专业、条理清晰。\n\n" "参考文档片段:\n{context}" ) prompt = ChatPromptTemplate.from_messages([ ("system" , system_prompt), ("human" , "{input}" ), ]) question_answer_chain = create_stuff_documents_chain(llm, prompt) rag_chain = create_retrieval_chain(final_retriever, question_answer_chain) def ask (question: str ): """执行检索与生成,返回回答和去重后的引用源""" response = rag_chain.invoke({"input" : question}) answer = response["answer" ] sources = list ({doc.metadata.get("source" , "未知文档" ) for doc in response["context" ]}) return answer, sources
七、Gradio Web 界面 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import gradio as grdef chat_fn (message, history ): if not message.strip(): return history, "" answer, sources = ask(message) if sources: sources_str = "、" .join([f"`{s} `" for s in sources]) formatted_answer = f"{answer} \n\n> 📚 **参考知识库文档**:{sources_str} " else : formatted_answer = answer history.append({"role" : "user" , "content" : message}) history.append({"role" : "assistant" , "content" : formatted_answer}) return history, "" with gr.Blocks(title="企业内网知识库问答" ) as demo: gr.Markdown("# 🏢 企业内网知识库智能问答机器人" ) gr.Markdown("基于混合检索(BM25 + 向量)与 Flashrank Rerank 重排序引擎构建。" ) chatbot = gr.Chatbot(type ="messages" , height=520 ) msg = gr.Textbox(placeholder="请输入您的问题(支持制度查询、技术方案、产品规格等)..." , label="输入问题" ) with gr.Row(): submit_btn = gr.Button("发送提问" , variant="primary" ) clear_btn = gr.Button("清空对话" ) msg.submit(chat_fn, [msg, chatbot], [chatbot, msg]) submit_btn.click(chat_fn, [msg, chatbot], [chatbot, msg]) clear_btn.click(lambda : [], None , chatbot) demo.launch(server_name="0.0.0.0" , server_port=7860 )
八、效果调优清单 在实际项目部署后,按这个顺序调优:
调切片参数 :如果你的文档段落很短或者表格很多,chunk_size 需要调小
调检索数量 k :太多了 prompt 会超长,太少了信息不够。从 k=4 开始调
调混合检索权重 :如果你的文档术语多、关键词重要,调大 BM25 的权重
加 Reranker :对检索结果做二次排序(如 bge-reranker-v2-m3),能明显提升准确率
调 Prompt :加上”如果不知道就说不知道”这一句能大幅减少幻觉
监控反馈 :记录用户点了”赞”还是”踩”,回头分析那些”踩”的问题
以上代码组装起来就是一个完整的文档问答机器人。把 docs/ 目录换成你自己的文档,改一下 LLM 的 API Key,就能跑起来了。