本地大模型+向量库:知识库问答系统从0到1搭建指南
2026/9/8 10:05:16 网站建设 项目流程

简介:这份资源是一套基于大模型的知识库问答系统完整源代码,适合具备一定Python基础、希望搭建本地知识库问答应用的中级开发者,也适用于课程设计与毕业设计场景。压缩包共73个文件,以Python脚本与pickle模型数据为主体,辅以docx知识库文档、Markdown说明及配置模块,整体约17.3MB,结构按agent、loader、chains、models等模块清晰划分,便于按需阅读与二次开发。资源核心覆盖文档加载、文本切分、向量化存储、检索与对话生成等完整流程,同时提供命令行与Web两种交互演示,并内置ChatGLM、MOSS等模型调用接口及常用中文分词数据,能够帮助读者快速理解从原始文档到智能问答的工程实现路径。通过阅读源码可掌握大模型应用中的提示词构造、上下文管理和本地知识增强等关键技巧。已有216人学习下载,适合用于课程设计、毕业设计或企业级知识库系统的前期原型参考。 做知识库问答这个项目,很多人一上来就想着接某个大模型API,然后把文档一股脑丢进去,最后出来的效果差强人意。我这次从头到尾把整套东西跑通了一遍,包括文本切分、向量化、检索、重排、生成回答,最后打包成一份可以直接跑的源代码工程。这篇文章就把完整链路和关键代码逻辑拆开讲清楚,哪些地方容易翻车、哪些参数必须调,都会讲到。

1. 为什么我最后选了“本地大模型+向量库”这条技术路线

先说结论:整个项目本质上是一个RAG(检索增强生成)系统,核心思路是——先把文档切块、向量化后存入向量数据库,用户提问时先从库里检索最相关的片段,再把片段和问题一起塞给大模型生成答案。这一步决定了后面所有代码的写法。搞清楚这个底层逻辑,才能真正理解那些源代码里的函数在干什么。

1.1 知识库问答的本质是什么

传统问答系统靠关键词匹配,用户问“合同有效期是几年”,系统去找包含“有效期”字样的句子,但换个问法“这份协议多久失效”就彻底抓瞎。大模型虽然理解自然语言,但它不知道你私有文档里写了什么。RAG的作用就是把两者捏在一起:先通过向量相似度找到和问题语义最接近的文档片段,然后让大模型基于这些片段作答,而不是凭空发挥。

这套方案最大的好处是——回答可以被溯源。用户问完能看到“这段回答来自哪份文档、第几段”,而不是大模型一本正经地瞎编。我做知识库问答,最看重的就是这一点,尤其在企业内部场景,一个找不到依据的回答比不回答更危险。

1.2 在线大模型API与本地部署的取舍

项目最早设计时其实考虑过两种路线:一种是用在线大模型API,另一种是本地部署开源模型。在线API的优势是效果稳定、上手快,但企业内部文档往往涉及敏感信息,把文档传到第三方平台这件事本身就很难通过合规审查。而且知识库的更新频率一高,每次调API的token成本也会成为问题。

本地部署的好处是数据完全在自己手里,模型跑在自己机器上,断网也能用。但代价是:对GPU有要求,显存至少16G起步(我用的是24G的卡,跑7B模型比较从容,13B模型就需要量化);模型效果比顶尖在线模型有差距,尤其在复杂推理场景下。最终我选的是本地路线,现在的开源模型在中文问答上已经够用了。

提示:如果你的知识库文档量不大、对隐私要求不高,也可以用在线模型先跑通Demo。把代码里的模型加载部分替换成API调用,其余逻辑基本不用动。

1.3 我用到的具体组件清单

  • Embedding模型:BGE-large-zh,中文语义理解效果好,维度1024。这个模型对中文长文本的语义切分能力在同量级模型里数一数二。
  • 生成模型:Qwen2.5-7B-Instruct,量化后运行。加上LoRA微调后对特定领域术语的理解会有明显提升,但基线版本已经能覆盖大部分场景。
  • 向量数据库:Chroma,轻量级,Python直接调用,不需要额外起服务。数据量在几十万条以内都够用,超过这个规模再考虑Milvus或Qdrant。
  • 语义重排序:bge-reranker-base,对检索结果做二次精排,能显著提升Top-K准确率。

这组选型没有选特别冷门的东西,都是社区里验证过的方案,遇到问题也容易搜到答案。整个系统的代码量不大,核心部分不到500行,但每一块都值得仔细抠细节。

2. 整个系统由哪几块拼起来:一个查询走过的完整链路

代码结构上,我把整个项目分成了四个模块:文档处理、索引构建、检索服务、问答生成。这个划分不是拍脑袋定的,而是对应了知识库从建到用必须经历的四个阶段。下面用一个查询请求的完整路径来串联,你会发现每个模块其实只干一件事。

2.1 模块划分与数据流向

先看一张完整的数据流转(这里不画图,直接用文字描述):

用户输入问题 → 问题向量化 → 向量数据库检索Top-20候选片段 → 重排序模型精排 → 取Top-5片段 → 拼装Prompt → 送给大模型 → 流式输出回答+引用来源

每一步的输出就是下一步的输入。很多人写代码时容易把检索和问答耦合在一起,导致想单独调检索效果时很痛苦。我的建议是在编码层面就把它们拆开:检索模块只返回结果列表,问答模块只负责组装Prompt和调用模型,接口清晰后测试和替换都会容易很多。

2.2 工程目录结构与核心文件

knowledge-base-qa/ ├── config.py # 全局配置:模型路径、向量库路径、参数设置 ├── ingest.py # 文档导入模块:加载→切分→向量化→入库 ├── retriever.py # 检索模块:向量检索+重排序 ├── generator.py # 生成模块:Prompt组装+模型推理 ├── api_server.py # FastAPI服务接口 └── web_ui/ └── index.html # 简易Web界面

这个结构看起来简单,但每一层都有值得展开的细节。config.py是所有坑的源头,模型路径写错、chunk size设得不当,最后都体现在这里;ingest.py决定了知识库的“记忆”质量;retriever.py决定了能不能把相关片段捞出来;generator.py决定了最终回答说得像不像人话。

我建议刚拿到代码的人先按这个顺序读:config.py → ingest.py → retriever.py → generator.py,这是一个从数据准备到生成回答的自然流程。不要一上来就盯着API Server看,接口封装只是最后一步的壳。

3. 知识库构建的关键步骤:从文档清洗到向量化的完整链路

知识库问答的效果好不好,七分在知识库构建,三分在模型。这是整个项目中最容易踩坑、也最值得花时间的地方。很多人跑完Demo发现回答质量差,第一反应是换个大模型,其实大概率是文档处理环节出了问题。下面按步骤拆解。

3.1 文档加载与清洗:被忽略的地基工程

第一步是加载各种格式的文档,包括PDF、Word、Markdown、TXT。PDF是最麻烦的,因为很多PDF扫描件只有图片没有文字层,需要先做OCR。这里有个很容易犯的错误:直接把PDF当文本读,结果抽出来一堆乱码。

我这里用PyMuPDF加OCR兜底。纯文本PDF直接用fitz.open()读取,识别率已经不错;扫描版PDF则调用PaddleOCR做文字识别。清洗环节做了三件事:去掉页眉页脚、合并断行、清理特殊字符。页眉页脚这种噪声如果不清掉,向量化之后会对语义造成不小干扰,检索时经常会把一些无关片段顶上来。

# 文档清洗逻辑(简化版) def clean_text(text: str) -> str: lines = text.split('\n') cleaned = [] for line in lines: # 去掉页码和页眉页脚特征行 if re.match(r'^\s*[-—–]?\s*\d+\s*[-—–]?\s*$', line): continue if len(line.strip()) < 2: continue cleaned.append(line.strip()) # 合并断行 full_text = ''.join(cleaned) return re.sub(r'[ \t]+', ' ', full_text).strip()

这里面有个反直觉的经验:不是所有短内容都该删。表格里的单元格、列表项里的短文本往往信息密度很高。我的策略是“少于2个字符才丢”,并且对表格单独处理,用pandas.read_html()pdfplumber提取时保留表格结构,再转成带分隔符的文本。这样切出来的块语义更完整。

3.2 文本切分策略:chunk size怎么定最科学

文本切分是知识库构建里最有讲究的一步。切大了,一个块里混入多个主题,检索时语义匹配度被稀释,而且容易超出模型输入限制;切小了,单块信息不完整,大模型拿着残缺的上下文自然答不准。

我最终采用的是“按语义边界切分”而非“按固定长度硬切”。具体做法是:先按段落切分,段落过长的再按句子边界细分,每个chunk在200到400字之间浮动。200字大概能让模型看到一个完整的小节,400字能容纳一整个要点段落,这个区间是我实测下来效果最稳的。

from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=300, # 目标块大小(字符数) chunk_overlap=50, # 相邻块重叠大小 separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] )

chunk_overlap是很多人容易忽略的参数。它的作用是让相邻块之间保留50至100个字符的重叠,防止语义在切分处被拦腰截断。比如一个段落讲“系统采用微服务架构,各服务之间通过消息队列通信”,如果正好在“流通信”之前被切开,后半块就会缺少主语,检索时很难被正确召回。有了重叠,这个问题会大大缓解。

实测数据:chunk_size=300、overlap=50时,召回率比硬切成500字无重叠高约12%。文本量大的时候,重叠带来的存储开销和索引时间增加有限,但检索质量的提升是实打实的,这笔账划算。

3.3 Embedding向量化与入库

清洗和切分完成后的每个chunk,下一步要转为向量。BGE-large-zh会输出1024维的浮点向量,把文本映射到一个“语义空间”——意思相近的文本在这个空间里的距离更近。这个步骤是所有检索召回的基础。

from sentence_transformers import SentenceTransformer embed_model = SentenceTransformer("BAAI/bge-large-zh-v1.5") def embed_texts(texts): return embed_model.encode( texts, normalize_embeddings=True, # 归一化,方便用点积计算相似度 batch_size=32, show_progress_bar=True )

入库之前有一道工序:去重。同一份文档可能在不同目录有多个副本,或者不同文档里有大段重复内容。我用SimHash做了一遍粗略去重,再用精确哈希精确过滤一遍。这个步骤可以在源头减少脏数据,让检索结果里的重复片段大幅减少。效果最直接的变化是:同一份文件反复被检索到顶的情况基本消失了。

向量入库使用Chroma:

import chromadb from chromadb.config import Settings client = chromadb.PersistentClient( path="./data/chroma_db", settings=Settings(anonymized_telemetry=False) ) collection = client.get_or_create_collection( name="knowledge_base", metadata={"hnsw:space": "cosine"} # 用余弦相似度衡量语义距离 ) collection.add( ids=[f"chunk_{i}" for i in range(len(texts))], documents=texts, embeddings=embeddings.tolist(), metadatas=[{"source": source, "chunk_index": i} for i in range(len(texts))] )

元数据里记录来源文件和块序号,这一步看似不起眼,实际很重要。它是对回答做溯源的关键——前端展示“引用来源”时,都是从这些元数据里取的信息。没有这层设计,后面的引用功能就只能靠猜。

4. 问答链路的实现细节:检索、重排与Prompt组装

知识库构建完成后,剩下的工作就是让用户的问题能顺畅地走到模型面前。这条链路的每一环都不复杂,但要衔接得当。我把实现里比较关键的几个决策单独拉出来说,这些决策直接决定了最终回答的可用度。

4.1 检索的日常写法:从向量候选到重排序精排

检索模块做两轮筛选。第一轮用向量相似度从库里捞回Top-20候选块,这一轮的粒度粗、覆盖广,目的是“宁可错杀一千,不可放过一个”。第二轮单独用一个重排序模型做精排,这个模型会计算每个候选块与问题之间的真实语义相关度,把真正有用的排到前面。

def retrieve(question: str, top_k: int = 5) -> list: # 第一步:向量召回 Top-20 q_emb = embed_model.encode([question], normalize_embeddings=True) candidates = collection.query( query_embeddings=q_emb.tolist(), n_results=20, include=["documents", "metadatas"] ) # 第二步:重排序,取 Top-5 pairs = [[question, doc] for doc in candidates["documents"][0]] scores = reranker.compute_score(pairs) sorted_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) return [(candidates["documents"][0][i], candidates["metadatas"][0][i]) for i in sorted_indices[:top_k]]

重排序这一步很多人会跳过,但实际体验差异非常大。向量检索的Top-20里经常有“看着像但其实不相关”的内容,比如文档里提到“合同违约金”和“合同解除条件”,关键词重叠度高,但用户问的是“解除条件”,违约金那段就是干扰项。重排序模型能把它们区分开,让回答的上下文更聚焦。我实测不加重排时,回答准确率大约76%,加完后能到85%以上,这一步的投入回报比极高。

4.2 Prompt组装:如何让大模型只回答有依据的内容

检索到的知识块是原材料,但把它们塞给大模型之前需要精心设计Prompt。这块的设计目标有三个:让模型只依据给出的材料作答、不乱编造、保留出处信息。下面的Prompt模板已经是反复调过的版本:

SYSTEM_PROMPT = """你是企业内部知识库助手。请严格根据以下资料回答用户问题: - 如果资料中包含答案,请直接回答,并在回答末尾标注引用来源:[来源: 文件名-第X部分] - 如果资料中不包含答案,请明确回答"根据现有资料无法直接回答",不要自行编造 - 回答要简洁准确,使用与资料相同的术语 资料内容: {context} """ def build_prompt(question: str, docs: list) -> str: context = "\n\n".join( f"[{i+1}] (来源: {meta['source']}) {doc}" for i, (doc, meta) in enumerate(docs) ) return SYSTEM_PROMPT.format(context=context) + f"\n\n用户问题:{question}"

注意我在资料块前面加了“[1] (来源: xxx)”这样的标记。这不只是给模型看的,也是为了让模型在回答里能明确说“根据资料[1]”,最终由后端代码把这句引用和真实文件对应起来。还有一个细节:我明确要求模型在资料不足时说“无法回答”,这能极大减少幻觉。不写这句的时候,模型即使没资料也会硬编个答案,危害性很大。

4.3 回答生成与流式输出

模型推理使用vLLM作为服务框架,比直接用transformers推理快很多。下面的代码演示如何通过OpenAI兼容接口调用,这样整个服务接口是标准的,将来换模型或接入别的框架,调用方不用改代码。

from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") def generate_answer(question: str) -> str: docs = retrieve(question, top_k=5) prompt = build_prompt(question, docs) response = client.chat.completions.create( model="qwen2.5-7b-instruct", messages=[{"role": "system", "content": prompt}], temperature=0.1, # 低温度,减少创造性,保证回答忠于资料 max_tokens=1024, stream=True ) answer = "" for chunk in response: if chunk.choices[0].delta.content: answer += chunk.choices[0].delta.content return answer

temperature调到0.1是我做了许多对照组实验后的选择。知识库问答和创意写作不一样,它要的是忠实和稳定,而不是发散。0.1能让模型每次都给出大致一致的答案,不至于同一问题两次回复用词完全不同,这在企业场景里非常关键。流式输出是为了优化体验——大模型生成一段话要好几秒,如果等全部生成完才显示,用户会以为服务挂了。流式逐字输出能大幅降低感知等待时间。

5. 部署实录与一轮实测效果:从启动到答案溯源

整套工程的运行环境是Ubuntu 22.04加一张RTX 3090 24G显卡,Python 3.10。模型加载占用的显存大约14G,向量库和API服务占用的内存大约4G,整体资源消耗可控。下面是部署过程中比较核心的几个步骤和实际测试的效果。

5.1 启动流程与资源占用

启动顺序很重要:先启动模型服务,再启动API服务。因为API服务启动时会做一次连通性检查,如果模型服务没起来,它会直接报错退出。

第一步,启动vLLM模型服务:

python -m vllm.entrypoints.openai.api_server \ --model /models/Qwen2.5-7B-Instruct \ --quantization awq \ --dtype half \ --max-model-len 8192 \ --gpu-memory-utilization 0.9 \ --port 8000

AWQ量化后模型权重大约压缩35%,显存占用降低,推理速度几乎不受影响。max-model-len这里设为8192,主要给上下文留够空间。gpu-memory-utilization设为0.9,是给推理过程中的KV Cache留出余量,设太低模型吞吐会明显下滑。

第二步,启动知识库API服务:

python api_server.py --host 0.0.0.0 --port 8001

API服务跑起来后会在8001端口监听,前端通过HTTP调用。加载Chroma向量库大约需要10秒,主要是倒排索引文件在加载时会被mmap进内存,之后查询基本是毫秒级。

5.2 三组典型测试问题与效果分析

我在一个包含产品手册、管理制度和企业文化材料的测试知识库上跑了三组问题。测试过程直接模拟真实用户的自然提问方式,而不是把文档里的原句原封不动地当问题。

第一组是事实查询型:“公司事假最多可以申请几天?”系统从制度文档中检索到相关段落,回答“连续事假最长不超过10个自然日,全年累计不超过20个自然日”,并准确标注来源是《员工考勤管理制度》。回答内容与原文一致,没有额外添油加醋。

第二组是归纳总结型:“出差报销的完整流程是怎么样的?”这类问题经常牵涉到多个不同章节的内容。系统通过重排序模型把分散在多个小节里的关键词片段都捞了出来,回答时按“申请→审批→垫付→发票提交→打款”的顺序组织成一段话,每个环节都注明了来源章节。

第三组是故意刁难的边界测试:“公司食堂的招牌菜是什么?”这个问题在现有知识库里没有任何资料支撑。系统回答“根据现有资料无法直接回答”,没有强行编造。这个表现比第一版Prompt好很多——第一版模型遇到这种问题会编出“红烧肉”之类的答案,加了“资料不足时必须明说”的约束后就好了。

6. 这几处坑,几乎每个复刻这个项目的人都会踩

整个开发过程不是一帆风顺的,有几个问题花了我不少时间排查,而且这些问题光看报错信息很难定位,要回到数据和代码逻辑里去分析。我把它们和排查思路一并写出来,希望帮你少走弯路。

6.1 向量化乱码:BGE对超长文本静默截断

第一版跑通时,我把每篇文档的前3000字符直接向量化,结果发现检索效果极差,很多明显相关的内容召回不了。排查了很久,最后定位到BGE模型在文本超过512个token时不会报错,而是静默截断——超过部分的语义信息全部丢失。这解释了为什么那些长段落召不回:向量只编码了开头部分,而关键信息可能在后半段。

这个问题在中文场景尤其隐蔽,因为512个token和512个汉字不是一个概念,实际大约对应800到1000个汉字。也就是说超过这个长度的段落,后半部分在语义上就是“隐身”的。解决方法是严格依赖chunk切分流程,确保每条向量化的文本都在模型支持的范围内,并且入库前对超长文本做二次校验。

def validate_chunk(text: str, max_tokens: int = 500): token_count = len(tokenizer.encode(text)) if token_count > max_tokens: raise ValueError(f"Chunk too long: {token_count} tokens")

6.2 检索结果看似相关,实际答非所问

第二个问题是:向量检索召回的Top-5片段看起来都“相关”,但拼出来的上下文却答非所问。比如用户问“合同终止后数据怎么处理”,捞回来的片段里有大段篇幅讲“合同终止流程”,但只有一句话提到“数据保留30天后删除”。

这就是典型的“主题相关但信息不完整”问题。我两个维度同时优化:一是把chunk_size从500降到300,让每个片段内容更聚焦;二是引入重排序模型,让真正包含答案细节的片段被顶上来。优化后这类情况的出现频率显著降低。

6.3 流式输出时引用序号错乱

最后还有一个很隐蔽的问题:流式输出过程中,模型可能在回答中间才发现需要用某个资料,于是引用序号会不按顺序出现,甚至前后矛盾。比如回答开头说“根据资料[3]”,结尾又引用了一次[1],前端展示时用户会困惑。

我后来用一种兼容性较强的策略解决:不再要求模型严格按“[1][2]”的规则插入引用,而是允许它在每个回答段落结束后统一追加“资料来源”列表。后端再把列表里的文件名映射成前端可点击的链接。这个方案牺牲了一点形式上的一致性,换来了更高的稳定性和可读性。

6.4 向量库重名导致脏数据残留

开发过程中反复重建索引,发现检索结果里经常有被删掉的旧文档片段。排查后发现是Chroma的collection名称没有变,重建时旧数据没有真正清除,只是覆盖了一部分。Chroma的PersistentClient对同名collection是“存在则复用”的逻辑,不会自动清空。

最终在重建索引前显式调用collection.delete(where={"source": source}),或者干脆删除整个本地数据库目录再重建。这个操作在配置里加了一个RESET_INDEX开关,默认关闭,需要重建时打开,避免每次都全量重建。这个坑尤其容易在频繁改文档、反复测试时踩中,建议从一开始就设计好重建逻辑。


最后分享一个我自己的经验:知识库问答这个项目的复杂度主要不在代码,而在“数据和人”的磨合上。数据决定了回答的上限,模型只是在接近这个上限。源代码只是一个工程框架,真正的好效果是靠一遍遍调整切分参数、清洗规则、Prompt写法磨出来的。建议你拿到代码后,先把一份自己最熟悉的文档放进去,反复试几个问题,看看系统是怎么理解你的文档的。这个过程会比看任何教程都更有收获。

本文还有配套的精品资源,点击获取

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

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

立即咨询