====== 第十三章:项目实战 - 企业知识库问答系统 ======
前面的章节已经讲清楚了模型、提示词、消息、检索、结构化输出和状态管理。从本章开始,我们把这些能力真正拼起来,做一个最具代表性的落地项目:**企业知识库问答系统**。
之所以把它作为第一个完整项目,是因为它几乎覆盖了 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")
这个脚本后续可以接到:
* 定时任务;
* 管理后台按钮;
* 文档上传流程。