从零构建轻量级 RAG 知识库问答系统:多供应商适配 + 答案溯源实战
本文完整记录了一个轻量级 RAG(检索增强生成)知识库问答系统的设计与实现过程。从文档上传到智能问答,从多供应商模型适配到答案引用溯源,带你一站式跑通"上传 → 检索 → 问答 → 溯源"全链路。零外部中间件,支持本地 Ollama 零成本运行。
一、为什么需要又一个 RAG 系统?
市面上的知识库方案不少,但落地时常遇到这些痛点:
| 痛点 | 典型方案的问题 |
|---|---|
| 部署太重 | 动辄 MySQL + Redis + ES + Milvus + K8s,验证个想法就得搭一套集群 |
| 模型锁定 | 绑定单一 LLM 供应商,换模型要改一堆代码 |
| 答案不可溯源 | AI 回答了,但不知道依据哪段原文,无法核实 |
| 本地化困难 | 敏感文档不能发云端 API,但本地部署门槛高 |
基于这些痛点,我设计并实现了KB-MVP——一个轻量到python -m app.main就能跑起来的 RAG 知识库系统,核心目标:
- 零外部中间件:向量存储用 numpy + JSON,不装数据库
- 多供应商模型适配:LLM 和 Embedding 都能灵活切换,改配置不改代码
- 答案可溯源:答案中的
[资料N]是可点击链接,点击直达原文并高亮 - 本地化优先:通过 Ollama 可完全本地运行,零 API 费用
二、整体架构设计
┌──────────────────────────────────────────────────────────────┐ │ 浏览器(前端单页) │ │ 文档上传 · 对话问答 · [资料N] 引用点击溯源 · 文档预览高亮 │ └────────────────────────────┬─────────────────────────────────┘ │ HTTP / SSE ▼ ┌──────────────────────────────────────────────────────────────┐ │ FastAPI 应用(单进程) │ │ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ │ 静态资源 │ │ API 路由层 │ │ RAG 引擎 │ │ │ │ 托管前端 │ │ upload/ask/ │ │ 检索→构造Prompt→生成 │ │ │ └──────────┘ └──────┬───────┘ └───────────┬────────────┘ │ │ │ │ │ │ ┌───────────────┼──────────────────────┘ │ │ ▼ ▼ │ │ ┌─────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ │文档解析 │ │ 向量存储 │ │ 模型适配层(通用) │ │ │ │PDF/Word │ │ numpy+JSON │ │ LLM + Embedding │ │ │ └─────────┘ └──────────────┘ └──────────┬───────────┘ │ └──────────────────────────────────────┼───────────────────────┘ │ ┌──────────────────────────┼──────────────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ DashScope API │ │ DeepSeek / GLM │ │ Ollama 本地 │ │ 通义千问 LLM/Emb │ │ OpenAI 兼容 API │ │ LLM / Embedding│ └─────────────────┘ └──────────────────┘ └─────────────────┘技术栈一览:
| 层次 | 选型 | 选型理由 |
|---|---|---|
| 后端 | Python + FastAPI | 异步高性能,SSE 流式支持好 |
| 向量存储 | numpy + JSON | 零依赖,万级向量瞬时检索 |
| 文档解析 | PyMuPDF + python-docx | PDF/Word 解析成熟 |
| 模型适配 | dashscope SDK + openai SDK | 覆盖多供应商 |
| 前端 | 原生 HTML/CSS/JS | 零构建,单文件 |
三、核心实现解析
3.1 多供应商模型适配层:开闭原则实战
问题:不同 LLM 供应商(通义千问、DeepSeek、GLM、Ollama)的 SDK 各不相同,如何做到切换供应商只改配置不改代码?
方案:抽象基类 + 工厂模式,符合开闭原则。
# llm_provider.pyclassBaseLLM(ABC):"""LLM 适配器抽象基类,定义统一接口。"""@property@abstractmethoddefprovider_name(self)->str:...@property@abstractmethoddefmodel(self)->str:...@abstractmethoddefchat_stream(self,messages:List[dict])->Generator[str,None,None]:"""流式生成:输入消息列表,逐 token 返回。"""...classDashScopeLLM(BaseLLM):"""通义千问:原生 SDK,流式 + 增量输出。"""defchat_stream(self,messages):importdashscope responses=dashscope.Generation.call(model=self._model,messages=messages,stream=True,incremental_output=True,)forrespinresponses:ifresp.status_code==200:yieldresp.output.choices[0].message.contentclassOpenAICompatibleLLM(BaseLLM):"""OpenAI 兼容适配器:一套代码覆盖 DeepSeek / GLM / Ollama。"""def__init__(self,provider,model,api_key,api_base):fromopenaiimportOpenAI# Ollama 本地无需 Key,用占位符满足 SDK 要求self._client=OpenAI(api_key=api_keyor"ollama",base_url=api_base,)defchat_stream(self,messages):stream=self._client.chat.completions.create(model=self._model,messages=messages,stream=True,)forchunkinstream:ifchunk.choices:delta=chunk.choices[0].delta.contentifdelta:yielddeltadefcreate_llm()->BaseLLM:"""工厂函数:根据配置创建适配器。"""provider=config.LLM_PROVIDERifprovider=="dashscope":returnDashScopeLLM()ifproviderin("deepseek","glm","ollama","openai_compatible"):returnOpenAICompatibleLLM(...)raiseValueError(f"不支持的 LLM 提供商:{provider}")设计要点:
BaseLLM定义统一接口chat_stream,所有供应商实现同一契约OpenAICompatibleLLM是关键——DeepSeek、GLM、Ollama 都兼容 OpenAI API,一个类搞定三个供应商- 工厂函数
create_llm()根据配置实例化,新增供应商只需实现子类 + 注册工厂 - Embedding 适配层(
embedding_provider.py)同理,支持线上 API 与 Ollama 本地模型灵活切换
切换供应商只需改.env:
# 从通义千问切换到 DeepSeek + Ollama 本地向量化LLM_PROVIDER=deepseekLLM_MODEL=deepseek-chatLLM_API_KEY=sk-xxxxxxxxEMBEDDING_PROVIDER=ollamaEMBEDDING_MODEL=nomic-embed-text3.2 RAG 检索增强问答:SSE 流式输出
问题:RAG 问答需要先检索再生成,如何让用户尽快看到回答(而不是等 LLM 生成完才返回)?
方案:SSE(Server-Sent Events)流式输出,先推送来源列表,再逐 token 推送答案。
# rag_engine.pyclassRAGEngine:defask(self,question:str)->Tuple[Generator,List[dict]]:"""问答:返回(流式答案, 来源列表)。"""# 1. 检索 Top-K 相关片段(含 chunk_id 与 locator 定位信息)results=self.vector_store.search(question,self.top_k)# 2. 构造来源摘要(携带 locator,供前端溯源高亮)sources=[{"doc_id":r["doc_id"],"doc_name":r["doc_name"],"chunk_id":r["chunk_id"],"score":round(r["score"],4),"locator":r["locator"],# 页码/段落/行号/章节}forrinresults]# 3. 构造带来源标注的上下文context="\n\n".join(f"[资料{i}] (来源:{r['doc_name']})\n{r['content']}"fori,rinenumerate(results,1))# 4. 构造提示词messages=[{"role":"system","content":SYSTEM_PROMPT},{"role":"user","content":f"参考资料:\n{context}\n\n用户问题:{question}"},]# 5. 流式生成defanswer_gen():fortokeninself.llm.chat_stream(messages):yieldtokenreturnanswer_gen(),sourcesAPI 层将来源列表和流式答案通过 SSE 推送:
# api.pyasyncdefevent_stream():# 先推送来源列表(含 locator)yieldf"data:{json.dumps({'type':'sources','data':sources})}\n\n"# 再逐 token 推送答案fortokeningen:yieldf"data:{json.dumps({'type':'token','data':token})}\n\n"# 最后推送完成标志yieldf"data:{json.dumps({'type':'done'})}\n\n"效果:用户提问后,先看到来源标签,再看到答案逐字流出,体验流畅。
3.3 答案引用溯源:从[资料N]到文档高亮
这是本项目最核心的差异化能力。问题:LLM 生成的答案中有[资料1]、[资料2]这样的引用标识,但它们只是纯文本。如何让它们变成可点击的链接,点击后直接定位到原文?
挑战:流式输出时,[资料1]可能被拆分到多个 token(如[→资料→1→]),不能简单逐 token 匹配。
方案:每次收到新 token 后,对完整累积文本重新解析。
// index.htmlfunctionrenderAnswerText(bubble,text){constsources=bubble._sources||[];// 先转义 HTML,再把 [资料N] 替换为可点击链接lethtml=escapeHtml(text);html=html.replace(/\[资料\s*(\d+)\]/g,(m,num)=>{constidx=parseInt(num,10)-1;// 引用 1 基,sources 0 基consts=sources[idx];if(s){return`<a class="cite-link" >${s.doc_id}" >${s.chunk_id}">[资料${num}]</a>`;}returnm;// 无对应来源则保留原文});bubble.innerHTML=html;}// 事件委托:点击引用链接 → 打开文档预览chatInner.addEventListener('click',(e)=>{constlink=e.target.closest('.cite-link');if(!link)return;openViewer(link.dataset.doc,link.dataset.chunk);});溯源定位的关键:locator 机制
解析阶段记录每个文本块的字符偏移,分块阶段继承为完整的locator:
# locator 结构{"char_start":1520,# 全文起始字符偏移(高亮起点)"char_end":1985,# 全文结束字符偏移(高亮终点)"page_number":3,# PDF 页码"para_start":12,# 起始段落"line_start":45,# 起始行号"section":"第三章>3.2退款政策"# 所属章节}点击[资料N]后,前端请求文档结构化内容(blocks),根据char_start/char_end计算与每个 block 的重叠区间,用<mark>标签高亮并自动滚动:
答案: "...RAG 常用检索策略 [资料2]:1. **稠密检索**..." ↓ 点击 ┌─────────────────────────────┐ │ 📝 01-RAG指南 [Markdown] │ ← 类型 + 名称 │ 第3.3.1节 · 字符 19895-20407 │ ├─────────────────────────────┤ │ 3.3.1 完整 RAG Pipeline │ │ █████self._setup_chain()███ │ ← 高亮引用片段 └─────────────────────────────┘完整闭环:解析(记录char_start/end) → 分块(继承locator) → 检索(返回locator) → 生成(标注[资料N]) → 渲染(转可点击链接) → 点击(高亮定位)
3.4 向量一致性校验:防止切换供应商翻车
问题:不同 Embedding 模型的向量空间完全不同(维度、分布都不一样)。如果用模型 A 入库了向量,然后切换到模型 B 检索,相似度计算完全失真,会返回错误的检索结果。
方案:在vectors.json中记录入库时的 Embedding 元信息,检索前自动校验。
# vector_store.pyclassVectorStore:def_check_embedding_consistency(self):"""校验当前 Embedding 配置与已入库向量是否一致。"""ifnotself.meta:returncur_provider=self.embedding_service.provider cur_model=self.embedding_service.model stored_provider=self.meta.get("embedding_provider")stored_model=self.meta.get("embedding_model")ifcur_provider!=stored_providerorcur_model!=stored_model:raiseRuntimeError(f"Embedding 配置与已入库向量不一致:"f"当前={cur_provider}/{cur_model},"f"已入库={stored_provider}/{stored_model}。"f"请删除 data/store/vectors.json 后重新入库。")defsearch(self,query,top_k):ifnotself.records:return[]# 检索前校验一致性,防止向量空间混用self._check_embedding_consistency()query_emb=self.embedding_service.embed(query)[0]# numpy 矩阵化余弦相似度检索matrix=np.array([r["embedding"]forrinself.records])scores=self._cosine_similarity(matrix,query_emb)...入库时记录元信息:
defadd_documents(self,doc_id,doc_name,chunks,parsed_doc):embeddings=self.embedding_service.embed(texts)# 首次入库时记录 embedding 元信息ifnotself.recordsandembeddings:self.meta={"embedding_provider":self.embedding_service.provider,"embedding_model":self.embedding_service.model,"dim":len(embeddings[0]),}这样,切换 Embedding 供应商后系统会主动拦截并给出明确提示,而不是静默返回垃圾结果。
四、实战:零成本本地运行
借助 Ollama,整套 RAG 链路可以完全本地运行,零 API 费用、零网络依赖:
# 1. 安装 Ollama 并拉取模型ollama pull qwen2.5:7b# 对话模型ollama pull nomic-embed-text# 向量模型# 2. 配置 .envLLM_PROVIDER=ollamaLLM_MODEL=qwen2.5:7bEMBEDDING_PROVIDER=ollamaEMBEDDING_MODEL=nomic-embed-text# 本地部署无需 API Key# 3. 启动python-mapp.main也可以混合使用——DeepSeek 线上对话 + Ollama 本地向量化:
LLM_PROVIDER=deepseekLLM_MODEL=deepseek-chatLLM_API_KEY=sk-xxxxxxxxEMBEDDING_PROVIDER=ollamaEMBEDDING_MODEL=nomic-embed-text三条链路独立、配置隔离、互不干扰。
五、效果展示
RAG 问答界面
- 左侧文档列表,支持上传与删除
- 对话区流式输出答案,
[资料N]自动渲染为可点击链接 - 上方来源标签展示文档名、章节、相似度
引用溯源预览
- 点击
[资料N]弹出文档预览抽屉 - 顶部显示文档类型与名称
- 命中片段黄色高亮并自动滚动居中
六、设计亮点总结
| 亮点 | 实现方式 | 解决的问题 |
|---|---|---|
| 多供应商适配 | 抽象基类 + 工厂模式,OpenAI 兼容一个类覆盖多供应商 | 模型锁定,切换需改代码 |
| 答案引用溯源 | locator 贯穿解析→分块→检索→渲染,[资料N]转可点击链接 | 答案不可核实,AI 幻觉难发现 |
| 向量一致性校验 | vectors.json 记录入库元信息,检索前自动校验 | 切换 Embedding 供应商后检索失真 |
| 零外部中间件 | numpy 矩阵化检索 + JSON 持久化 | 部署太重,验证门槛高 |
| 流式渲染兼容 | 逐 token 累积后全文重新解析[资料N] | 引用标识被拆分到多个 token |
| 本地化运行 | Ollama 适配,LLM + Embedding 均可本地 | 敏感文档不能发云端 |
七、后续展望
本项目当前为 MVP 验证版,已验证核心技术链路可行。后续演进方向:
- 向量库升级:numpy → Milvus,支撑亿级向量检索
- 多轮对话:增加对话上下文记忆
- 混合检索:稠密向量 + 稀疏关键词(BM25)融合
- 企业级特性:多租户、RBAC 权限、高可用部署
- 更多格式:Excel、PPT、图片 OCR
项目已开源,欢迎 Star 支持:GitHub 仓库 gitee 仓库
写在最后:RAG 不是简单的"检索 + 生成",好的 RAG 系统需要在检索质量、生成效果、用户体验三个维度同时打磨。本文分享的引用溯源和向量一致性校验,就是在用户体验和系统健壮性上的一些实践。希望对你有启发。