两天搭一个企业知识库问答:FastAPI + LangChain + ChromaDB 实战
上传文档 → 提问 → 大模型基于文档回答,并给出引用来源。全程本地向量库,不依赖任何云服务。
一、为什么要做这个
企业里最头疼的事之一:文档一大堆,想找个答案却翻半天。
规章制度、产品手册、FAQ 散落在各种地方;新人来了到处问老员工;老员工走了,经验就断了。
大模型出现后,这件事有了新解法——RAG(检索增强生成) :
把企业自己的文档喂给模型,让模型只基于这些文档回答问题,而不是凭记忆瞎编。
本文记录我用 FastAPI + LangChain + ChromaDB 从零搭一个企业知识库问答的过程,包含完整代码和两个真实的坑。
二、RAG 是怎么工作的
一句话:先检索到相关资料,再让模型基于资料回答。
流程五步:
1. 加载 PDF / Word / TXT → 纯文本
2. 切分 长文档切成小块(chunk)
3. 向量化 每块文本用 Embedding 模型转成向量,存入向量库
4. 检索 问题也转成向量,找出最相似的 Top-K 块
5. 生成 检索到的内容拼进 Prompt,交给大模型回答
关键点在第 3、4 步:用向量相似度来找"相关段落" ,而不是关键词匹配。所以"报销多久到账"能命中"费用报销制度"这一段。
三、技术选型
FastAPI LangChain ChromaDB DeepSeek-V3 BGE-large-zh
一个关键设计:全部走 OpenAI 兼容接口。
OpenAI / DeepSeek / 豆包 / 智谱随便换,改一个环境变量就行,代码一行不用动
OPENAI_BASE_URL=https://api.siliconflow.cn/v1
LLM_MODEL=deepseek-ai/DeepSeek-V3
EMBEDDING_MODEL=BAAI/bge-large-zh-v1.5
四、核心代码
1. 文档切分
中文和英文不同,标点、换行才是天然的语义边界。我用 RecursiveCharacterTextSplitter,并把中文标点加进分隔符
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每块最大 500 字
chunk_overlap=50, # 块之间重叠 50 字,避免切断语义
separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
)
chunks = splitter.split_text(text)
2. 向量化 + 入库
from langchain_chroma import Chroma
embeddings = OpenAIEmbeddings(
model="BAAI/bge-large-zh-v1.5",
api_key=API_KEY,
base_url=BASE_URL,
check_embedding_ctx_length=False, # ← 关键,见下面的坑
)
vectorstore = Chroma(
collection_name="knowledge_base",
embedding_function=embeddings,
persist_directory="./chroma_db", # 本地持久化
)
vectorstore.add_documents(docs)
3. 检索 + 生成
def ask(question: str) -> dict:
hits = vectorstore.similarity_search(question, k=4) # 检索 Top-4
context = "\n\n".join(f"[资料 {i+1}]\n{d.page_content}" for i, d in enumerate(hits))
prompt = ChatPromptTemplate.from_messages([
("system", "你是企业知识库助手。只根据提供的上下文回答问题;"
"如果上下文里没有答案,直接说“知识库中没有相关信息”,不要编造。"),
("human", "上下文:\n{context}\n\n问题:{question}"),
])
chain = prompt | llm
answer = chain.invoke({"context": context, "question": question}).content
return {"answer": answer, "sources": [d.metadata["source"] for d in hits]}
注意 system prompt 里那句"不要编造"——这是 RAG 的灵魂。否则模型会在检索不到时一本正经地胡说八道。
4. FastAPI 接口
async def upload(file: UploadFile = File(...)):
text = await load_upload(file)
n = ingest_text(text, metadata={"source": file.filename})
return {"filename": file.filename, "chunks": n}
@app.post("/api/ask")
def ask_endpoint(req: AskRequest):
return ask(req.question)
前端就是一个单页 HTML,用 fetch 调这两个接口,零构建。
五、踩坑记录(重点看这里)
坑 1:LangChain 1.x 把包拆了
很多旧教程里的写法,在 LangChain 1.x 已经跑不通了:
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import Chroma
# ✅ 新写法:text splitters 和 chroma 都是独立包
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
装依赖时要记得:pip install langchain-text-splitters langchain-chroma。
坑 2:check_embedding_ctx_length 默认会把文本变成 token ID
这是最隐蔽的一个坑,报错信息还特别误导人。
现象:调用 embedding 接口一直返回:
{"code": 20015, "message": "The parameter is invalid. Please check again."}
查了半天,参数明明都对。最后发现是 OpenAIEmbeddings 有个默认参数 check_embedding_ctx_length=True:
它会为了计算 token 长度,先把文本编码成一串 token 数字再发给接口。
而 OpenAI 官方接口能接受这种 token 数组,但 SiliconFlow、DeepSeek 等国产 OpenAI 兼容接口只接受原始文本,于是直接 400。
解法就一行:
OpenAIEmbeddings(..., check_embedding_ctx_length=False)
六、效果
问: 报销流程是什么?
答: 1. 在 OA 填写报销单,上传发票照片;2. 直属上级审批;3. 财务审核发票真伪与合规性;4. 出纳打款到员工工资卡。(每月 1-5 日集中受理,审批通过后 10 个工作日内到账)
来源: 公司规章制度.md
答案准确,且带来源。
七、完整代码
项目已开源,含完整代码、示例文档、测试和 CI:
八、后续计划
- 流式输出(SSE)
- 混合检索(向量 + 关键词)
- 引用原文高亮
- Docker 一键部署
- 多轮对话
如果你也在做 LLM 应用落地,欢迎交流。