Quivr Core + Chainlit 实战:用几十行代码搭建本地文档问答 Chatbot
本篇指南基于仓库中 chainlit.md 与 main.py 示例,讲解如何用 quivr-core 库和 Chainlit 快速搭一个"上传文档即可问答"的 RAG Chatbot。读完后你将掌握完整的安装与运行步骤,并能读懂示例背后 Brain 的建库流程、默认的向量库/Embedding/LLM 组件,以及 QuivrQARAG 的流式回答链路,从而把这个最小示例迁移到自己的产品集成场景中。
示例定位与整体结构
该示例位于 backend/core/examples/chatbot 目录,是一个面向 quivr-core 库(而非完整 Quivr 后端服务)的独立小项目,仅包含四个文件:
- chainlit.md / README.md:使用说明文档;
- main.py:Chainlit 应用入口,全部核心逻辑约 60 行;
- requirements.txt:依赖锁定文件。
整体思路是:用户通过 Chainlit 提供的 Web 界面上传一个 .txt 文件,程序用 quivr_core.Brain.from_files 把文件解析、切块、向量化并构建内存向量库(即"大脑"),之后所有提问都通过该大脑做 RAG 检索 + 生成,回答以流式 token 形式返回。
环境要求与安装
前置条件:Python 3.8 及以上(文档明确标注)。
进入示例目录并安装依赖:
cd backend/core/examples/chatbot
pip install -r requirements.txt
当前仓库的 requirements.txt 锁定为两个精确版本:
quivr-core[base]==0.0.8
chainlit==1.1.306
这里有两个值得注意的点:
quivr-core[base]的base额外依赖不是可选项。从 brain_defaults.py 的源码可以看到,默认的向量库(FAISS)、默认 Embedder(OpenAIEmbeddings)和默认 LLM(ChatOpenAI)都通过try: from langchain_community... / from langchain_openai...动态导入,缺包时会抛出ImportError,并提示 "Please provide a valid vector store or install quivr-core['base'] package"。也就是说,不装base组件就必须自己传vector_db/embedder/llm参数,否则建脑直接失败。- 示例锁定的是 PyPI 上的 0.0.8 版本,与仓库
backend/core目录中的最新源码可能存在 API 差异(例如当前源码中Brain的构造签名、ask_streaming的返回结构)。本文的源码分析以仓库当前源码为准,作为理解机制的依据;若以 0.0.8 版本运行,行为细节以该版本为准。
由于默认 Embedder 与 LLM 都是 OpenAI 组件,运行前需要设置 OPENAI_API_KEY 环境变量(LLMEndpointConfig.llm_api_key 为空时,ChatOpenAI 会回退读取该环境变量)。
运行与使用流程
启动 Chainlit 服务:
chainlit run main.py
然后打开终端显示的地址(通常是 http://localhost:8000)。使用步骤与文档一致:
- 界面加载后,机器人会提示你上传一个
.txt文本文件; - 在上传区选择一个
.txt文件,大小不得超过 20MB(该限制来自代码中的max_size_mb=20); - 上传完成后,机器人会先发送 "Processing ..." 消息,处理完成(文件解析 + 向量化 + 建库)后更新为 "You can now ask questions!";
- 之后在输入框提问即可,回答基于上传文件内容生成。
源码走读:main.py 的两个生命周期钩子
Chainlit 应用的全部逻辑由两个装饰器函数构成。
on_chat_start:等待上传并建脑
main.py 中的关键片段:
@cl.on_chat_start
async def on_chat_start():
files = None
# Wait for the user to upload a file
while files is None:
files = await cl.AskFileMessage(
content="Please upload a text .txt file to begin!",
accept=["text/plain"],
max_size_mb=20,
timeout=180,
).send()
file = files[0]
...
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as temp_file:
temp_file.write(text)
...
brain = Brain.from_files(name="user_brain", file_paths=[temp_file_path])
cl.user_session.set("brain", brain)
几个设计细节:
cl.AskFileMessage的参数与文档中的使用规则一一对应:accept=["text/plain"]决定只接受纯文本,max_size_mb=20就是文档所说的 20MB 上限,timeout=180给上传预留 3 分钟;- Chainlit 上传的文件对象自带
path,代码按 UTF-8 读取全文后重新写入一个delete=False的临时文件再交给Brain.from_files。这样做的效果是:传给大脑的文件始终是一个带.txt后缀的磁盘文件(suffix=".txt"),保证后续的文件处理器能按扩展名路由; cl.user_session.set("brain", brain)把 Brain 实例挂到会话上,实现"每个浏览器会话独立一份大脑"——两个不同用户分别上传不同文件时互不干扰;- 处理前后复用同一个
cl.Message,通过msg.update()把 "Processing" 就地更新为 "done",避免聊天流里堆两条系统消息。
on_message:流式问答
@cl.on_message
async def main(message: cl.Message):
brain = cl.user_session.get("brain")
if brain is None:
await cl.Message(content="Please upload a file first.").send()
return
msg = cl.Message(content="")
await msg.send()
async for chunk in brain.ask_streaming(message.content):
await msg.stream_token(chunk.answer)
await msg.send()
这里 brain.ask_streaming 返回一个异步生成器,每块是带 answer 字段的 ParsedRAGChunkResponse;Chainlit 侧用 msg.stream_token 逐块追加,实现打字机式的流式输出。如果用户在未上传文件时直接提问,会话里取不到 brain,会收到 "Please upload a file first." 的引导提示。
Brain 建脑管线:from_files 内部做了什么
Brain.from_files(name="user_brain", file_paths=[...]) 只是入口。从 brain.py 的源码看,同步版 from_files(L169-L192)内部通过事件循环调用异步版 afrom_files,后者按四步完成建脑:
- 文件入库:对每个路径调用
load_qfile(brain_id, path)生成文件实体,再storage.upload_file(file)存入 storage(默认是TransparentStorage,即不落盘、仅内存中转); - 解析成 Document:
process_files按文件扩展名从处理器注册表get_processor_class(file_extension)取出对应的 processor(txt 走文本处理器),输出 LangChainDocument列表(即切块后的知识块); - 构建向量库:未显式传入
vector_db时调用build_default_vectordb(docs, embedder),用 FAISS 一次性afrom_documents完成 embedding 与索引; - 组装 Brain:绑定
llm、embedder、vector_db、storage,并初始化一个默认ChatHistory会话——后续所有问答都挂在这个默认会话上,多轮上下文由此维护。
也就是说,文档里"chatbot uses the Quivr library to create a 'brain' from the uploaded text file"这句话的完整含义是:文本解析 → 分块 → 向量化 → FAISS 内存索引 → 绑定 LLM 与会话历史。
默认组件与可调参数
brain_defaults.py 定义了三项默认值:
| 组件 | 默认实现 | 源码位置 |
|---|---|---|
| 向量库 | FAISS(CPU,FAISS.afrom_documents) |
build_default_vectordb |
| Embedder | OpenAIEmbeddings |
default_embedder |
| LLM | LLMEndpoint.from_config(LLMEndpointConfig()),即 ChatOpenAI |
default_llm |
LLM 的默认参数在 config.py 中:
class LLMEndpointConfig(BaseModel):
model: str = "gpt-3.5-turbo-0125"
llm_base_url: str | None = None
llm_api_key: str | None = None
max_input: int = 2000
max_tokens: int = 2000
temperature: float = 0.7
streaming: bool = True
llm_base_url 可指向任意 OpenAI 兼容端点(本地 Ollama、Groq 等),这是替换模型的最小改动点。RAG 侧还有 RAGConfig:max_history=10(最多回看 10 轮对话)、max_files=20、prompt(可选的系统提示/角色设定)。示例代码没有显式传 rag_config,ask_streaming 会用 Brain 自身 LLM 的配置构造默认的 RAGConfig(见 brain.py)。
回答链路:QuivrQARAG 的流式 RAG
每次提问时,Brain.ask_streaming 会构造 QuivrQARAG 管道,build_chain(L94-L143)串起了四个环节:
- 历史裁剪
filter_history:按"1 token ≈ 4 字符"的粗估和max_history上限,只保留最近的若干轮 Human/AI 消息对,控制上下文长度; - 问题改写
standalone_question:用CONDENSE_QUESTION_PROMPT把带指代/省略的追问改写成独立问题(例如"它多少钱" → "XX 多少钱"),再拿去检索; - 检索:改写后的问题经过
ContextualCompressionRetriever从 FAISS 向量库取文档(retriever属性即vector_store.as_retriever()); - 生成:把检索上下文拼进
ANSWER_PROMPT交给 LLM;若模型支持函数调用(supports_func_calling()),会绑定cited_answer工具强制"先引用来源再回答"(tool_choice="any")。
流式版本 answer_astream(L165-L234)对该 chain 做 astream:支持函数调用的模型会做增量 diff 只 yield 新增部分,最后再补一个 last_chunk=True 的块携带元数据。Brain.ask_streaming(brain.py)把这些块逐个 yield 给 Chainlit,同时把完整的 Human/AI 消息追加进 default_chat,供下一轮 filter_history 使用——这就是示例能进行多轮对话的原因。
适用前提与限制
- 模型与密钥:默认链路(OpenAI Embeddings + ChatOpenAI)要求
OPENAI_API_KEY;换本地模型时需在Brain.from_files中显式传入自定义llm/embedder,因为默认的default_llm()/default_embedder()只认 OpenAI 兼容客户端; - 文件类型:Chainlit 端
accept=["text/plain"]+ 临时文件后缀.txt决定了该示例面向纯文本;其他格式需要放宽这两处并依赖对应 processor; - 规模:FAISS 向量库与
TransparentStorage都在进程内存中,脑随会话结束而销毁,适合单文件、中小语料的演示与集成原型,不适合持久化多文档服务; - 版本:示例依赖锁定
quivr-core==0.0.8,与仓库当前源码的 API 细节可能有差异,以你实际安装的版本行为为准。
小结
这个示例的价值在于用最少的代码展示了 quivr-core 的完整 RAG 闭环:Chainlit 负责交互(AskFileMessage 上传、stream_token 流式输出),Brain 负责解析、向量库与多轮会话,QuivrQARAG 负责"改写问题 → 检索 → 引用式生成"。把 main.py 中的 Brain.from_files 换成你自己的文件列表或自定义 llm/vector_db,就能得到一个可直接嵌入产品的文档问答基线。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00