从零构建轻量级 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 │ │ numpyJSON │ │ LLM Embedding │ │ │ └─────────┘ └──────────────┘ └──────────┬───────────┘ │ └──────────────────────────────────────┼───────────────────────┘ │ ┌──────────────────────────┼──────────────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ DashScope API │ │ DeepSeek / GLM │ │ Ollama 本地 │ │ 通义千问 LLM/Emb │ │ OpenAI 兼容 API │ │ LLM / Embedding│ └─────────────────┘ └──────────────────┘ └─────────────────┘技术栈一览层次选型选型理由后端Python FastAPI异步高性能SSE 流式支持好向量存储numpy JSON零依赖万级向量瞬时检索文档解析PyMuPDF python-docxPDF/Word 解析成熟模型适配dashscope SDK openai SDK覆盖多供应商前端原生 HTML/CSS/JS零构建单文件三、核心实现解析3.1 多供应商模型适配层开闭原则实战问题不同 LLM 供应商通义千问、DeepSeek、GLM、Ollama的 SDK 各不相同如何做到切换供应商只改配置不改代码方案抽象基类 工厂模式符合开闭原则。# llm_provider.pyclassBaseLLM(ABC):LLM 适配器抽象基类定义统一接口。propertyabstractmethoddefprovider_name(self)-str:...propertyabstractmethoddefmodel(self)-str:...abstractmethoddefchat_stream(self,messages:List[dict])-Generator[str,None,None]:流式生成输入消息列表逐 token 返回。...classDashScopeLLM(BaseLLM):通义千问原生 SDK流式 增量输出。defchat_stream(self,messages):importdashscope responsesdashscope.Generation.call(modelself._model,messagesmessages,streamTrue,incremental_outputTrue,)forrespinresponses:ifresp.status_code200: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._clientOpenAI(api_keyapi_keyorollama,base_urlapi_base,)defchat_stream(self,messages):streamself._client.chat.completions.create(modelself._model,messagesmessages,streamTrue,)forchunkinstream:ifchunk.choices:deltachunk.choices[0].delta.contentifdelta:yielddeltadefcreate_llm()-BaseLLM:工厂函数根据配置创建适配器。providerconfig.LLM_PROVIDERifproviderdashscope: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_PROVIDERdeepseekLLM_MODELdeepseek-chatLLM_API_KEYsk-xxxxxxxxEMBEDDING_PROVIDERollamaEMBEDDING_MODELnomic-embed-text3.2 RAG 检索增强问答SSE 流式输出问题RAG 问答需要先检索再生成如何让用户尽快看到回答而不是等 LLM 生成完才返回方案SSEServer-Sent Events流式输出先推送来源列表再逐 token 推送答案。# rag_engine.pyclassRAGEngine:defask(self,question:str)-Tuple[Generator,List[dict]]:问答返回(流式答案, 来源列表)。# 1. 检索 Top-K 相关片段含 chunk_id 与 locator 定位信息resultsself.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():# 先推送来源列表含 locatoryieldfdata:{json.dumps({type:sources,data:sources})}\n\n# 再逐 token 推送答案fortokeningen:yieldfdata:{json.dumps({type:token,data:token})}\n\n# 最后推送完成标志yieldfdata:{json.dumps({type:done})}\n\n效果用户提问后先看到来源标签再看到答案逐字流出体验流畅。3.3 答案引用溯源从[资料N]到文档高亮这是本项目最核心的差异化能力。问题LLM 生成的答案中有[资料1]、[资料2]这样的引用标识但它们只是纯文本。如何让它们变成可点击的链接点击后直接定位到原文挑战流式输出时[资料1]可能被拆分到多个 token如[→资料→1→]不能简单逐 token 匹配。方案每次收到新 token 后对完整累积文本重新解析。// index.htmlfunctionrenderAnswerText(bubble,text){constsourcesbubble._sources||[];// 先转义 HTML再把 [资料N] 替换为可点击链接lethtmlescapeHtml(text);htmlhtml.replace(/\[资料\s*(\d)\]/g,(m,num){constidxparseInt(num,10)-1;// 引用 1 基sources 0 基constssources[idx];if(s){returna classcite-link >${s.doc_id} >${s.chunk_id}[资料${num}]/a;}returnm;// 无对应来源则保留原文});bubble.innerHTMLhtml;}// 事件委托点击引用链接 → 打开文档预览chatInner.addEventListener(click,(e){constlinke.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_providerself.embedding_service.provider cur_modelself.embedding_service.model stored_providerself.meta.get(embedding_provider)stored_modelself.meta.get(embedding_model)ifcur_provider!stored_providerorcur_model!stored_model:raiseRuntimeError(fEmbedding 配置与已入库向量不一致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_embself.embedding_service.embed(query)[0]# numpy 矩阵化余弦相似度检索matrixnp.array([r[embedding]forrinself.records])scoresself._cosine_similarity(matrix,query_emb)...入库时记录元信息defadd_documents(self,doc_id,doc_name,chunks,parsed_doc):embeddingsself.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_PROVIDERollamaLLM_MODELqwen2.5:7bEMBEDDING_PROVIDERollamaEMBEDDING_MODELnomic-embed-text# 本地部署无需 API Key# 3. 启动python-mapp.main也可以混合使用——DeepSeek 线上对话 Ollama 本地向量化LLM_PROVIDERdeepseekLLM_MODELdeepseek-chatLLM_API_KEYsk-xxxxxxxxEMBEDDING_PROVIDERollamaEMBEDDING_MODELnomic-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 系统需要在检索质量、生成效果、用户体验三个维度同时打磨。本文分享的引用溯源和向量一致性校验就是在用户体验和系统健壮性上的一些实践。希望对你有启发。