两天搭一个企业知识库问答:FastAPI + LangChain + ChromaDB 实战

4 阅读4分钟

两天搭一个企业知识库问答: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:

👉 github.com/zxd-sudo/ra…

八、后续计划

  • 流式输出(SSE)
  • 混合检索(向量 + 关键词)
  • 引用原文高亮
  • Docker 一键部署
  • 多轮对话

如果你也在做 LLM 应用落地,欢迎交流。