====== 第十三章:项目实战 - 企业知识库问答系统 ====== 前面的章节已经讲清楚了模型、提示词、消息、检索、结构化输出和状态管理。从本章开始,我们把这些能力真正拼起来,做一个最具代表性的落地项目:**企业知识库问答系统**。 之所以把它作为第一个完整项目,是因为它几乎覆盖了 LangChain 落地最核心的能力: * 文档接入; * 文本切分; * Embedding 与向量索引; * 检索增强生成; * 引用与防幻觉; * 会话上下文; * 基础评估与上线治理。 本章不会只讲概念,而是按“项目目标 -> 目录结构 -> 数据处理 -> 检索链 -> 接口封装 -> 评估优化”的顺序,构建一个可以继续扩展的工程骨架。 ===== 13.1 项目目标 ===== 一个企业知识库问答系统,至少要满足以下要求: * 员工提问时,不必翻大量制度文件; * 回答必须基于企业内部资料,而不是模型自己猜; * 最好能显示依据来源; * 如果资料不足,要明确拒答; * 后续可以接 Web 页面、机器人或办公门户。 ===== 13.2 第一版系统范围 ===== 为了避免一开始就做成“大而全”的系统,建议第一版只做这些能力: * 支持导入本地 Markdown / TXT / PDF 文档; * 支持按主题切分和向量化; * 支持自然语言检索问答; * 支持引用来源展示; * 支持资料不足时拒答。 第一版先不要引入 Agent。因为对知识库问答系统来说,稳定检索和引用正确,往往比“会不会调用工具”更重要。 ===== 13.3 推荐目录结构 ===== enterprise-rag/ ├── app/ │ ├── main.py │ ├── chains.py │ ├── prompts.py │ ├── retriever.py │ ├── ingestion.py │ └── settings.py ├── data/ │ ├── raw/ │ └── processed/ ├── vectorstore/ ├── tests/ │ ├── test_ingestion.py │ └── test_rag_chain.py └── requirements.txt 这样的结构有几个优点: * 数据处理与在线问答解耦; * Prompt 独立管理; * 检索逻辑可单独替换; * 后期更容易接 API 服务。 ===== 13.4 数据接入与预处理 ===== ==== 13.4.1 一个简单的文档导入脚本 ==== from pathlib import Path from langchain_community.document_loaders import DirectoryLoader, TextLoader def load_kb_documents(data_dir: str): loader = DirectoryLoader( data_dir, glob="**/*.md", loader_cls=TextLoader, show_progress=True, ) return loader.load() docs = load_kb_documents("./data/raw") print(f"Loaded {len(docs)} documents") ==== 13.4.2 预处理要做什么 ==== 导入之后,不要立刻切分。建议先处理: * 去除页眉页脚; * 去除重复空行; * 给每份文档补充 metadata; * 补充文档类别、版本号、生效日期。 def normalize_document(doc): text = doc.page_content text = text.replace(" ", " ") text = " ".join(line.strip() for line in text.splitlines() if line.strip()) doc.page_content = text doc.metadata.setdefault("doc_type", "policy") doc.metadata.setdefault("source", "unknown") return doc normalized_docs = [normalize_document(doc) for doc in docs] ===== 13.5 文本切分与索引 ===== ==== 13.5.1 切分器配置 ==== from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=600, chunk_overlap=100, separators=[" ", " ", "。", ",", " ", ""] ) chunks = splitter.split_documents(normalized_docs) print(f"Total chunks: {len(chunks)}") ==== 13.5.2 写入向量库 ==== from langchain_openai import OpenAIEmbeddings from langchain_chroma import Chroma embeddings = OpenAIEmbeddings(model="text-embedding-3-large") vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./vectorstore/chroma" ) print("Vector store created") ===== 13.6 构建检索器 ===== def build_retriever(vectorstore): return vectorstore.as_retriever( search_kwargs={"k": 4} ) retriever = build_retriever(vectorstore) 如果你的文档 metadata 较完整,还可以加过滤: finance_retriever = vectorstore.as_retriever( search_kwargs={ "k": 4, "filter": {"department": "finance"} } ) ===== 13.7 设计问答 Prompt ===== 对于企业知识库问答,Prompt 不应只追求“回答自然”,而要优先强调: * 只依据资料回答; * 资料不足时拒答; * 输出引用; * 不要把通用常识当企业制度。 from langchain_core.prompts import ChatPromptTemplate rag_prompt = ChatPromptTemplate.from_template(""" 你是企业知识库助手。 请严格依据提供的资料回答问题,不得虚构制度、日期或流程。 如果资料不足,请明确回答“未找到足够依据”。 请按以下格式回答: 1. 直接答案 2. 依据说明 3. 引用来源 问题:{question} 资料: {context} """) ===== 13.8 用 LCEL 构建问答链 ===== from langchain_openai import ChatOpenAI from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser def format_docs(docs): return " ".join( f"[来源: {doc.metadata.get('source', 'unknown')}] {doc.page_content}" for doc in docs ) model = ChatOpenAI(model="gpt-4o-mini", temperature=0) rag_chain = ( { "context": retriever | format_docs, "question": RunnablePassthrough(), } | rag_prompt | model | StrOutputParser() ) answer = rag_chain.invoke("差旅报销一般多久到账?") print(answer) ===== 13.9 增加引用返回 ===== 实际业务里,常见需求是不仅返回答案,还要把命中的文档片段一起带回前端。 def ask_with_sources(question: str): docs = retriever.invoke(question) context = format_docs(docs) answer = (rag_prompt | model | StrOutputParser()).invoke({ "question": question, "context": context, }) return { "answer": answer, "sources": [ { "source": doc.metadata.get("source", "unknown"), "preview": doc.page_content[:180], } for doc in docs ] } result = ask_with_sources("发票抬头写错了还能报销吗?") print(result["answer"]) print(result["sources"]) 这样前端就能展示: * 主答案; * 命中文档来源; * 引用内容摘要。 ===== 13.10 加入会话历史 ===== 企业问答常常不是单轮的。比如: * 用户先问报销多久到账; * 再问如果发票抬头错误怎么办; * 再追问金额超标如何处理。 这时就要引入消息历史。 from langchain_core.prompts import MessagesPlaceholder from langchain_core.messages import HumanMessage, AIMessage chat_rag_prompt = ChatPromptTemplate.from_messages([ ("system", "你是企业知识库助手,仅依据提供资料回答。"), MessagesPlaceholder("history"), ("human", "问题:{question} 资料:{context}") ]) history = [ HumanMessage(content="差旅报销多久到账?"), AIMessage(content="通常在审批通过后的 3 个工作日内到账。") ] ===== 13.11 封装为函数或 API ===== 一个更像样的项目,不应把链直接散落在脚本里,而应封装成服务接口。 class EnterpriseRAGService: def __init__(self, retriever, model, prompt): self.retriever = retriever self.model = model self.prompt = prompt def ask(self, question: str): docs = self.retriever.invoke(question) context = format_docs(docs) answer = (self.prompt | self.model | StrOutputParser()).invoke({ "question": question, "context": context, }) return { "answer": answer, "documents": docs, } 之后你可以在 FastAPI、Flask 或 Streamlit 中调用这个服务。 ===== 13.12 质量提升方向 ===== 第一版上线后,常见优化路线包括: * 按文档类型做不同切分策略; * 引入多查询检索; * 引入重排; * 对旧文档做版本治理; * 增加“无答案”检测; * 构建评测集。 ===== 13.13 一个简单的评测脚本 ==== test_cases = [ { "question": "差旅报销多久到账?", "must_include": ["3 个工作日"], }, { "question": "试用期员工可以申请差旅报销吗?", "must_include": ["未找到足够依据"], }, ] for case in test_cases: answer = rag_chain.invoke(case["question"]) passed = all(keyword in answer for keyword in case["must_include"]) print(case["question"], passed) 这虽然只是最粗糙的评测,但已经比“凭感觉好像还行”强很多。 ===== 13.14 本章小结 ===== 企业知识库问答系统最重要的不是“说得像不像人”,而是: * 资料对不对; * 引用稳不稳; * 没依据时能不能拒答; * 能否持续维护和迭代。 ===== 练习 ===== 1. 为你的业务场景列出第一批最适合入库的 20 份文档。 2. 实现一个从本地目录导入、切分并写入 Chroma 的脚本。 3. 用 LCEL 构建一个最小 RAG 链,并返回引用来源。 4. 增加一个“资料不足时拒答”的测试样例。 5. 设计一个 API 输出格式,包含答案、来源、trace_id 和耗时。 ===== 13.15 补充案例:用 FastAPI 暴露问答接口 ===== from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() rag_service = EnterpriseRAGService(retriever=retriever, model=model, prompt=rag_prompt) class AskRequest(BaseModel): question: str @app.post("/ask") def ask(req: AskRequest): result = rag_service.ask(req.question) return { "answer": result["answer"], "sources": [doc.metadata for doc in result["documents"]], } ===== 13.16 补充案例:知识库更新脚本 ===== 在实际项目中,知识库不会只建一次。你需要定期更新向量索引: def rebuild_index(data_dir: str, persist_dir: str): docs = load_kb_documents(data_dir) docs = [normalize_document(doc) for doc in docs] chunks = splitter.split_documents(docs) Chroma.from_documents( documents=chunks, embedding=OpenAIEmbeddings(model="text-embedding-3-large"), persist_directory=persist_dir, ) print("Index rebuilt") 这个脚本后续可以接到: * 定时任务; * 管理后台按钮; * 文档上传流程。