
在实际业务场景中,通用大模型(LLM)无法直接回答基于企业内部规章、产品手册或存量PDF文档的精细化问题。虽然DeepSeek具备强大的推理能力,但在未提供上下文的情况下,仍会产生“幻觉”。
本文聚焦于本地化部署的RAG(检索增强生成)系统,选用 DeepSeek-R1 蒸馏模型(14B) 作为生成底座,并结合 BGE-M3 嵌入模型 与 Milvus 向量数据库,构建一套完全离线、高可用的知识问答系统。
技术选型依据:
+----------------------------------------------------------+
| 应用层(Gradio / API Server) |
+----------------------------------------------------------+
| 编排层(LangChain / LlamaIndex) |
| (Query Rewriting -> Multi-Recall -> Rerank) |
+----------------------------------------------------------+
| 检索层(Milvus 2.4) |
| [稠密索引: HNSW] + [稀疏索引: Sparse-BM25] |
+----------------------------------------------------------+
| 嵌入层 & 生成层(本地GPU推理) |
| BGE-M3 (Embedding) + DeepSeek-R1:14B (Generation) |
+----------------------------------------------------------+
| 数据层(本地NAS / MinIO) |
| 源文件(PDF/Word/Markdown) -> 解析 -> 分块 |
+----------------------------------------------------------+数据流向:
痛点:固定长度切分(如512字符)会破坏段落逻辑,导致检索时召回碎片化信息。
方案:采用 SemanticChunker(基于句子边界 + 嵌入相似度动态切割)。
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import PyPDFLoader, UnstructuredMarkdownLoader
import numpy as np
class SemanticChunker:
def __init__(self, embedding_model, threshold=0.65):
self.embedder = embedding_model
self.threshold = threshold # 余弦相似度阈值
self.base_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=128,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
)
def split(self, text):
base_chunks = self.base_splitter.split_text(text)
if len(base_chunks) <= 1:
return base_chunks
# 计算相邻块的embedding相似度
final_chunks = []
current_chunk = base_chunks[0]
for i in range(1, len(base_chunks)):
emb_curr = self.embedder.embed_query(base_chunks[i-1])
emb_next = self.embedder.embed_query(base_chunks[i])
sim = np.dot(emb_curr, emb_next) / (np.linalg.norm(emb_curr) * np.linalg.norm(emb_next))
if sim < self.threshold: # 语义突变,断开
final_chunks.append(current_chunk)
current_chunk = base_chunks[i]
else:
current_chunk += base_chunks[i]
if current_chunk:
final_chunks.append(current_chunk)
return final_chunks参数调优记录:
threshold 设为 0.55(避免代码与上下文割裂)。threshold 设为 0.75(保持条款内聚)。source_path、page_num、chunk_index。BGE-M3 支持同时输出 稠密向量(Dense) 和 词权重稀疏向量(Lexical)。我们利用该特性构建双路索引,无需额外部署 BM25 算法库。
from FlagEmbedding import BGEM3FlagModel
class BGEM3Embedder:
def __init__(self, model_path="/models/bge-m3"):
self.model = BGEM3FlagModel(model_path, use_fp16=True, device="cuda")
def encode_documents(self, texts):
# 返回 Dense 向量 和 Sparse 词权重
outputs = self.model.encode(texts, return_dense=True, return_sparse=True, return_colbert_vecs=False)
dense_vecs = outputs['dense_vecs']
sparse_vecs = outputs['lexical_weights'] # List[Dict[int, float]]
return dense_vecs, sparse_vecs
def encode_query(self, query):
# 查询需额外增加指令:"Represent this sentence for searching relevant passages: "
formatted_query = f"Represent this sentence for searching relevant passages: {query}"
outputs = self.model.encode(formatted_query, return_dense=True, return_sparse=True)
return outputs['dense_vecs'][0], outputs['lexical_weights'][0]Milvus 2.4 支持 Dense + Sparse 双字段在一个 Collection 中,并执行混合检索(Hybrid Search)。
Schema 定义(Python SDK):
from pymilvus import CollectionSchema, FieldSchema, DataType, Collection, connections
connections.connect(host="localhost", port="19530")
dense_field = FieldSchema(name="dense_vector", dtype=DataType.FLOAT_VECTOR, dim=1024)
sparse_field = FieldSchema(name="sparse_vector", dtype=DataType.SPARSE_FLOAT_VECTOR)
id_field = FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True)
text_field = FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535)
meta_field = FieldSchema(name="metadata", dtype=DataType.JSON)
schema = CollectionSchema(fields=[id_field, dense_field, sparse_field, text_field, meta_field])
collection = Collection("knowledge_base", schema)
# 创建索引
dense_index = {"index_type": "HNSW", "metric_type": "IP", "params": {"M": 16, "efConstruction": 200}}
sparse_index = {"index_type": "SPARSE_INVERTED_INDEX", "metric_type": "IP"} # IP 即内积
collection.create_index("dense_vector", dense_index)
collection.create_index("sparse_vector", sparse_index)
collection.load()批量插入逻辑:
def ingest_documents(chunks, embedder, collection):
texts = [chunk.page_content for chunk in chunks]
dense_list, sparse_list = embedder.encode_documents(texts)
entities = [
dense_list.tolist(), # 注意转list
sparse_list,
texts,
[chunk.metadata for chunk in chunks]
]
collection.insert([dense_vector, sparse_vector, text, metadata])
collection.flush()纯稠密检索对高频词(如“的、是”)敏感,纯稀疏检索丢失语义。我们采用 加权求和 策略:Score = α * Dense_IP + (1-α) * Sparse_IP,其中 α=0.7(业务经验值)。
def hybrid_search(query, collection, embedder, top_k=10, alpha=0.7):
dense_q, sparse_q = embedder.encode_query(query)
# Milvus 2.4 Hybrid Search 语法
search_params = {
"dense": {"metric_type": "IP", "params": {"ef": 64}},
"sparse": {"metric_type": "IP"}
}
result = collection.hybrid_search(
reqs=[
{"vector": dense_q, "anns_field": "dense_vector", "param": search_params["dense"], "limit": top_k * 2},
{"vector": sparse_q, "anns_field": "sparse_vector", "param": search_params["sparse"], "limit": top_k * 2}
],
rerank={"strategy": "weighted", "weights": [alpha, 1-alpha]},
limit=top_k,
output_fields=["text", "metadata"]
)
return result踩坑记录:
dict[int, float] 格式,且 value 需为 TF-IDF 风格权重,不能归一化。BGE-M3 默认输出的 lexical_weights 恰好满足。alpha 过大会导致纯关键词匹配失效,建议针对不同 Collection 做 A/B 测试。我们最终选择 vLLM 作为推理后端(相比 Ollama,吞吐量提升 2.3 倍),启动命令:
python -m vllm.entrypoints.openai.api_server \
--model /models/deepseek-r1-14b \
--served-model-name deepseek-r1 \
--tensor-parallel-size 1 \
--max-model-len 8192 \
--enforce-eager \
--kv-cache-dtype fp8_e4m3 # 启用 FP8 缓存,显存占用降低 40%RAG 上下文易引入无关噪音,必须通过 System Prompt 强制 DeepSeek 遵循“仅依据上下文回答”原则。
SYSTEM_PROMPT = """
你是一个严谨的知识库助手。你必须严格基于以下【上下文】内容回答用户问题。
规则:
1. 如果上下文中没有提及任何相关信息,请直接回复“根据当前知识库,无法找到相关信息”,严禁编造。
2. 如果上下文中存在信息,请提取最相关的部分进行简洁、条理清晰的回答,并标注信息来源(引用文件名)。
3. 回答中禁止出现“根据我的理解”、“我认为”等主观表述。
"""
def build_prompt(query, context_chunks):
context_text = "\n\n---\n\n".join([
f"【来源:{chunk['metadata']['source']}】{chunk['text']}"
for chunk in context_chunks
])
return f"{SYSTEM_PROMPT}\n\n【上下文】\n{context_text}\n\n【用户问题】\n{query}\n\n【回答】"import openai
client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
def generate_response(query, retrieved_chunks):
prompt = build_prompt(query, retrieved_chunks)
response = client.chat.completions.create(
model="deepseek-r1",
messages=[{"role": "user", "content": prompt}],
temperature=0.1, # 低温度减少发散
top_p=0.9,
max_tokens=2048
)
return response.choices[0].message.content为验证架构有效性,我们以 1000 份技术文档(约 5GB 纯文本)为测试集,人工标注 500 对 Q&A。
策略组合 | Hit Rate (Top-5) | 生成答案BLEU-4 | 平均延迟(ms) |
|---|---|---|---|
仅固定长度分块 + 稠密检索 | 72.3% | 0.31 | 380 |
语义分块 + 稠密检索 | 81.5% | 0.39 | 410 |
语义分块 + 混合检索 (α=0.7) | 89.2% | 0.46 | 560 |
语义分块 + 混合检索 + Rerank(BGE-Reranker) | 91.0% | 0.47 | 920 |
结论:混合检索带来了 7.7% 的 Hit Rate 提升,虽然延迟增加 150ms,但在本地千兆网内可接受。Rerank 延迟过高且收益仅 1.8%,我们将其下线,改为提升 top_k 从 5 到 10 来弥补。
DeepSeek-R1:14B(FP16)需要约 28GB 显存,BGE-M3 约 2.5GB。在单卡 A10G(24GB)上无法共存。我们的方案:
accelerate 将 BGE-M3 加载至 CPU,推理时通过 device_map="auto" 动态分配。根因:PyPDF2 无法解析字体子集映射。
方案:替换为 pypdfium2 + pdfplumber,提取文本后保留 <formula> 占位符,并在分块时保留前后 2 个字符上下文。
根因:高频通用模板文本(如“版权所有,翻录必究”)污染了向量空间。
方案:在离线 ETL 中增加 MinHash 去重 和 TextRank 关键句筛选,过滤低于信息熵阈值的段落。代码实现:
from math_utils import shannon_entropy
def filter_noise(text, min_entropy=4.5):
# 中文汉字 Unicode 范围,计算信息熵
chars = [c for c in text if '\u4e00' <= c <= '\u9fff']
if len(chars) < 10:
return False
entropy = shannon_entropy(''.join(chars))
return entropy > min_entropy根因:DeepSeek-R1 为推理模型,默认会输出 <think> 标签内的推理链。
方案:在 System Prompt 中加入 "严禁输出 <think> 标签及内部推理过程",并在后端通过正则过滤 re.sub(r'<think>.*?</think>', '', response) 进行兜底清洗。
我们将 RAG 评估纳入 GitLab CI 流程:
评估脚本核心逻辑:
def evaluate(retriever, qa_pairs):
hits = 0
for q, a, relevant_id in qa_pairs:
res = retriever.search(q, top_k=5)
if relevant_id in [r.id for r in res]:
hits += 1
return hits / len(qa_pairs)本文系统性地拆解了基于 DeepSeek 的本地 RAG 构建全流程,重点解决了中文技术文档场景下的 语义分块、双路混合检索 与 本地显存受限部署 三大核心难题。该方案已在我们的内部运维知识库中稳定运行 3 个月,问答准确率达 92.3%。
下一步演进方向:
附录:核心依赖版本清单
torch==2.1.2
vllm==0.5.4
pymilvus==2.4.5
FlagEmbedding==1.2.11
langchain==0.3.7
pypdfium2==4.30.0本文所有代码片段均已脱敏,在实际部署时可根据硬件资源调整量化策略(Q4_K_M / Q5_K_M)。如有关于稀疏向量重排权重优化的疑问,欢迎评论区交流具体的业务场景数据。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。