简介:这份PDF文档面向希望将大模型落地到垂直业务场景的开发者与技术支持人员,系统讲解如何用DeepSeek大模型结合RAG技术搭建本地知识库。内容以CST/ABAQUS官方文档为语料,构建“虚拟技术支持工程师”智能体,验证AI模型与行业知识库在真实业务中的响应效果。资源包共1个PDF文件,约2.9MB,篇幅紧凑,涵盖整体架构、RAG检索增强生成流程、Embedding向量化、RAGFlow智能检索以及Ollama容器化本地部署等关键环节,并给出在线与本地部署方式对比。读者可从中获得从知识库解析、向量存储到问答生成的完整方法论,理解RAG如何降低微调成本、缓解模型幻觉,并掌握敏感数据全程驻留内网的离线部署思路。目前已有897人学习,适合关注DeepSeek、RAG与本地知识库实践的技术人员参考。
1. 从一份 PDF 说起:DeepSeek 加 RAG 到底能解决什么
你可能也遇到过这种场景:公司内部攒了一堆产品手册、运维文档、合同模板,想用大模型直接问答,结果模型要么一本正经地胡说,要么根本不知道你私有的那点业务细节。把文档全塞进上下文?token 烧得心疼,长文档还会被截断。这份《DeepSeek模型+RAG技术构建本地知识库.pdf》讲的正是这条落地路径——用 DeepSeek 作为生成侧的大模型,用 RAG(检索增强生成)把私有文档变成可检索的知识源,最终搭出一个能本地跑、数据不出内网的知识库问答系统。
它适合三类人:一是手里有大量非结构化文档、想快速做内部问答的工程师;二是想搞明白 RAG 检索链路每一环怎么调、为什么召回不准的开发者;三是已经在用 DeepSeek API 或本地部署,想把它接进自己知识库的从业者。这份资料的价值不在于教你调 API,而在于把「文档切分、向量化、检索、重排、拼 prompt」这条链路拆开讲清楚,让你知道每一环的参数怎么设、坑在哪。下面我按自己复现的顺序,把这份资料里的关键点重新捋一遍。
2. RAG 链路拆解:从文档切分到向量入库的每一步
RAG 听起来玄学,拆开就是一条流水线:文档进来先切块,切完转成向量存进向量库,用户提问时把问题也转成向量去库里捞相似的块,捞出来的块拼进 prompt 交给 DeepSeek 生成答案。这条链路里任何一环出问题,最终答案都会翻车。这一章先把「入库」这半段讲透,下一章讲「检索和生成」那半段。
2.1 文档加载与切分:chunk_size 和 overlap 怎么定
切分是 RAG 里最容易被低估的一步。切太大,检索出来的块里一半是无关内容,噪声干扰生成;切太小,一个完整语义被切断,模型拿到半句话也答不对。常见做法是按字符数切,配合重叠窗口保证跨块语义连续。
from langchain.text_splitter import RecursiveCharacterTextSplitter # 中文文档按字符切,分隔符优先级从段落降到句子 splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 每块目标字符数 chunk_overlap=80, # 相邻块重叠字符数,防止语义断裂 separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, ) with open("handbook.md", encoding="utf-8") as f: raw = f.read() chunks = splitter.split_text(raw) print(f"切出 {len(chunks)} 块,首块长度 {len(chunks[0])}")这段代码的关键在separators的顺序。RecursiveCharacterTextSplitter会优先用靠前的分隔符切,切完还超长就降级到下一个分隔符。中文文档一定要把中文标点加进去,否则默认按空格切,一整段中文会被当成一个词,切出来全是超长块。chunk_size我一般从 500 起步,技术文档可以到 800,FAQ 类可以降到 300。chunk_overlap取 chunk_size 的 10% 到 20%,太小起不到衔接作用,太大又会让检索结果重复。
提示:切分前先做一次清洗,把页眉页脚、连续空行、PDF 转换残留的乱码去掉。这些噪声进了向量库,检索时会被当成有效内容召回。
2.2 向量化与入库:embedding 模型和向量库选型
切完的块要转成向量。DeepSeek 本身不提供 embedding 接口,所以向量化这一步得另找模型。常见做法是用开源的 BGE 系列或者 m3e,本地跑不花钱,中文效果也够用。向量库选 Chroma 最省事,单机持久化,几行代码就能建库。
from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma # 本地 embedding 模型,首次运行会自动下载权重 embedding = HuggingFaceEmbeddings( model_name="BAAI/bge-base-zh-v1.5", model_kwargs={"device": "cpu"}, # 有 GPU 改成 "cuda" encode_kwargs={"normalize_embeddings": True}, # 归一化后余弦相似度更稳 ) vectordb = Chroma.from_texts( texts=chunks, embedding=embedding, persist_directory="./chroma_db", # 持久化目录,重启不丢 collection_name="handbook", ) vectordb.persist() print("入库完成,当前集合向量数:", vectordb._collection.count())normalize_embeddings=True这行别省。BGE 模型归一化之后,内积等价于余弦相似度,检索打分更稳定。persist_directory指定了持久化路径,Chroma 会把向量和原文一起落盘,下次直接Chroma(persist_directory=..., embedding_function=...)加载,不用重新算。如果你文档量大,比如上万块,CPU 算 embedding 会很慢,这时候要么上 GPU,要么换成批量接口。入库完成后一定打印一下集合里的向量数,确认和切块数对得上,我见过因为编码问题静默丢块的。
2.3 元数据设计:让检索结果能溯源
光存文本不够,每块还得带上来源信息,否则检索出来你都不知道这段话出自哪份文档哪一页。Chroma 支持给每个块挂 metadata,检索时一起返回。
metadatas = [ {"source": "handbook.md", "chunk_id": i, "section": "运维规范"} for i in range(len(chunks)) ] vectordb = Chroma.from_texts( texts=chunks, embedding=embedding, metadatas=metadatas, persist_directory="./chroma_db", collection_name="handbook", )source和chunk_id是最基本的两个字段,前者用于展示引用来源,后者用于定位原文。如果文档有章节结构,把章节名也塞进 metadata,检索时可以按章节过滤,比如只在「故障处理」章节里搜。元数据设计得好,后面做引用展示和结果过滤会省很多事。这一步在资料里往往一笔带过,但实际项目里它是能不能做「可溯源问答」的分水岭。
3. 检索与生成:把召回结果喂给 DeepSeek 的正确姿势
入库只是上半场,真正决定答案质量的是检索和生成这半段。检索召回不准,DeepSeek 再强也只能拿着错误材料编;prompt 拼得不好,模型会把检索内容当耳旁风。这一章把检索策略、重排、prompt 模板和 DeepSeek 调用串起来讲。
3.1 相似度检索与 top_k 的取舍
最基础的检索就是拿问题向量去库里找最相似的 k 个块。k 太小,可能漏掉关键信息;k 太大,无关内容稀释了有效信息,还推高 token 成本。
query = "服务器磁盘满了怎么处理?" # 相似度检索,返回 top 4 docs = vectordb.similarity_search_with_score(query, k=4) for doc, score in docs: print(f"score={score:.4f} source={doc.metadata['source']}") print(doc.page_content[:80]) print("-" * 40)similarity_search_with_score会返回距离分数,Chroma 默认用 L2 距离,分数越小越相似。这里有个容易踩的坑:不同向量库、不同距离度量,分数的含义和范围都不一样,别拿一个库的阈值去套另一个库。我一般先跑一批典型问题,看召回块的分数分布,再决定要不要设阈值过滤。k的取值从 3 到 6 起步,配合后面的重排再精筛。
3.2 重排:用 rerank 把真正相关的块顶上来
向量检索是「粗筛」,它看的是语义相似,但相似不等于相关。比如你问「磁盘满了怎么办」,它可能召回一段讲「磁盘类型」的内容,语义很近但答非所问。重排模型(rerank)专门解决这个问题,它把问题和每个候选块放一起做精细打分,把真正相关的顶到前面。
from FlagEmbedding import FlagReranker reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True) # 先粗筛 10 个,再重排取前 3 candidates = vectordb.similarity_search(query, k=10) pairs = [[query, doc.page_content] for doc in candidates] scores = reranker.compute_score(pairs) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) top_docs = [doc for doc, _ in ranked[:3]]重排的代价是慢,每个候选块都要过一次模型,所以流程是「粗筛多召回、重排精筛」。k=10粗筛再取前 3,是我在文档量几千块时比较稳的配置。如果文档量特别大,粗筛阶段可以先用向量库的近似检索压到几十个,再重排。重排模型和 embedding 模型最好选同一系列的,比如都用 BGE,打分尺度更一致。
3.3 拼 prompt 调 DeepSeek:上下文模板与参数设置
检索出来的块要拼成 prompt 交给 DeepSeek。模板设计的原则是:明确告诉模型「只根据下面材料回答,材料里没有就说不知道」,并且把材料编号,方便模型引用。
import os from openai import OpenAI client = OpenAI( api_key=os.getenv("DEEPSEEK_API_KEY"), base_url="https://api.deepseek.com", # DeepSeek 兼容 OpenAI 接口 ) context = "\n\n".join( f"[{i+1}] {doc.page_content}" for i, doc in enumerate(top_docs) ) prompt = f"""你是一个严谨的技术助手。请只根据下面提供的资料回答问题。 如果资料中没有相关信息,直接回答「资料中未提及」,不要编造。 资料: {context} 问题:{query} """ resp = client.chat.completions.create( model="deepseek-chat", messages=[{"role": "user", "content": prompt}], temperature=0.2, # 知识库问答要稳,温度调低 max_tokens=800, ) print(resp.choices[0].message.content)base_url指向 DeepSeek 的兼容接口,SDK 用法和 OpenAI 一致。temperature设 0.2 左右,知识库问答不需要发挥,越低越稳。max_tokens按答案长度预期设,别设太大浪费。模板里那句「资料中未提及」很关键,它是防幻觉的第一道闸。如果你用的是本地部署的 DeepSeek,把base_url换成你本地服务的地址即可,其余不变。
注意:拼 prompt 时给每块材料加编号,生成答案后可以让模型标注引用了哪几号材料,前端就能做溯源展示。这个编号习惯我从第一次做知识库就强制保留,后面加引用功能几乎零改动。
4. 避坑与排查:RAG 效果差的五个真实原因
RAG 搭起来容易,调好难。下面这五条是我复现过程中真实踩过的,每条按「现象 → 原因 → 解决」写,你对着排查能省不少时间。
4.1 召回内容对但答案还是错
现象:检索出来的块明明包含正确答案,DeepSeek 却答偏了或者答成别的。原因通常是 prompt 里材料太多太杂,模型注意力被无关块分散,或者材料顺序把关键块埋在了中间。解决:先上重排把 top_k 压到 3 以内,再把最相关的块放在 prompt 最前面。模型对开头和结尾的内容注意力更高,中间容易被忽略,这是血泪经验。
4.2 中文文档切出来全是超长块
现象:切分后每块长度远超 chunk_size,检索时一块里混了好几个主题。原因:分隔符列表里没加中文标点,切分器找不到切点,只能整段保留。解决:把。!?;,加进 separators,并且把\n\n放在最前面优先按段落切。切完打印长度分布,超过 chunk_size 两倍的块要单独看。
4.3 相似度分数没法设阈值
现象:想用分数过滤低质量召回,结果发现分数范围飘忽,设了阈值要么全滤掉要么全留下。原因:不同向量库距离度量不同,Chroma 默认 L2 距离,分数是距离不是相似度,且和向量归一化与否强相关。解决:统一开启normalize_embeddings=True,改用余弦距离,然后跑一批标注问题看分数分布,取一个能分开正负样本的值。别照搬网上的阈值。
4.4 更新文档后检索还是旧内容
现象:文档改了重新入库,检索出来的还是老版本。原因:Chroma 的 collection 是追加写入,同名的块不会自动覆盖,旧向量还在库里。解决:更新时先按 metadata 里的 source 删除旧块再插入新块,或者干脆换一个 collection 名重建。我一般给 collection 名带上日期或版本号,重建最干净。
4.5 本地部署 DeepSeek 显存不够
现象:本地跑 DeepSeek 时显存爆了,或者响应慢到没法用。原因:模型量化等级、上下文长度、并发数都会吃显存,长 prompt 尤其明显。解决:优先用量化版本,把检索回来的上下文块数压下来,max_tokens调小。如果还是不够,生成侧走 API、检索侧本地跑,是性价比最高的折中。这块资料里没细讲,但实际部署时绕不开。
5. 进阶技巧:把命中率和可维护性再抬一档
基础链路跑通之后,真正拉开差距的是检索命中率和长期可维护性。这一章讲三个我常用的进阶手段,都是在这份资料基础上往外延的实操。
5.1 混合检索:向量加关键词双路召回
纯向量检索对专有名词、型号、错误码这类精确匹配不敏感。比如你问「ERR-5021 怎么解」,向量检索可能召回一堆讲「错误处理」的泛泛内容,就是命不中那个具体错误码。混合检索的做法是同时跑一路关键词检索(BM25),把两路结果合并去重再重排。
from rank_bm25 import BM25Okapi import jieba # 对切好的块建 BM25 索引,中文先分词 tokenized = [list(jieba.cut(c)) for c in chunks] bm25 = BM25Okapi(tokenized) query_tokens = list(jieba.cut(query)) bm25_scores = bm25.get_scores(query_tokens) bm25_top = [chunks[i] for i in bm25_scores.argsort()[-5:][::-1]] # 向量路召回 vec_top = [doc.page_content for doc in vectordb.similarity_search(query, k=5)] # 两路合并去重,再交给 rerank 精排 merged = list(dict.fromkeys(bm25_top + vec_top))BM25 那一路负责精确命中,向量那一路负责语义泛化,合并去重后候选集更全。jieba分词对中文 BM25 是必须的,不分词直接按字算,效果会差一截。合并时用dict.fromkeys去重同时保序,把两路的高分项都留住。这套组合在专有名词多的技术文档上,命中率提升很明显。
5.2 查询改写:让用户的口语问题对上文档的书面表达
用户提问往往很口语,文档却是书面语,两者向量距离可能很远。查询改写就是在检索前先把问题改写成更接近文档表达的多个版本,分别检索再合并。
rewrite_prompt = f"""把下面的问题改写成 3 个适合检索技术文档的查询语句, 每行一个,不要编号,不要解释: {query} """ resp = client.chat.completions.create( model="deepseek-chat", messages=[{"role": "user", "content": rewrite_prompt}], temperature=0.3, ) rewritten = resp.choices[0].message.content.strip().split("\n") # 每个改写版本各召回一批,合并去重 all_docs = [] for q in [query] + rewritten: all_docs.extend(vectordb.similarity_search(q, k=3))用 DeepSeek 自己做查询改写,成本很低但效果立竿见影。改写出来的多个查询覆盖了不同表达角度,召回面更广。注意改写结果要去空行、去编号,否则会污染检索。这一步会增加一次模型调用,延迟上去了,适合对准确率要求高于响应速度的场景。
5.3 效果验证:用固定问题集量化命中率
调参不能靠感觉,得有一组固定问题来量化。我一般从真实文档里挑 20 到 30 个有明确答案的问题,人工标注每个问题的正确块,然后跑检索看正确块有没有进 top_k。
| 指标 | 含义 | 目标 |
|---|---|---|
| Recall@3 | 正确块进前三的比例 | 技术文档 0.85 以上 |
| MRR | 正确块排名的倒数均值 | 越接近 1 越好 |
| 答案准确率 | 人工判断答案是否正确 | 抽样 30 题评估 |
def recall_at_k(questions, vectordb, k=3): hit = 0 for q, gold_chunk_id in questions: docs = vectordb.similarity_search(q, k=k) ids = [d.metadata["chunk_id"] for d in docs] if gold_chunk_id in ids: hit += 1 return hit / len(questions) print("Recall@3 =", recall_at_k(eval_set, vectordb, k=3))有了这个数字,你调 chunk_size、换 embedding 模型、加重排,都能看到明确涨跌,而不是凭感觉。我每次改检索链路,都强制先跑一遍这个评估集,涨了才留下,跌了就回滚。从那以后我每次动 RAG 参数都强制走一遍评估,再也没出现过「感觉变好了其实变差了」的情况。希望帮到你。
本文还有配套的精品资源,点击获取