☰
从零构建轻量级RAG知识库问答系统
2026/10/15 19:44:52 网站建设 项目流程

从零构建轻量级 RAG 知识库问答系统:多供应商适配 + 答案溯源实战

本文完整记录了一个轻量级 RAG(检索增强生成)知识库问答系统的设计与实现过程。从文档上传到智能问答,从多供应商模型适配到答案引用溯源,带你一站式跑通"上传 → 检索 → 问答 → 溯源"全链路。零外部中间件,支持本地 Ollama 零成本运行。


一、为什么需要又一个 RAG 系统?

市面上的知识库方案不少,但落地时常遇到这些痛点:

痛点典型方案的问题
部署太重动辄 MySQL + Redis + ES + Milvus + K8s,验证个想法就得搭一套集群
模型锁定绑定单一 LLM 供应商,换模型要改一堆代码
答案不可溯源AI 回答了,但不知道依据哪段原文,无法核实
本地化困难敏感文档不能发云端 API,但本地部署门槛高

基于这些痛点,我设计并实现了KB-MVP——一个轻量到python -m app.main就能跑起来的 RAG 知识库系统,核心目标:

  1. 零外部中间件:向量存储用 numpy + JSON,不装数据库
  2. 多供应商模型适配:LLM 和 Embedding 都能灵活切换,改配置不改代码
  3. 答案可溯源:答案中的[资料N]是可点击链接,点击直达原文并高亮
  4. 本地化优先:通过 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-docxPDF/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-text

3.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(),sources

API 层将来源列表和流式答案通过 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 系统需要在检索质量、生成效果、用户体验三个维度同时打磨。本文分享的引用溯源和向量一致性校验,就是在用户体验和系统健壮性上的一些实践。希望对你有启发。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询