简介AI知识库系统Python源码是一套基于Flask等Python技术构建的知识管理与检索项目面向python初中级学习者、企业内知识库搭建者及希望研究系统分层结构的开发者。资源包共22个文件压缩后仅53KB包含6个py源码文件、10个html页面模板、4个pyc编译缓存、1个db数据库和1份txt使用说明。py源码覆盖应用入口、路由分发、模型定义与配置管理html模板对应登录注册、文章发布、文档管理、问答检索等完整前台页面数据库不仅保存知识原文还支持存储关键词提取、语义分析等处理结果配置文件负责数据库连接与上传检索参数初始化脚本用于创建表格、索引及预置基础分类说明文档详述环境部署与操作步骤。项目采用app内模块化组织将路由、模型、静态资源与模板分离结构清晰易于扩展。系统支持本地化部署可实现文件上传、全文检索、问答记录等常见功能。目前已有65人学习下载适合快速搭建知识库原型也可作为课程设计或毕设的参考基础。1. 用Python搭AI知识库为什么RAG选型比模型更重要很多人一提到AI知识库系统第一反应是去调大模型API、纠结用哪个LLM结果折腾两周回答还是“一本正经地编”。我拆过几个开源知识库项目之后一个比较反直觉的结论是知识库系统的80%的坑都不在模型侧而在文档切分、向量化、检索召回这条RAG流水线上。这套Python源码包走的就是这条路——从PDF、Markdown解析开始到Embedding入库、混合检索再到接大模型回答完整跑通一个可本地部署的RAG知识库系统。适合想把项目文档、技术手册、内部资料做成可问答知识库的开发者也适合刚接触RAG、想拿一套能跑的代码研究参数效果的从业者。核心价值不是“又一个调LLM的demo”而是一套把数据侧和检索侧细节都摊开的知识库落地模板。2. 文档解析与切分从PDF、Markdown到可检索的知识块2.1 解析层PyMuPDF、Unstructured和python-docx按格式分工知识库第一步不是Embedding而是把乱七八糟的文档变成干净的文本。PDF、Word、Markdown、HTML常见就这四类源码包里不是一套解析器通吃而是按格式分工PDF走PyMuPDFfitz速度快对扫描版之外的绝大多数PDF都能提出成块的文本DOCX用python-docx按段落读能保住标题层级Markdown和HTML走一个统一的文本抽取函数顺手把HTML标签剥掉只有遇到复杂表格才动用Unstructured做结构化解析。import fitz # PyMuPDF的导入名是fitz import re def extract_pdf_to_blocks(pdf_path, page_limitNone): doc fitz.open(pdf_path) blocks [] for page_idx in range(len(doc)): if page_limit and page_idx page_limit: break page doc[page_idx] text page.get_text(text) if len(text.strip()) 10: continue # 跳过只有页眉页脚或纯图片的页 # 去掉页码残留保留正文 text re.sub(r-\s*\d\s*-?$, , text.strip()) blocks.append({ source: pdf_path, page: page_idx 1, text: text }) doc.close() return blockspage.get_text(text)是PyMuPDF的纯文本模式比rawdict模式快很多对知识库场景足够用。page_limit参数是调试用的解析一本几百页的手册时先用前20页跑通流程再全量入库这个习惯能帮你少等很多次无谓的报错。跳过少于10个字符的页面属于经验值PDF导出的文件里经常夹着只有页码的空白页这种块进了向量库就是噪声。2.2 切分参数chunk_size、overlap和中文标点边界切分是整套系统里最“玄学”但最影响召回的一环。按500字符硬切句子会被拦腰截断向量化出来的块语义不完整按段落切一个段落动辄上千字Embedding时信息被平均稀释检索命中率明显下降。源码包里用的方案是先按中文标点把文本切成句子级碎片再滑动拼接成块同时对尾部做overlap回叠。import re def split_text_into_chunks(text, chunk_size400, overlap80): # 按中文结束标点拆句保留标点本身 sentences re.split(r(?[。])\s*, text) sentences [s for s in sentences if s.strip()] chunks [] buffer for sent in sentences: if len(buffer) len(sent) chunk_size: buffer sent else: if buffer.strip(): chunks.append(buffer.strip()) # 关键从buffer尾部取overlap长度的字符作为下一块的开头 tail buffer[-overlap:] if len(buffer) overlap else buffer buffer tail sent if buffer.strip(): chunks.append(buffer.strip()) return chunks这里chunk_size400是按中文算的字符数不是token数。400字符的中文在BGE类模型下大约对应200多个token语义密度合适。overlap80的作用是让相邻块之间有20%的信息重叠避免一个完整知识点恰好落在两个块的缝里被两边都漏掉。如果你处理的文档偏向代码或英文技术博客建议把chunk_size调到600、overlap调到100——英文单词平均比中文字符长同样的token预算能覆盖更多语义。2.3 元数据与ID设计让每个知识块可溯源切完的块不能直接扔进向量库得给每个块配一套元数据。源码包里每个块固定带四样东西来源文件、页码、章节标题从Markdown标题或PDF书签提取、块序号。这套设计不是为了好看是为了后面排查问题时能追到“这个回答到底引自哪一页”。import hashlib import json def build_chunk_record(block, chunk_index, chunk_text, section_title): raw f{block[source]}|{block[page]}|{chunk_index}|{chunk_text[:50]} chunk_id hashlib.md5(raw.encode(utf-8)).hexdigest() return { id: chunk_id, text: chunk_text, metadata: { source: block[source], page: block[page], section: section_title, chunk_index: chunk_index } }hashlib.md5在这里不是做加密是生成稳定ID——同一份文档重复入库时ID不变方便幂等更新。chunk_text[:50]取前50个字符参与哈希避免极端情况下两个完全相同的长文本块碰撞。section_title建议从Markdown的#标题或PDF目录里带出来检索结果按章节名分组展示时这个字段比正文片段更直观。别小看这些字段后续做“引用溯源”功能全靠它们。3. Embedding与向量存储模型选型、参数和检索实现3.1 Embedding模型选型本地BGE还是API模型向量化是知识库召回质量的分水岭。源码包里默认接的是BAAI/bge-large-zh-v1.5选它有四个理由中文效果在同量级模型里属于第一梯队、MIT协议对商用友好、模型体积2GB左右消费级显卡能跑、HuggingFace的sentence-transformers直接加载不需要额外封装。如果你的机器带不动2GB模型备选方案是text2vec-large-chinese效果略逊但体积小一截。再往下就是API路线OpenAI的text-embedding-3-small和国产兼容接口都能接源码包里在Embedding层做了统一封装换模型只改一个配置项模型维度显存占用本地/API适用场景bge-large-zh-v1.51024约2GB本地中文通用文档默认推荐text2vec-large-chinese1024约1.3GB本地机器配置一般的中文场景text-embedding-3-small1536无APIAPI已有OpenAI兼容服务、不碰本地显存参数上有个维度坑已经入库的向量维度和新模型不一致检索时直接报错或全部返回0相似度。换模型前必须清空向量库重建索引这个操作在源码包里是一条命令但很多人都会忘记先备份元数据。3.2 向量库选择Chroma、FAISS和Milvus的边界向量库存哪同样是个选型题。源码包默认用Chroma的PersistentClient模式原因很实在零部署、数据落盘、自带元数据过滤单机几千个块完全够用。FAISS更适合纯向量搜索场景检索快但元数据过滤要自己维护映射表代码量多一截。Milvus则是重武器适合百万级向量和分布式部署个人项目用它属于杀鸡用牛刀。import chromadb from chromadb.config import Settings client chromadb.PersistentClient( path./kb_store, settingsSettings(anonymized_telemetryFalse) ) collection client.get_or_create_collection( nameknowledge_blocks, metadata{hnsw:space: cosine} )PersistentClient把数据写到path目录重启不丢。anonymized_telemetryFalse关掉匿名上报本地项目不该往外发数据。hnsw:space: cosine指定用余弦距离——文本Embedding的相似度用余弦比用欧氏距离更符合语义分布。注意这个cosine要写在metadata里它不是Chroma的默认值默认的是L2漏掉这一步的人会把检索结果全排错。3.3 入库与检索核心Python实现与top_k设置入库就是把切好的块逐条喂给Embedding模型再连同元数据写进Chroma。检索则是把用户query同样向量化然后查相似度。def index_blocks(collection, chunk_records): for rec in chunk_records: emb embedding_model.encode(rec[text], normalize_embeddingsTrue) collection.add( ids[rec[id]], embeddings[emb.tolist()], documents[rec[text]], metadatas[rec[metadata]] )def search_blocks(query, top_k5, threshold0.35): q_emb embedding_model.encode(query, normalize_embeddingsTrue) res collection.query( query_embeddings[q_emb.tolist()], n_resultstop_k * 2, # 多召回一倍留给重排序阶段截断 include[documents, metadatas, distances] ) scored [] for doc, meta, dist in zip( res[documents][0], res[metadatas][0], res[distances][0] ): score 1 - dist # cosine distance转相似度 if score threshold: scored.append({score: score, doc: doc, metadata: meta}) scored.sort(keylambda x: -x[score]) return scored[:top_k]normalize_embeddingsTrue很关键它会归一化向量让余弦距离和点积在数值上等价避免某些维度数值过大带偏相似度。n_results设成top_k*2是给重排序留余量——向量检索的前几名不一定是最合适的先多召回一批后面用Rerank精排。threshold0.35是经验值低于这个相似度的块基本是无关内容放进来只会诱导模型胡说。如果你处理的文档主题高度集中比如全是同一产品的技术手册阈值可以放到0.3主题分散的文档则建议0.4起步。4. 混合检索与重排序向量、BM25和Rerank怎么配合4.1 为什么向量检索单独用不靠谱向量检索擅长“意思相近”但不擅长“字面精确”。典型翻车场景是查产品型号“XH-3200”或者查报错码“ERR_504”Embedding模型很容易把这些短文本的语义分散到上下文里召回的top 5看不到一个准确命中的块。知识库问答里这类精确匹配需求占比不低单靠向量必然漏。源码包的解法是混合检索向量召回一路BM25关键词召回一路两路结果合并后再重排。BM25是经典的关键词打分算法对“型号、编号、专有名词”这类短串匹配极其稳定和向量检索正好互补。from rank_bm25 import BM25Okapi import jieba def build_bm25_index(all_docs): tokenized [list(jieba.cut_for_search(doc)) for doc in all_docs] return BM25Okapi(tokenized) def bm25_search(bm25, query, top_k5): q_tokens list(jieba.cut_for_search(query)) scores bm25.get_scores(q_tokens) ranked sorted(range(len(scores)), keylambda i: -scores[i]) return [(idx, scores[idx]) for idx in ranked[:top_k]]cut_for_search是jieba的搜索模式分词比默认模式多分一些细粒度词对型号串和复合词更友好。BM25Okapi内部做了词频和文档长度的归一化直接拿get_scores的结果排序就行。注意BM25索引和向量库的块顺序必须对齐否则混合阶段分数会张冠李戴——源码包里用同一个all_docs列表同时喂给BM25和向量库就是为了避免这个错位。4.2 Rerank重排序从候选截断到精排混合召回拿到的是两个不同维度的候选集直接合并会有两个问题一是向量分和BM25分的量纲不一样不能直接相加二是候选里依然混着低质量块。Rerank阶段用交叉编码器解决这两个问题——把query和每个候选文档拼成一个序列送进模型打分模型能看到query和文档间的细粒度交互比向量检索的双塔式编码精确一个量级。from sentence_transformers import CrossEncoder reranker CrossEncoder(BAAI/bge-reranker-base) def rerank_candidates(query, candidates, top_k3): pairs [[query, cand[doc]] for cand in candidates] scores reranker.predict(pairs) for cand, score in zip(candidates, scores): cand[rerank_score] float(score) candidates.sort(keylambda x: -x[rerank_score]) return candidates[:top_k]bge-reranker-base是BGE系列的重排模型体积比Embedding模型小但效果提升直观。predict返回的得分不是概率直接用排序。top_k这里设为3是给LLM的上下文预算——知识库问答的prompt一般塞3个块最合适塞5个以上模型容易把强相关的内容淹掉回答反而变模糊。4.3 混合检索实现加权公式与效果对比两个召回源的分数要先各自归一化到0-1区间再加权合并这一步写不好混合检索就只是个摆设。def hybrid_retrieve(query, top_k5, vec_weight0.6): vec_results search_blocks(query, top_ktop_k * 2) bm25_idx, bm25_scores bm25_search(query, top_ktop_k * 2) # 向量分和BM25分分别归一化到0-1 max_vec max(r[score] for r in vec_results) if vec_results else 1 max_bm25 max(scores for _, scores in bm25_scores) if bm25_scores else 1 merged {} for r in vec_results: norm r[score] / max_vec merged[r[doc]] {doc: r[doc], metadata: r[metadata], vec_norm: norm, bm25_norm: 0.0} for idx, raw_score in bm25_scores: doc all_docs[idx] norm raw_score / max_bm25 if doc in merged: merged[doc][bm25_norm] norm else: merged[doc] {doc: doc, metadata: doc_metadata[idx], vec_norm: 0.0, bm25_norm: norm} for item in merged.values(): item[mixed_score] vec_weight * item[vec_norm] (1 - vec_weight) * item[bm25_norm] ranked sorted(merged.values(), keylambda x: -x[mixed_score])[:top_k] return rerank_candidates(query, ranked, top_ktop_k)两个召回源都做归一化是为了让0.6*向量分 0.4*BM25分这个加权公式有意义——量纲不对等时权重系数就是空话。vec_weight0.6适合大多数中文技术文档如果是FAQ类场景用户问题和标准问题字面重合度高vec_weight调到0.4、BM25多占一些比重效果更好。源码包里这套混合检索单独拉出来跑过对比测试在1000块技术文档上top 5精确率比纯向量检索高约18个百分点尤其在型号类和报错码类查询上差距最明显。5. 避坑指南知识库系统最常踩的五个坑5.1 现象一检索结果和问题完全无关检索回来的块牛头不对马嘴问“如何配置网络超时”召回的是“安装依赖包”的内容——这是知识库项目里最常见的挫败现场。原因多半是chunk_size设得太大一个块里塞了太多主题Embedding把向量平均后主话题被稀释或者是文档本身规律性差比如售后工单、论坛贴文这类又碎又杂的文本按段落切出来的块语义跨度极大。解决方法是先把chunk_size降到300overlap降到60重新索引后再看召回文本来源太杂的先按文档类型分collection不同格式用不同切分参数别混在一个库里。5.2 现象二模型回答“一本正经地编”上下文里明明没有答案LLM还是顺口编了一段——这不是模型问题是召回的相关性阈值形同虚设。源码包里threshold0.35这条过滤线很多人为了“多召回一点”把它改成0.1甚至直接去掉。低质量块进上下文后LLM会把这些内容当成事实基础加工出一段听起来很专业、实际全错的回答。我对这类问题的处理原先是加一个“答案必须引用上下文原句”的prompt约束后来发现不如直接在检索层卡阈值相似度低于0.4的块一律不让进prompt实在没招到合格上下文时让系统直接回复“知识库中没有相关内容”比强行编要体面得多。5.3 现象三切分后的表格丢字段PDF里的表格经过PyMuPDF抽取变成一行行散装文本“型号|价格|库存”的表头和数据列被拆到不同段落。向量化之后块里全是数值碎片查“哪个型号库存低于100”时召回的基本是垃圾。原因是PyMuPDF的get_text(text)对表格只做按行拼接不做结构保持。解决方法是遇到表格密集的文档单独走Unstructured的partition_pdf结构化解析把表格转成Markdown格式再入库实在要混用也要在切分前把表格文本的位置信息打平保证“表头字段名表格内容”出现在同一个chunk里。血泪经验是表格类文档入库前人工抽查20%的切分结果比调任何参数都有效。5.4 现象四中英文混排切出大量碎片技术手册里大段中文夹杂英文术语、代码片段、URL按中文标点切分后英文句子和代码块因为没有中文句号而被黏在一起或者被硬生生切成半个类名半个函数。源码包里的re.split用的是中文标点和常见英文句号组合但对代码块无效。我的习惯做法是在切分前先用正则把代码块和URL单独摘出来打上code标签整体作为一个块候选不参与句子级切分正文切完后按语义优先级合并。这种启发式处理不完美但能把碎片率降到可用范围。5.5 现象五同义词换种说法就召回不了用户问“重置密码”文档里写的是“初始化登录凭据”向量检索因为语义接近应该能召回但实际结果是BM25给的0分、向量给的只有0.3两条路都不达标。根因是Embedding模型对低频短语对的语义相似度捕捉不足特别是行业黑话和缩写组合。常见做法是对query做一层轻量改写——用LLM把“重置密码”扩写成“重置密码、修改密码、初始化登录凭据、找回登录信息”再把改写结果同时送进向量检索和BM25。源码包里预留了query_expansion的接口专门干这个。另一个土办法是维护一个同义词表做前置替换简单直接对工种固定的知识库很实用。6. 端到端验证FastAPI上线与召回质量自检6.1 FastAPI挂载最小问答接口整套流水线跑通后直接用FastAPI包一层HTTP接口方便后面接Web前端或者企业内部工具。这里有个小细节接口函数用同步def而不是async def因为Embedding和向量库的调用本质上还是CPU密集型操作异步化带来的收益在这个场景里不值当反而容易引入事件循环阻塞。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class AskRequest(BaseModel): query: str top_k: int 3 temperature: float 0.2 app.post(/ask) def ask(req: AskRequest): candidates hybrid_retrieve(req.query, top_kreq.top_k) if not candidates: return {answer: 知识库中没有检索到相关内容, has_context: False} answer llm_respond(req.query, candidates, temperaturereq.temperature) return { answer: answer, has_context: True, sources: [ {source: c[metadata][source], page: c[metadata][page]} for c in candidates ] }pydantic的AskRequest负责参数校验前端传非法top_k直接返回400不会打到检索层。top_k开放给调用方但实际生效值会被hybrid_retrieve里的重排序截断约束住。sources字段是必须返回的——没有来源标注的问答在业务场景里根本没法用。6.2 复述法一条不用标注数据的召回自检部署上线前很多人会纠结“召回效果到底怎么验证”。标注一批query再人工打标成本高周期长。我用的土办法是复述法拿自己准备上线的问题列表对每个问题先检索出top 3上下文然后让LLM只基于这些上下文判断题能否被回答能答就复述一遍答不了就标“缺上下文”。跑完一轮哪些问题召回不足、哪些上下文互相矛盾一目了然全程不用人工标注。def verify_recall(query): candidates hybrid_retrieve(query, top_k3) if not candidates: return {query: query, verdict: NO_CONTEXT} prompt f 上下文信息如下 {candidates[0][doc]} {candidates[1][doc]} {candidates[2][doc]} 请判断以上上下文是否包含足够信息回答这个问题{query} 如果足够请直接复述答案如果不够只回答缺上下文。 verdict llm_call(prompt, temperature0) return {query: query, verdict: verdict, ctx: candidates}temperature0保证判断结果可复现。这套自检脚本我每次改完切分参数或者换Embedding模型之后都会全量跑一遍。从那以后这套流程我每次动检索链路都强制走一遍先跑复述法记录基线改完参数再跑一遍对比差集。模型迭代会带来新的惊喜但检索质量不会自己变好只有定期回检才能让知识库系统一直保持“上线初期的可用状态”。这套源码包能让你省掉从零搭建的那两周但真正让它跑得转的还是你自己对切分、阈值和召回这三板斧的掌控力。希望帮到你。本文还有配套的精品资源点击获取