点击下方卡片,关注“人工智能陈小白”
视觉/大模型/图像重磅干货,第一时间送达!
RAG(Retrieval-Augmented Generation,检索增强生成) 是一种结合检索和生成的 AI 技术架构。它的核心思想是:
用户提问 → 检索相关知识 → 大模型生成答案为什么需要 RAG?
RAG 的工作流程:
什么是嵌入模型(Embedding Model)?
嵌入模型的作用是将文本转换为固定长度的向量(数字数组)。转换后的向量能够捕捉文本的语义信息——语义相似的文本,其向量在空间中的距离也更接近。
文本:"苹果是一种水果" → [0.1, -0.5, 0.8, ..., 0.3] (1024 个数字)文本:"香蕉是一种水果" → [0.2, -0.4, 0.7, ..., 0.4] (1024 个数字)这两个向量会很相似BGE-large-zh-v1.5 特点:
为什么选择 BGE?
什么是大语言模型(LLM)?
大语言模型是基于海量文本数据训练的深度学习模型,能够理解和生成自然语言文本。它可以完成回答问题、写作、翻译、编程等多种任务。
DeepSeek-LLM-7B-base 特点:
4bit 量化技术:
原始 7B 模型需要约 14GB 显存,通过量化可以降低资源需求:
本项目使用 NF4(4-bit Normal Float) 量化,在保持较好生成质量的同时,使 RTX 4090(24GB 显存)可以流畅运行。
为什么选择 DeepSeek-LLM?
什么是向量数据库?
向量数据库专门用于存储和检索向量数据。它支持近似最近邻搜索(ANN),可以在百万级向量中快速找到最相似的几个向量。
Qdrant 特点:
为什么选择 Qdrant?
| pdfplumber | ||
| python-docx | ||
| chardet | ||
| transformers | ||
| torch | ||
| bitsandbytes |
工作流程说明:
离线阶段(一次性或定期执行):
在线阶段(用户提问时):
本系统适用于以下场景:
✅ 企业知识库问答:员工可以快速查询公司制度、产品文档✅ 个人文档管理:快速定位笔记、论文、报告中的信息✅ 法律法规查询:基于法律条文和案例的智能问答✅ 医疗文献检索:从医学论文中提取关键信息✅ 教育培训:基于教材的自动答疑系统

MAX_CHUNK_LENGTH = 200# 每段最多 200 字defsplit_text_by_semantic(text):"""按句号分割,合并为不超过 MAX_CHUNK_LENGTH 的语义片段""" sentences = [s.strip() + "。"for s in text.split("。") if s.strip()] semantic_chunks = [] current_chunk = ""for sent in sentences:iflen(current_chunk) + len(sent) > MAX_CHUNK_LENGTH:if current_chunk: semantic_chunks.append(current_chunk) current_chunk = sentelse: current_chunk += sentif current_chunk: semantic_chunks.append(current_chunk)return semantic_chunksdefread_pdf(file_path):"""PDF 解析,按页提取文本并分段"""with pdfplumber.open(file_path) as pdf:for page_num, page inenumerate(pdf.pages, start=1): text = page.extract_text()if text and text.strip(): chunks = split_text_by_semantic(text.strip())for chunk_idx, chunk inenumerate(chunks, start=1): pdf_content.append({"text": chunk,"source": {"file_name": os.path.basename(file_path),"page": page_num,"chunk": chunk_idx,"type": "pdf" } })return pdf_contentdefread_word(file_path):"""Word 文档解析,按段落提取""" doc = Document(file_path)for para_num, paragraph inenumerate(doc.paragraphs, start=1): text = paragraph.textif text and text.strip(): chunks = split_text_by_semantic(text.strip())# ... 类似 PDF 的结构化存储defread_txt(file_path):"""TXT 文件解析,自动检测编码"""withopen(file_path, 'rb') as f: result = chardet.detect(f.read()) encoding = result['encoding'] or'utf-8'# ... 读取并分段###2.2 向量库初始化与幂等检查
definit_qdrant():"""初始化 Qdrant 客户端,清理锁文件""" qdrant_db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "qdrant_db")# 清理可能存在的旧锁文件 lock_file = os.path.join(qdrant_db_path, ".lock")if os.path.exists(lock_file):try: os.remove(lock_file)print(f"已清理旧的锁文件:{lock_file}")except Exception as e:print(f"警告:无法删除锁文件 {e}") client = QdrantClient(path=qdrant_db_path)return clientdeffull_pipeline():# 检查集合是否已存在且有数据 collection_exists = qdrant_client.collection_exists(COLLECTION_NAME) vectors_exist = Falseif collection_exists:try: collection_info = qdrant_client.get_collection(COLLECTION_NAME)if collection_info.points_count > 0: vectors_exist = Trueprint(f"检测到已有向量数据({collection_info.points_count}条),跳过向量生成步骤")except Exception as e:print(f"检查集合信息时出错:{e}")✅ 自动检测已有向量:避免重复计算,节省时间✅ 清理锁文件:防止 Qdrant 数据库锁定问题✅ 幂等性设计:首次运行完整流程,后续运行跳转向量生成
BGE_LOCAL_PATH = "/root/autodl-fs/class-2/bge-large-zh-v1.5"VECTOR_DIM = 1024defload_bge_model():"""加载本地BGE模型"""print(f"\n【加载本地BGE模型】路径:{BGE_LOCAL_PATH}...")try: tokenizer = BgeTokenizer.from_pretrained(BGE_LOCAL_PATH, local_files_only=True) model = AutoModel.from_pretrained(BGE_LOCAL_PATH, local_files_only=True) device = "cuda"if torch.cuda.is_available() else"cpu" model = model.to(device)print(f"BGE模型加载完成!运行设备:{device}")return tokenizer, model, deviceexcept Exception as e:raise Exception(f"本地BGE 加载失败:{str(e)}")defgenerate_embeddings(text_segments, tokenizer, model, device, batch_size=32):"""分批生成向量,避免 GPU 内存不足""" texts = [seg["text"] for seg in text_segments]print(f"\n正在生成{len(texts)}个语义片段的 BGE 向量...(批量大小:{batch_size})", flush=True) embeddings_with_info = []# 分批处理 total_batches = (len(texts) + batch_size - 1) // batch_sizefor batch_idx inrange(total_batches): start_idx = batch_idx * batch_size end_idx = min((batch_idx + 1) * batch_size, len(texts)) batch_texts = texts[start_idx:end_idx]# 生成本批次的向量 batch_embeddings = get_bge_embedding(batch_texts, tokenizer, model, device)# 添加到结果列表for i, emb inenumerate(batch_embeddings): global_idx = start_idx + i embeddings_with_info.append({"text": text_segments[global_idx]["text"],"vector": emb,"source": text_segments[global_idx]["source"] })print(f"已处理 {end_idx}/{len(text_segments)} 个片段(批次 {batch_idx + 1}/{total_batches})", flush=True)# 清理 GPU 缓存 torch.cuda.empty_cache()return embeddings_with_infodefget_bge_embedding(texts, tokenizer, model, device):"""获取单个批次的 BGE 向量""" inputs = tokenizer( texts, padding=True, truncation=True, max_length=512, return_tensors="pt" ).to(device)with torch.no_grad(): outputs = model(**inputs)# 使用 CLS token 的隐藏状态作为句子嵌入 embeddings = outputs.last_hidden_state[:, 0, :].cpu().numpy()# L2 归一化 embeddings = embeddings / (embeddings ** 2).sum(axis=1, keepdims=True) ** 0.5return embeddings.tolist()defcreate_qdrant_collection(client):"""创建或重建集合"""if client.collection_exists(COLLECTION_NAME): client.delete_collection(COLLECTION_NAME) client.create_collection( collection_name=COLLECTION_NAME, vectors_config=VectorParams(size=VECTOR_DIM, distance=Distance.COSINE) )definsert_vectors_to_qdrant(client, embeddings_with_info):"""插入向量到 Qdrant""" points = []for idx, item inenumerate(embeddings_with_info):# 验证向量维度iflen(item["vector"]) != VECTOR_DIM:raise ValueError(f"第{idx}个向量维度错误,应为{VECTOR_DIM}维(实际{len(item['vector'])}维)") points.append(PointStruct(id=idx, vector=item["vector"], payload={"text_content": item["text"], "source_info": item["source"]} )) client.upsert(collection_name=COLLECTION_NAME, points=points)print(f"已向 Qdrant 插入{len(points)}个 BGE 向量片段")###2.5 DeepSeek-LLM 加载
defload_local_llm():"""加载本地 DeepSeek-LLM 模型(4bit 量化)""" model_path = "/root/models/deepseek-llm-7b-base/deepseek-ai/deepseek-llm-7b-base" quantization_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_double_quant=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16 )print(f"\n正在加载本地 LLM:{model_path}")try:# 加载 tokenizer,禁用 fast tokenizer 以避免依赖问题 tokenizer = AutoTokenizer.from_pretrained( model_path, local_files_only=True, use_fast=False# 使用慢速 tokenizer,减少依赖 )if tokenizer.pad_token isNone: tokenizer.pad_token = tokenizer.eos_token# 加载模型 model = AutoModelForCausalLM.from_pretrained( model_path, quantization_config=quantization_config, device_map="auto", trust_remote_code=True, local_files_only=True )print(f"LLM 加载完成,运行设备:{model.device}")return tokenizer, modelexcept FileNotFoundError: error_msg = f"""⚠️ 模型文件未找到:{model_path}💡 请先下载模型文件,方法: 1. 运行命令:cd /root/autodl-fs/class-2 && python download_deepseek.py 2. 或手动从 modelscope 下载:from modelscope import snapshot_download; snapshot_download('deepseek-ai/deepseek-llm-7b-base', cache_dir='/root/models/deepseek-llm-7b-base')"""raise Exception(error_msg)defretrieve_similar_segments(client, query, tokenizer, model, device):"""检索与问题最相似的文档片段"""# 将问题转换为向量 query_embedding = get_bge_embedding([query], tokenizer, model, device)[0]# 在 Qdrant 中搜索 search_result = client.query_points( collection_name=COLLECTION_NAME, query=query_embedding, limit=2, # 返回前 2 个最相似的结果 with_payload=True# 同时返回原始文本和来源信息 )print(f"\n【调试】问题'{query}'的检索结果:")for i, hit inenumerate(search_result.points, 1):print(f"第{i}个片段:相似度{hit.score:.3f} | 文本:{hit.payload['text_content']}")# 过滤低相似度结果(阈值 0.45) similar_segments = [ {"text": hit.payload["text_content"],"source": hit.payload["source_info"],"similarity": round(hit.score, 3) }for hit in search_result.points if hit.score >= 0.45 ]return similar_segments if similar_segments else"未找到与问题相关的文档片段"优化前的 Prompt:
prompt = f"""问题:{query}片段:{key_text}输出要求:1. 第一行写"答案:",后面跟从片段中提取的完整答案(至少 10 个字);2. 第二行写"信息来源:",后面跟"{source_str}"(直接复制,不要改)。"""优化后的 Prompt:
prompt = f"""请根据提供的片段回答问题。问题:{query}片段内容:{key_text}请严格按照以下格式输出:答案:[从片段中提取的完整答案,至少 15 个字]信息来源:{source_str}"""defgenerate_answer_with_source(query, similar_segments, llm_tokenizer, llm_model): top_segment = similar_segments[0] key_text = top_segment["text"]# 生成来源字符串(区分文件类型)if top_segment["source"]["type"] == "pdf": source_str = f"《{top_segment['source']['file_name']}》PDF 第{top_segment['source']['page']}页(片段{top_segment['source']['chunk']})"elif top_segment["source"]["type"] == "docx": source_str = f"《{top_segment['source']['file_name']}》Word 第{top_segment['source']['paragraph']}段(片段{top_segment['source']['chunk']})"else: # TXT source_str = f"《{top_segment['source']['file_name']}》TXT(片段{top_segment['source']['chunk']})"# 生成配置 inputs = llm_tokenizer( prompt, return_tensors="pt", truncation=False, padding=True ).to(llm_model.device)with torch.no_grad(): outputs = llm_model.generate( **inputs, max_new_tokens=200, # 足够长的输出长度 temperature=0.4, # 较低温度,提升稳定性 do_sample=True, eos_token_id=llm_tokenizer.eos_token_id, pad_token_id=llm_tokenizer.pad_token_id, no_repeat_ngram_size=2# 避免重复输出 )# 解析并清理输出 answer = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)# 【关键修复 1】提取模型生成的部分(去掉 prompt)if prompt in answer: generated_text = answer[len(prompt):].strip()else: generated_text = answer.strip() answer_lines = [line.strip() for line in generated_text.split("\n") if line.strip()]# 【关键修复 2】分别提取答案行和来源行 answer_content = None source_content = Nonefor line in answer_lines:if line.startswith("答案:") andlen(line) > 5: answer_content = lineelif line.startswith("信息来源:"): source_content = line# 【关键修复 3】兜底机制:如果模型未生成答案,使用检索到的片段内容ifnot answer_content: answer_content = f"答案:{key_text[:100]}..."ifnot source_content: source_content = f"信息来源:{source_str}"returnf"{answer_content}\n{source_content}"【用户问题】:刘备、关羽、张飞在桃园结义时立下了什么誓言?
【用户问题】:鲁智深在相国寺看管菜园时,如何震慑附近的泼皮无赖?
【用户问题】:孙悟空因什么事被如来佛祖压在五行山下?【检索结果】:第 1 个片段:相似度 0.648 | 文本:虽一度被太白金星招安封为齐天大圣,掌管蟠桃园,却因偷吃蟠桃、盗饮玉液琼浆、窃走太上老君金丹,再次大闹天宫,无人能敌。最终,玉皇大帝请来西天如来佛祖,孙悟空被压在五行山下,历经五百年风吹雨打,等待取经人前来解救。
现象:
RuntimeError: CUDA out of memory. Tried to allocate xxx MiB原因: 大批量向量生成导致显存不足
解决方案:
defgenerate_embeddings(text_segments, tokenizer, model, device, batch_size=32):# 分批处理,每批次 32 个 total_batches = (len(texts) + batch_size - 1) // batch_sizefor batch_idx inrange(total_batches):# ... 生成本批次向量 torch.cuda.empty_cache() # 每批次后清理 GPU 缓存现象:
Exception: Lock file .lock exists. Another process may be using the database.原因: 异常退出导致 .lock 文件残留
解决方案:
definit_qdrant(): qdrant_db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "qdrant_db") lock_file = os.path.join(qdrant_db_path, ".lock")if os.path.exists(lock_file):try: os.remove(lock_file)print(f"已清理旧的锁文件:{lock_file}")except Exception as e:print(f"警告:无法删除锁文件 {e}")现象:
FileNotFoundError: Can't find config for 'xxx' at '/path/to/model'原因:from_pretrained 路径配置错误或未添加本地加载参数
解决方案:
# 正确做法tokenizer = AutoTokenizer.from_pretrained( model_path, local_files_only=True, # 必须添加 use_fast=False)model = AutoModelForCausalLM.from_pretrained( model_path, local_files_only=True, # 必须添加# ... 其他配置)现象:
答案:未提取到相关答案原因: LLM 未按 Prompt 要求的格式输出
解决方案:
prompt = f"""请根据提供的片段回答问题。问题:{query}片段内容:{key_text}请严格按照以下格式输出:答案:[从片段中提取的完整答案,至少 15 个字]信息来源:{source_str}"""if prompt in answer: generated_text = answer[len(prompt):].strip()else: generated_text = answer.strip()ifnot answer_content: answer_content = f"答案:{key_text[:100]}..."##5. 总结与展望
# 安装依赖pip install transformers bitsandbytes acceleratepip install qdrant-clientpip install pdfplumber python-docx chardetpip install torch --index-url https://download.pytorch.org/whl/cu118# 方式 1:使用 ModelScope 下载from modelscope import snapshot_downloadsnapshot_download('deepseek-ai/deepseek-llm-7b-base', cache_dir='/root/models/deepseek-llm-7b-base')# 方式 2:使用辅助脚本cd /root/autodl-fs/class-2python download_deepseek.py
