#!/usr/bin/env python3 """ RAG 全链路优化系统 — 员工手册智能问答 ============================================ 8 项核心优化: 1. 使用 XX科技有限公司员工手册.md 作为测试文档 2. 检索器封装为 Agent 工具,自动判断是否需要检索 3. 使用本地 BGE-Large-Zh-v1.5 embedding 模型 (1024d) 4. 语义文本切块 (SemanticChunker, percentile=60) 5. 多路召回: BM25 / 向量 / 混合(Ensemble) / 多查询(MultiQuery) 6. 查询增强: 查询重写 + HyDE 7. BGE-Reranker 精排 8. RAG 效果评估: 测试集生成 → Hit@K/MRR/Recall → 幻觉率 → Bad Case 分析 运行方式: python copy_rag_chain.py """ # ============================================================================ # Section 1: 导入 + 路径常量 + 配置常量 # ============================================================================ from __future__ import annotations import json import os import re import sys import shutil import warnings from pathlib import Path from typing import Optional, Union from collections import defaultdict # --- 抑制无关日志 --- warnings.filterwarnings("ignore", category=DeprecationWarning) os.environ.setdefault("GRPC_VERBOSITY", "NONE") os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") # --- 加载 .env --- from dotenv import load_dotenv load_dotenv(Path(__file__).resolve().parent.parent / ".env") # --- 路径常量 --- _PROJECT_ROOT = Path(__file__).resolve().parent.parent _CURRENT_DIR = Path(__file__).resolve().parent _MODELS_DIR = _PROJECT_ROOT / "models" _BGE_PATH = _MODELS_DIR / "BAAI" / "bge-large-zh-v1.5" _RERANKER_PATH = _MODELS_DIR / "BAAI" / "bge-reranker-base" _DATA_PATH = _PROJECT_ROOT / "XX科技有限公司员工手册.md" _CHROMA_DIR = _CURRENT_DIR / "chroma_db_bge" # --- 配置常量 --- # 各参数取值理由:经过员工手册(~7600 字、62 个条款)实测调优 CHUNK_PERCENTILE = 45 # 语义切块阈值:45 在粒度和语义完整间取平衡(值越小切块越细) RETRIEVAL_K = 10 # 召回阶段返回数量:取 10 是精度/速度折中(太小漏召回,太大稀释 Reranker) DEFAULT_K = 4 # 最终给 LLM 的文档数:4 篇足以覆盖一个问题,再多会撑爆上下文窗口 ENSEMBLE_WEIGHTS = [0.4, 0.6] # 混合检索权重 [BM25, 向量]:政策文档关键词重要,BM25 提到 0.4 MULTI_QUERY_VARIANTS = 3 # 多查询变体数:3 个变体已足够覆盖同义词,再多边际效益递减 N_EVAL_QA = 25 # 评估测试集大小:覆盖全部 16 章的最少问题数 RRF_K = 60 # RRF 平滑参数:业界标准值,避免排名靠前的文档过度霸占分数 # --- 确保根目录在 sys.path 中 --- sys.path.insert(0, str(_PROJECT_ROOT)) # ============================================================================ # Section 2: 模型初始化 # ============================================================================ # --- 2a. Embedding 模型 --- class SentenceTransformerEmbeddings: """将 SentenceTransformer 包装为 LangChain Embeddings 接口。 支持 BGE 系列模型的 query_prefix 机制: - embed_query: 查询前加前缀「为这个句子生成表示以用于检索相关文章:」 - embed_documents: 文档不加前缀(BGE 官方推荐用法) """ def __init__(self, model_path: str, device: str = "cpu", query_prefix: str = ""): from sentence_transformers import SentenceTransformer self._model = SentenceTransformer(model_path, device=device) self._query_prefix = query_prefix def embed_documents(self, texts: list[str]) -> list[list[float]]: # 文档侧不加前缀 embeddings = self._model.encode( texts, normalize_embeddings=True, show_progress_bar=False, batch_size=32, ) return embeddings.tolist() def embed_query(self, text: str) -> list[float]: # 查询侧加前缀(BGE 模型关键!不加前缀效果掉 10%+) if self._query_prefix: text = self._query_prefix + text embedding = self._model.encode( [text], normalize_embeddings=True, show_progress_bar=False, ) return embedding[0].tolist() _embedding_model: Optional[SentenceTransformerEmbeddings] = None # BGE 系列模型的 query_prefix(官方推荐,不加的话检索精度明显下降) _BGE_QUERY_PREFIX = "为这个句子生成表示以用于检索相关文章:" def init_embedding_model() -> SentenceTransformerEmbeddings: """初始化 BGE-Large-Zh-v1.5 embedding 模型(1024 维)。 BGE-Large-Zh-v1.5 是 BAAI 中文优化版,MTEB 中文榜单榜首。 相比之前的 Corom (768d),维度更高、中文理解更强。 关键: 查询时必须加 query_prefix,文档不加。 """ global _embedding_model if _embedding_model is not None: return _embedding_model if not _BGE_PATH.exists(): raise FileNotFoundError(f"BGE 模型未找到: {_BGE_PATH}") print(f"[Embedding] 加载 BGE-Large-Zh-v1.5: {_BGE_PATH}") _embedding_model = SentenceTransformerEmbeddings( str(_BGE_PATH), device="cpu", query_prefix=_BGE_QUERY_PREFIX, ) print(f"[Embedding] 加载完成,维度: 1024 (含 query_prefix)") return _embedding_model # --- 2b. LLM 客户端 --- _llm = None def get_llm(): """获取 LLM 客户端(复用根目录 llm_client.py 的 get_llm())。""" global _llm if _llm is not None: return _llm from llm_client import get_llm as _root_get_llm _llm = _root_get_llm() print(f"[LLM] 客户端初始化完成") return _llm # --- 2c. Reranker 模型 --- _reranker = None def download_reranker() -> Optional[str]: """从 ModelScope 下载 BGE-Reranker-Base。 返回模型路径,如果下载失败返回 None。 已下载则直接返回路径,跳过下载。 """ config_file = _RERANKER_PATH / "config.json" if config_file.exists(): print(f"[Reranker] 模型已存在: {_RERANKER_PATH}") return str(_RERANKER_PATH) print(f"[Reranker] 正在从 ModelScope 下载 BGE-Reranker-Base (~1.1GB)...") print(f"[Reranker] 目标路径: {_RERANKER_PATH}") try: from modelscope import snapshot_download _RERANKER_PATH.parent.mkdir(parents=True, exist_ok=True) downloaded = snapshot_download( "BAAI/bge-reranker-base", cache_dir=str(_RERANKER_PATH.parent), ) # snapshot_download 返回缓存路径,可能不是我们指定的路径 # 如果路径不同,做复制 downloaded_path = Path(downloaded) if downloaded_path != _RERANKER_PATH: if _RERANKER_PATH.exists(): shutil.rmtree(str(_RERANKER_PATH)) shutil.copytree(str(downloaded_path), str(_RERANKER_PATH)) print(f"[Reranker] 下载完成: {_RERANKER_PATH}") return str(_RERANKER_PATH) except Exception as e: print(f"[Reranker] 下载失败: {e}") print(f"[Reranker] 将跳过精排步骤(不影响其他功能)") return None def init_reranker() -> Optional[object]: """初始化 CrossEncoder reranker。 返回 CrossEncoder 实例,如果不可用返回 None。 """ global _reranker if _reranker is not None: return _reranker if _reranker != "NONE" else None model_path = download_reranker() if model_path is None: _reranker = "NONE" return None try: from sentence_transformers import CrossEncoder print(f"[Reranker] 加载模型: {model_path}") _reranker = CrossEncoder(model_path, device="cpu") print(f"[Reranker] 加载完成") return _reranker except Exception as e: print(f"[Reranker] 加载失败: {e}") _reranker = "NONE" return None # ============================================================================ # Section 3: 文档加载 + 数据清洗 + 章节切块 (优化1, 优化4) # ============================================================================ def clean_markdown_escapes(text: str) -> str: """清理 Markdown 转义符。 Markdown 文件中常见 \-、\.、\* 等转义符,会污染关键词匹配和 Reranker 相关度计算(例如 "试用期\\-" 和 "试用期" 在 BM25 中是不同 token)。 清理示例: 9:00\\-18:00 → 9:00-18:00 1\\.5小时 → 1.5小时 V2\\.0 → V2.0 """ # 去掉反斜杠转义符:匹配 \<符号> 并只保留 <符号> # 字符类中的特殊符号需要逐个列出:- . * ? + [ ] ( ) { } ! ^ $ | # text = re.sub(r'\\([\-.*?+\[\](){}!^$|#])', r'\1', text) # 合并多余空行(>2 个连续换行):避免 chunks 中出现大段空白 text = re.sub(r'\n{3,}', '\n\n', text) return text def load_and_chunk() -> list: """加载员工手册 Markdown 文件,按章节结构切块。 优化1: 使用 XX科技有限公司员工手册.md 优化4: 按 Markdown 章节(## 第X条)切块,保证每条语义完整 切块策略: 1. 读取 .md 文件全部文本 2. **数据清洗**:清理 Markdown 转义符(9:00\\-18:00 → 9:00-18:00) 3. 跳过目录,从 # 第一章 开始 4. 按章节标题(## 第X条)切块,每个条款作为一个独立 chunk 5. 过长的条款二次切分(保留章节上下文前缀) 为什么不用 SemanticChunker? → SemanticChunker 在文档结构清晰时(如员工手册有章节)反而把相关内容 合并过度,导致向量被稀释。按 Markdown 标题切能保证每个 chunk 语义完整。 Returns: list[Document]: 文档块列表,每个包含 page_content 和 metadata """ from langchain_core.documents import Document from langchain_text_splitters import RecursiveCharacterTextSplitter print(f"\n{'='*60}") print(f"[文档加载] 读取: {_DATA_PATH.name}") # Step 1: 读取文件 raw_text = _DATA_PATH.read_text(encoding="utf-8") print(f"[文档加载] 原始长度: {len(raw_text)} 字符") # Step 2: 数据清洗 - 清理 Markdown 转义符 cleaned_text = clean_markdown_escapes(raw_text) print(f"[文档加载] 清洗后长度: {len(cleaned_text)} 字符") # Step 3: 跳过目录,从正文开始 body_start = cleaned_text.find("# 第一章") if body_start == -1: body_start = cleaned_text.find("# 第") if body_start > 0: body_text = cleaned_text[body_start:] else: body_text = cleaned_text # Step 4: 按章节结构切块 # 每个 "## 第X条" 作为一个独立的 chunk,保证语义完整 chunks = [] current_chapter = "" current_section = "" current_section_lines = [] def flush_section(): """保存当前 section 为一个 Document。""" if not current_section or not current_section_lines: return content = "\n".join(current_section_lines).strip() if len(content) < 15: return # 跳过纯符号行 if re.match(r"^[\s\-—#*=_.]+$", content): return # 附加上下文前缀:让 embedding 知道这段文字属于哪个章节 full_content = f"【{current_chapter} - {current_section}】\n{content}" chunks.append(Document( page_content=full_content, metadata={ "source": _DATA_PATH.name, "chapter": current_chapter, "section": current_section, } )) for line in body_text.split("\n"): # 检测章标题 (# 第X章 ...) — 一级标题,更新当前所在章 chap_match = re.match(r"^#\s+(第[一二三四五六七八九十\d]+章\s*\S.*)", line) if chap_match: flush_section() # 先保存上一节(章切换意味着上一节结束) current_chapter = chap_match.group(1).strip() current_section = "" # 进入新章时清空 section,等遇到 ## 才开始记录 current_section_lines = [] continue # 检测条标题 (## 第X条 ...) — 二级标题,每个条款是一个独立 chunk sec_match = re.match(r"^##\s+(第[一二三四五六七八九十\d]+条\s*\S.*)", line) if sec_match: flush_section() # 先保存上一节(条款切换意味着上一条结束) current_section = sec_match.group(1).strip() current_section_lines = [] continue # 跳过分隔线 (---):Markdown 的水平分隔符,对内容无意义 if re.match(r"^---+\s*$", line): continue # 收集正文:只有在已进入某个 section 后才开始收集 # (目录、文档头部说明等非条款内容会被跳过) if current_section: stripped = line.strip() if stripped: # 跳过空行 current_section_lines.append(stripped) flush_section() # 保存最后一节(循环结束后内存中还残留最后一节未写入) print(f"[文档加载] 按章节切块完成: {len(chunks)} 个条款") # Step 5: 对过长的 section 二次切分(保留章节上下文) # 防止单个条款过长(>1200 字符)稀释向量 final_chunks = [] sub_splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=100, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] ) for c in chunks: if len(c.page_content) > 1200: sub_chunks = sub_splitter.split_documents([c]) final_chunks.extend(sub_chunks) else: final_chunks.append(c) print(f"[文档加载] 最终块数量: {len(final_chunks)} 个 " f"(过长条款已二次切分)") if len(final_chunks) < 10: print(f"[文档加载] 警告: 块数量过少 ({len(final_chunks)})") return final_chunks # ============================================================================ # Section 4: ChromaDB 向量存储 (优化3) # ============================================================================ def build_vectorstore(chunks: list) -> object: """构建 ChromaDB 向量存储。 使用 BGE-Large-Zh-v1.5 embedding 模型 (1024d),collection 配置余弦相似度。 每次调用强制重建(开发阶段)。 """ from langchain_community.vectorstores import Chroma print(f"\n[向量存储] 构建 ChromaDB ({len(chunks)} 个文档)") embedding_model = init_embedding_model() # 清理旧数据 if _CHROMA_DIR.exists(): print(f"[向量存储] 清理旧数据库: {_CHROMA_DIR}") shutil.rmtree(str(_CHROMA_DIR)) vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding_model, collection_metadata={"hnsw:space": "cosine"}, persist_directory=str(_CHROMA_DIR), ) print(f"[向量存储] 构建完成,持久化到: {_CHROMA_DIR}") return vectorstore def get_vectorstore(chunks: list) -> object: """获取向量存储(优先加载已有,否则重建)。""" from langchain_community.vectorstores import Chroma sqlite_file = _CHROMA_DIR / "chroma.sqlite3" if sqlite_file.exists(): embedding_model = init_embedding_model() try: print(f"[向量存储] 从 {_CHROMA_DIR} 加载已有数据库") return Chroma( persist_directory=str(_CHROMA_DIR), embedding_function=embedding_model, collection_metadata={"hnsw:space": "cosine"}, ) except Exception as e: print(f"[向量存储] 加载失败 ({e}),将重建") return build_vectorstore(chunks) # ============================================================================ # Section 5: 多路召回系统 (优化5) # ============================================================================ _bm25_retriever = None _vector_retriever = None _ensemble_retriever = None _mq_retriever = None def _jieba_tokenize(text: str) -> list[str]: """使用 jieba 对中文文本分词。 BM25Retriever 默认用英文空白分词器,遇到中文时会将整段文本当作一个 token, 导致完全无法匹配关键词(不同查询返回相同结果)。 用 jieba 分词后,"试用期是几个月" 会被切成 ["试用期", "是", "几个", "月"], BM25 才能正常按词频打分。 """ import jieba # lcut 精确模式(vs cut 全模式):返回 list,每个 token 是一个词 tokens = jieba.lcut(text) # 过滤规则:去空白 + 丢弃单字 # 单字(如"的""是""一")信息量极低,且大量重复,会拉低 BM25 区分度 return [t for t in tokens if t.strip() and len(t.strip()) > 1] def create_bm25_retriever(chunks: list): """创建 BM25 关键词检索器。 BM25 基于词频统计,擅长精确关键词匹配, 弥补向量检索在专有名词、数字、缩写上的短板。 注意:必须传入中文分词器 _jieba_tokenize,否则默认英文分词器 会把整段中文当成一个 token,BM25 退化为按原始顺序返回。 """ global _bm25_retriever if _bm25_retriever is not None: return _bm25_retriever from langchain_community.retrievers import BM25Retriever _bm25_retriever = BM25Retriever.from_documents( chunks, preprocess_func=_jieba_tokenize, ) _bm25_retriever.k = RETRIEVAL_K print(f"[检索器] BM25 初始化完成 (k={RETRIEVAL_K}, 中文分词=jieba)") return _bm25_retriever def create_vector_retriever(vectorstore): """创建向量语义检索器。 基于 BGE-Large-Zh-v1.5 embedding 的余弦相似度检索, 能在语义层面理解同义词、近义词和概念关联。 """ global _vector_retriever if _vector_retriever is not None: return _vector_retriever _vector_retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": RETRIEVAL_K}, ) print(f"[检索器] 向量检索器初始化完成 (k={RETRIEVAL_K})") return _vector_retriever def create_ensemble_retriever(chunks, vectorstore): """创建混合检索器: BM25 + 向量检索融合。 - BM25 权重 0.3: 关键词精确匹配 - 向量权重 0.7: 语义理解(权重更高,因为语义匹配通常更重要) 融合方式: 分数归一化后按权重加权求和。 """ global _ensemble_retriever if _ensemble_retriever is not None: return _ensemble_retriever from langchain_classic.retrievers import EnsembleRetriever bm25 = create_bm25_retriever(chunks) vec = create_vector_retriever(vectorstore) _ensemble_retriever = EnsembleRetriever( retrievers=[bm25, vec], weights=ENSEMBLE_WEIGHTS, ) print(f"[检索器] 混合检索器初始化完成 " f"(BM25:{ENSEMBLE_WEIGHTS[0]} + 向量:{ENSEMBLE_WEIGHTS[1]})") return _ensemble_retriever def create_mq_retriever(vectorstore): """创建多查询检索器 (Multi-Query Retriever)。 LLM 自动生成 3 个查询变体 → 分别检索 → 合并去重。 适合处理用户问题表述不够精确的场景。 """ global _mq_retriever if _mq_retriever is not None: return _mq_retriever from langchain_classic.retrievers import MultiQueryRetriever vec = create_vector_retriever(vectorstore) _mq_retriever = MultiQueryRetriever.from_llm( retriever=vec, llm=get_llm(), ) print(f"[检索器] 多查询检索器初始化完成 (变体数={MULTI_QUERY_VARIANTS})") return _mq_retriever def retrieve(query: str, strategy: str = "ensemble", top_k: int = DEFAULT_K) -> list: """统一的检索调度接口。 Args: query: 用户问题 strategy: 检索策略,可选 "bm25" | "vector" | "ensemble" | "multi_query" top_k: 返回的文档数量 Returns: list[Document]: 检索到的文档列表 """ vs = _get_vectorstore_ref() chunks = _get_chunks_ref() if strategy == "bm25": ret = create_bm25_retriever(chunks) elif strategy == "vector": ret = create_vector_retriever(vs) elif strategy == "multi_query": ret = create_mq_retriever(vs) else: ret = create_ensemble_retriever(chunks, vs) # 为不同检索器适配 k 参数 # BM25Retriever / VectorStoreRetriever 用 .k 属性控制返回数量 # MultiQueryRetriever / EnsembleRetriever 没有可变 .k,需要 invoke 后切片 if hasattr(ret, "k"): # 临时修改 k → 调用 → 恢复原值(防止影响后续其他调用) old_k = ret.k ret.k = top_k docs = ret.invoke(query) ret.k = old_k else: docs = ret.invoke(query) docs = docs[:top_k] return docs # 模块级引用(由主流程设置) _VS = None _CHUNKS = None def _get_vectorstore_ref(): if _VS is None: raise RuntimeError("向量存储未初始化,请先调用 load_and_chunk() 和 get_vectorstore()") return _VS def _get_chunks_ref(): if _CHUNKS is None: raise RuntimeError("文档块未初始化") return _CHUNKS # ============================================================================ # Section 6: 查询增强 (优化6) # ============================================================================ def rewrite_query(original: str) -> str: """查询重写:用 LLM 将用户问题改写为更精确的检索查询。 改写策略: - 提取核心实体和关键词 - 消除歧义,明确指代 - 将口语化表达转为文档化风格 """ llm = get_llm() prompt = f"""你是一个查询优化助手。给定用户的原始问题,请将其改写成更适合文档检索的形式。 要求: 1. 提取核心实体和关键词 2. 移除歧义,明确指代 3. 保持原意不变 4. 使用文档中可能出现的术语(如"年假""加班费""绩效考核"等正式表述) 5. 直接输出改写后的查询,不要任何解释 原始问题:{original} 改写后的查询:""" try: raw = llm.invoke(prompt) rewritten = raw.content if hasattr(raw, "content") else str(raw) rewritten = rewritten.strip().strip('"').strip("'") if len(rewritten) < 2: return original return rewritten except Exception as e: print(f"[查询重写] 失败 ({e}),使用原始查询") return original def hyde_retrieve(query: str, vectorstore, top_k: int = RETRIEVAL_K) -> list: """HyDE (Hypothetical Document Embeddings) 检索。 核心思路: LLM 先生成一个「假设性答案」→ 将这个答案向量化 → 用这个向量去检索 → 假设性答案的语义空间比问题本身更接近真实文档。 流程: 1. LLM 生成假设性答案(不需要准确,只需"看起来像"文档内容) 2. Embedding 模型将假设性答案转为向量 3. 在 ChromaDB 中检索相似文档 """ llm = get_llm() prompt = f"""请根据以下问题,生成一个假设性的员工手册回答。这个回答不需要完全准确, 只需要看起来像是一段真实的公司制度文档内容。用来帮助搜索引擎找到相关文档。 要求:直接输出内容,不要任何前缀或解释。 问题:{query} 假设文档:""" try: raw = llm.invoke(prompt) hypothesis = raw.content if hasattr(raw, "content") else str(raw) hypothesis = hypothesis.strip() if len(hypothesis) < 10: return [] docs = vectorstore.similarity_search(hypothesis, k=top_k) return docs except Exception as e: print(f"[HyDE] 失败 ({e})") return [] def reciprocal_rank_fusion(doc_lists: list[list], k: int = RRF_K) -> list: """倒数秩融合 (Reciprocal Rank Fusion)。 将多个检索结果列表按 RRF 算法合并为一个去重排序列表。 公式: score(d) = Σ 1/(rank_i(d) + k) Args: doc_lists: 多个检索结果列表 k: 平滑参数(默认60,标准设置) Returns: 融合后按 RRF 分数降序排列的文档列表 """ scores: dict[int, float] = {} doc_map: dict[int, object] = {} # Step 1: 遍历每个检索器返回的列表,按排名累计 RRF 分数 for doc_list in doc_lists: for rank, doc in enumerate(doc_list): # rank 从 0 开始,表示该文档在该列表中的位置 # 用 page_content 的 hash 作为文档唯一标识(同一段文字会得到相同 hash) doc_id = hash(doc.page_content) if doc_id not in scores: scores[doc_id] = 0.0 doc_map[doc_id] = doc # RRF 核心公式:score(d) = Σ 1/(rank + k) # rank 从 0 开始,所以要 +1 才是真正的「第几名」 # k 是平滑常数:避免 rank=0 的文档得到 1.0 满分,让排名靠后的也有贡献 # k=60 是业界标准值,源自论文《Reciprocal Rank Fusion outperforms Condorcet》 scores[doc_id] += 1.0 / (rank + k) # Step 2: 按 RRF 分数降序排列,输出融合后的文档列表 sorted_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True) return [doc_map[did] for did in sorted_ids] def enhanced_retrieve(query: str, strategy: str = "ensemble", top_k: int = DEFAULT_K) -> list: """增强检索: 查询重写 + HyDE + RRF 融合。 流程: 1. LLM 重写查询 → 得到更精确的检索查询 2. HyDE 生成假设答案 → 从另一个语义角度检索 3. 标准检索(用重写后的查询) 4. RRF 融合 HyDE 结果 + 标准结果 → 取 top_k """ vectorstore = _get_vectorstore_ref() # Step 1: 查询重写 rewritten = rewrite_query(query) if rewritten != query: print(f"[增强检索] 查询重写: 「{query[:40]}...」→「{rewritten[:60]}...」") # Step 2: HyDE 检索 hyde_docs = hyde_retrieve(query, vectorstore, top_k=RETRIEVAL_K) print(f"[增强检索] HyDE 召回 {len(hyde_docs)} 个文档") # Step 3: 标准检索(使用重写后的查询) standard_docs = retrieve(rewritten, strategy=strategy, top_k=RETRIEVAL_K) print(f"[增强检索] {strategy} 召回 {len(standard_docs)} 个文档") # Step 4: RRF 融合 if hyde_docs and standard_docs: fused = reciprocal_rank_fusion([standard_docs, hyde_docs]) print(f"[增强检索] RRF 融合后 {len(fused)} 个文档") elif standard_docs: fused = standard_docs else: fused = hyde_docs return fused[:top_k] # ============================================================================ # Section 7: Reranker 精排 (优化7) # ============================================================================ def rerank(query: str, candidates: list, top_k: int = DEFAULT_K) -> list: """使用 BGE-Reranker 对候选文档精排。 Cross-Encoder 将 query-doc 对联合编码,比 embedding 相似度更精确。 缺点: 速度较慢,适合对 Top-N 候选做精排(N 通常 10-20)。 Args: query: 用户问题 candidates: 候选文档列表 top_k: 返回的文档数量 Returns: 按相关度分数降序排列的文档列表 """ reranker = init_reranker() if reranker is None or len(candidates) == 0: return candidates[:top_k] # 优化:候选数 ≤ top_k 时无需精排,直接返回(省下推理时间) if len(candidates) <= top_k: return candidates try: # 构建 query-doc 配对:CrossEncoder 是双塔模型的对比 # 与 embedding(Bi-Encoder,query 和 doc 分别编码再算相似度)不同, # CrossEncoder 把 query 和 doc 拼在一起送入模型,深度交互注意力, # 因此精度更高但速度更慢(适合 Top-N 精排,N 通常 10-20) pairs = [(query, doc.page_content) for doc in candidates] scores = reranker.predict(pairs, show_progress_bar=False) # 按相关度分数降序排列,取 top_k # scored 是 [(score, doc), ...],sort 后取前 top_k 个的 doc scored = list(zip(scores, candidates)) scored.sort(key=lambda x: x[0], reverse=True) reranked = [doc for _, doc in scored[:top_k]] print(f"[Reranker] 精排完成: {len(candidates)} → {len(reranked)} " f"(最高分: {scored[0][0]:.4f})") return reranked except Exception as e: print(f"[Reranker] 精排失败 ({e}),使用原始顺序") return candidates[:top_k] # ============================================================================ # Section 8: Agent 工具封装 (优化2) # ============================================================================ def create_retrieval_tool(): """将检索系统封装为 LangChain Tool。 Agent 可根据问题自动判断是否需要调用此工具检索员工手册。 """ from langchain_core.tools import tool @tool def search_employee_handbook(query: str) -> str: """从《XX科技有限公司员工手册》中检索与 query 相关的内容。 当用户问到以下内容时使用此工具: - 公司制度、考勤规定、请假流程 - 薪资福利、社保公积金 - 加班管理、出差报销 - 绩效考核、晋升条件 - 离职手续、保密规定 - 员工关怀、培训发展 - 入职流程、试用期规定 输入:用户的问题或关键词。 输出:手册中的相关章节内容。 """ # 使用增强检索 + 精排 docs = enhanced_retrieve(query, strategy="ensemble", top_k=RETRIEVAL_K) docs = rerank(query, docs, top_k=DEFAULT_K) parts = [] for i, doc in enumerate(docs): parts.append(f"【参考条款 {i+1}】\n{doc.page_content}") return "\n\n---\n\n".join(parts) return search_employee_handbook def create_rag_agent(): """创建 RAG Agent。 Agent 能自动判断: - 需要查手册时 → 调用 search_employee_handbook 工具 - 闲聊/通用问题 → 直接回答 """ from langchain.agents import create_agent tool = create_retrieval_tool() llm = get_llm() agent = create_agent( model=llm, tools=[tool], system_prompt=( "你是XX科技有限公司的员工手册问答助手。\n\n" "规则:\n" "1. 当用户问到公司制度、考勤、请假、薪资、福利、加班、离职、保密等" "规定时,必须先调用 search_employee_handbook 工具检索手册内容。\n" "2. 基于检索到的内容回答,不要编造。如果手册中没有相关信息," "如实告知用户。\n" "3. 回答要简洁清晰,引用手册中的具体条款编号。\n" "4. 对于闲聊或与员工手册无关的问题,直接回答即可。" ), ) print(f"[Agent] RAG Agent 创建完成") return agent # ============================================================================ # Section 9: 完整 RAG 链路 # ============================================================================ def build_context(docs: list) -> str: """将检索到的文档拼接为 LLM 上下文。""" parts = [] for i, doc in enumerate(docs): parts.append(f"[参考文档 {i+1}]\n{doc.page_content}") return "\n\n---\n\n".join(parts) def rag_query(query: str, strategy: str = "ensemble", use_enhance: bool = True, use_rerank: bool = True) -> dict: """确定性 RAG 查询流水线(不使用 Agent)。 Args: query: 用户问题 strategy: 检索策略 use_enhance: 是否使用查询增强(重写 + HyDE) use_rerank: 是否使用 Reranker 精排 Returns: dict: {"answer": str, "sources": list[Document], "strategy": str} """ from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser # Step 1: 检索(增强检索走 enhanced_retrieve,普通检索走 retrieve) if use_enhance: docs = enhanced_retrieve(query, strategy=strategy, top_k=RETRIEVAL_K) else: docs = retrieve(query, strategy=strategy, top_k=RETRIEVAL_K) # Step 2: 精排(可选):对召回的 top-K 候选重新打分排序,取 top-k if use_rerank: docs = rerank(query, docs, top_k=DEFAULT_K) # Step 3: 组装 prompt # ChatPromptTemplate.from_messages: 支持 system + user 多轮对话格式 # {context} 和 {query} 是占位符,invoke 时填充 context = build_context(docs) prompt = ChatPromptTemplate.from_messages([ ("system", """你是XX科技有限公司的员工手册问答助手。请根据以下参考资料回答问题。 **规则:** - 只基于提供的参考资料回答,不要编造 - 如果参考资料中没有相关信息,直接说「根据现有员工手册,我找不到这个问题的答案」 - 回答要简洁清晰,引用具体条款时注明出处 - 如果参考资料不足以回答,可以说明需要查阅手册的哪个章节 **参考资料:** {context}"""), ("user", "{query}") ]) # Step 4: 生成 # LCEL 链式语法:prompt | llm | parser # - prompt.invoke({...}) → 填充占位符生成最终 prompt # - llm.invoke(prompt) → 调用 LLM 生成回复 # - StrOutputParser().invoke(resp) → 从 AIMessage 中提取纯文本 # 等价于:parser.invoke(llm.invoke(prompt.invoke({...}))) llm = get_llm() chain = prompt | llm | StrOutputParser() answer = chain.invoke({"context": context, "query": query}) return {"answer": answer, "sources": docs, "strategy": strategy} def agent_query(query: str) -> str: """Agent 模式查询(自动判断是否需要检索)。 Agent 根据问题内容自动决定: - 调用 search_employee_handbook 工具检索 - 直接回答(不检索) """ agent = create_rag_agent() result = agent.invoke({"messages": [("user", query)]}) # 提取最后一条消息 messages = result.get("messages", []) if messages: return messages[-1].content return "Agent 未返回结果" # ============================================================================ # Section 10: RAG 效果评估体系 (优化8) # ============================================================================ # # 评估策略说明: # 1. 手动标注测试集: 覆盖员工手册全部 16 章的真实问题 → Section 级检索评估 # 2. 检索交叉验证: LLM 逐条判断检索到的文档能否回答该问题 # 3. LLM 辅助生成评估 + Bad Case 根因分析 # ======================================================================== # 10a. 手动标注真实测试集(覆盖全部 16 章,~30% 文档覆盖率) # ======================================================================== # 每条测试用例包含: # question: 员工真实会问的问题 # section_keywords: 期望匹配的章节关键词(用于 Section 级检索评估) # reference_answer: 从手册原文提取的参考答案(用于生成评估) _MANUAL_TEST_SET = [ # === 第一章 总则 === { "question": "员工手册制定的法律依据是什么?", "section_keywords": ["总则", "劳动法", "劳动合同法"], "reference_answer": "根据《中华人民共和国劳动法》《中华人民共和国劳动合同法》及相关法律法规制定。", }, # === 第二章 入职与试用期 === { "question": "试用期是多长时间?", "section_keywords": ["试用期", "入职"], "reference_answer": "新员工试用期为3个月,试用期包含在劳动合同期限内。", }, { "question": "入职需要准备哪些材料?", "section_keywords": ["入职", "入职流程", "报到"], "reference_answer": "入职需准备:身份证、学历学位证书、离职证明、体检报告、银行卡。", }, # === 第三章 工作时间与考勤 === { "question": "公司标准工作时间是怎样的?", "section_keywords": ["工作时间", "考勤", "上下班"], "reference_answer": "标准工作时间为周一至周五9:00-18:00,午休1小时,每天工作8小时。", }, # === 第四章 假期管理 === { "question": "年假没休完可以结转到下一年吗?", "section_keywords": ["年假", "带薪年假", "假期"], "reference_answer": "当年未休完的年假最多可结转5天至次年3月31日,逾期作废。", }, { "question": "请病假需要提供什么材料?", "section_keywords": ["病假", "请假", "假期管理"], "reference_answer": "请病假需提供二级甲等以上医院出具的病假证明和诊断证明。", }, # === 第五章 薪资福利 === { "question": "社保养老保险个人缴纳比例是多少?", "section_keywords": ["社保", "社会保险", "五险一金", "薪资福利"], "reference_answer": "养老保险个人缴纳比例为8%,单位缴纳比例为16%。", }, # === 第六章 绩效考核 === { "question": "绩效考核结果分几个等级?", "section_keywords": ["绩效考核", "考核等级", "绩效"], "reference_answer": "绩效考核分为S/A/B/C/D五个等级,其中S级占比不超过10%。", }, # === 第七章 培训与发展 === { "question": "公司每年要求的最低培训学时是多少?", "section_keywords": ["培训", "培训与发展", "学习"], "reference_answer": "每位员工每年需完成不低于40学时的培训,包括技术培训、管理培训和软技能培训。", }, # === 第八章 晋升制度 === { "question": "员工晋升需要满足哪些基本条件?", "section_keywords": ["晋升", "晋升制度", "职业发展"], "reference_answer": "晋升需满足:连续两次绩效考核B+以上,在现岗位工作满1年以上。", }, # === 第九章 加班管理 === { "question": "周末加班工资按几倍计算?", "section_keywords": ["加班", "加班费", "加班工资"], "reference_answer": "休息日(周末)加班,按200%(2倍)工资支付加班费。", }, # === 第十章 出差与报销 === { "question": "出差一线城市住宿标准多少钱一天?", "section_keywords": ["出差", "报销", "住宿标准"], "reference_answer": "一线城市(北上广深)出差住宿标准为500元/天。", }, # === 第十一章 劳动合同 === { "question": "第一次签劳动合同的期限是几年?", "section_keywords": ["劳动合同", "合同期限", "签订"], "reference_answer": "首次签订劳动合同期限为3年,续签第二次为5年,之后可签无固定期限合同。", }, # === 第十二章 奖惩制度 === { "question": "员工连续旷工几天会被开除?", "section_keywords": ["旷工", "处罚", "奖惩", "开除", "严重违纪"], "reference_answer": "连续旷工3天以上或一年内累计旷工5天以上,属于严重违纪,公司可单方解除劳动合同。", }, # === 第十三章 保密规定 === { "question": "公司商业机密包括哪些内容?", "section_keywords": ["保密", "商业机密", "保密规定"], "reference_answer": "商业机密包括技术资料、客户信息、财务数据、经营决策、未公开的人事信息等。", }, # === 第十四章 离职管理 === { "question": "离职需要提前多少天申请?", "section_keywords": ["离职", "离职管理", "解除"], "reference_answer": "正式员工离职需提前30天书面申请,试用期员工提前3天申请。", }, # === 第十五章 员工关怀 === { "question": "公司有哪些员工关怀和福利项目?", "section_keywords": ["员工关怀", "员工福利", "关怀"], "reference_answer": "公司提供补充商业保险、年度体检、节日礼品、团队建设、生日福利、婚育贺礼、困难补助等。", }, # === 第十六章 附则 === { "question": "员工手册的解释权归哪个部门?", "section_keywords": ["附则", "解释权", "人力资源部"], "reference_answer": "本手册的最终解释权归公司人力资源部所有。", }, { "question": "员工手册从什么时候开始生效?", "section_keywords": ["附则", "生效", "施行"], "reference_answer": "本手册自2025年1月1日起正式生效施行。", }, ] def get_manual_test_set() -> list[dict]: """获取手动标注的真实测试集。 这些测试用例是基于员工手册原文内容手动编写的,用于客观评估 RAG 系统。 与 LLM 自动生成的测试集不同,这里的问题和答案都是人工确定的, 不存在"同一 LLM 既当选手又当裁判"的问题。 """ return _MANUAL_TEST_SET # ======================================================================== # 10b. Section 级检索评估(核心指标) # ======================================================================== def evaluate_retrieval_section_level(test_set: list[dict]) -> dict: """Section 级检索评估 — 客观评估检索质量。 评估方法: 对每个问题,使用各检索策略检索 Top-K 文档,检查检索到的文档 是否来自员工手册的正确章节(通过章节关键词匹配)。 为什么不用"同一个 chunk"检测? → 那是循环验证:LLM 从 chunk A 生成问题 → 检查是否检索到 chunk A → 毫无意义,因为 embedding 向量天然相近。 为什么用 Section 级匹配? → 比如问"试用期",理应检索到"第二章 入职与试用期"的内容 → 如果检索到了"第四章 假期管理",说明检索跑偏了 → 这能真正区分不同检索策略的效果 Returns: dict: 每种策略的 {"precision@3", "precision@5", "section_recall", "details"} """ print(f"\n{'='*60}") print(f"[检索评估] Section 级评估 (测试 {len(test_set)} 个真实查询)") print(f"[检索评估] 衡量检索到的文档是否来自正确章节") strategies = ["bm25", "vector", "ensemble", "multi_query", "enhanced"] all_results = {} for strategy in strategies: per_query = [] for qa in test_set: query = qa["question"] expected_keywords = qa["section_keywords"] # "enhanced" 用增强检索链(重写+HyDE+RRF,是实际生产 pipeline) if strategy == "enhanced": docs = enhanced_retrieve(query, strategy="vector", top_k=10) else: docs = retrieve(query, strategy=strategy, top_k=10) # 对每个检索到的 doc,检查其内容是否包含期望的章节关键词 top3_hits = 0 top5_hits = 0 top3_texts = [] for rank, doc in enumerate(docs): doc_text = doc.page_content # 检查文档中是否包含任何期望的章节关键词 matched = any(kw in doc_text for kw in expected_keywords) if matched: if rank < 3: top3_hits += 1 if rank < 5: top5_hits += 1 if rank < 3: top3_texts.append(doc_text[:80]) per_query.append({ "query": query[:60], "expected_sections": expected_keywords, "top3_hits": top3_hits, "top5_hits": top5_hits, "top3_preview": top3_texts, }) n = len(test_set) precision_3 = sum(q["top3_hits"] for q in per_query) / (n * 3) # 理想情况是 top3 中每篇都命中 precision_5 = sum(q["top5_hits"] for q in per_query) / (n * 5) # Section Recall: 至少有一篇文档命中正确章节的查询比例 section_recall = sum(1 for q in per_query if q["top3_hits"] > 0) / n all_results[strategy] = { "precision_at_3": precision_3, "precision_at_5": precision_5, "section_recall": section_recall, "details": per_query, } print(f"[检索评估] {strategy:<15s} | P@3: {precision_3:.1%} | " f"P@5: {precision_5:.1%} | Section Recall: {section_recall:.1%}") return all_results # ======================================================================== # 10c. 检索交叉验证 # ======================================================================== def evaluate_retrieval_cross_verify(test_set: list[dict]) -> dict: """检索交叉验证: LLM 逐条判断检索到的文档是否能回答问题。 与 Section 级匹配互补: - Section 匹配是"粗粒度"的(文档来自正确章节?) - 交叉验证是"细粒度"的(文档内容真的能回答这个问题?) 方法: 对每个查询,取检索到的 Top-3 文档,让 LLM 逐条判断: "这篇文档是否包含回答该问题所需的信息?" → 是(1) / 部分(0.5) / 否(0) 优势: 不需要人工标注参考答案,LLM 只做"是否相关"的二元判断, 比对比参考答案更客观。 """ print(f"\n{'='*60}") print(f"[交叉验证] LLM 逐条判断检索文档是否相关") strategies = ["bm25", "vector", "ensemble", "multi_query"] all_results = {} llm = get_llm() for strategy in strategies: doc_scores = [] for qi, qa in enumerate(test_set): query = qa["question"] docs = retrieve(query, strategy=strategy, top_k=3) for rank, doc in enumerate(docs): verify_prompt = f"""判断以下文档片段是否包含回答该问题所需的信息。 问题:{query} 文档:{doc.page_content[:400]} 只回答数字: 1=包含关键信息 / 0.5=部分相关 / 0=无关""" try: raw = llm.invoke(verify_prompt) ans = raw.content if hasattr(raw, "content") else str(raw) score = float(ans.strip()[0]) if ans.strip() else 0 score = max(0, min(1, score)) except Exception: score = 0 doc_scores.append({ "query": query[:50], "strategy": strategy, "rank": rank + 1, "relevant": score, }) n = len(test_set) * 3 # 每个查询 3 篇文档 avg_relevance = sum(d["relevant"] for d in doc_scores) / max(1, n) perfect_ratio = sum(1 for d in doc_scores if d["relevant"] == 1) / max(1, n) all_results[strategy] = { "avg_relevance": avg_relevance, "perfect_ratio": perfect_ratio, "details": doc_scores, } print(f"[交叉验证] {strategy:<15s} | 平均相关度: {avg_relevance:.2%} | " f"完美相关比: {perfect_ratio:.1%}") return all_results # ======================================================================== # 10d. 生成质量评估 + Bad Case 分析 # ======================================================================== def evaluate_generation(test_set: list[dict], sample_size: int = 10) -> dict: """生成效果评估: 准确率、完整度、相关度、幻觉率。 对每个测试问题: 1. RAG 生成答案 2. LLM judge 对比"参考答案"和"生成答案" 3. 从 4 个维度打分 注意: 这个评估的可靠性取决于 LLM judge 的质量,建议作为辅助指标。 """ import random print(f"\n{'='*60}") print(f"[生成评估] LLM Judge 打分(辅助指标)") if len(test_set) > sample_size: samples = random.sample(test_set, sample_size) else: samples = test_set llm = get_llm() judge_results = [] bad_cases = [] for i, qa in enumerate(samples): query = qa["question"] reference = qa["reference_answer"] # RAG 生成答案 rag_result = rag_query(query, use_enhance=True, use_rerank=True) generated = rag_result["answer"] # 同时获取检索结果,用于 Bad Case 根因分析 retrieved_docs = retrieve(query, strategy="ensemble", top_k=5) # LLM judge judge_prompt = f"""你是一个严格但公正的评估助手。请对比参考答案和RAG系统生成的答案。 参考答案(来自员工手册原文):{reference} RAG生成答案:{generated} 请从以下维度严格打分(1-5分,5分最好): - accuracy(事实准确性):生成答案的事实是否与参考答案一致?(有事实错误扣分) - completeness(完整性):是否覆盖了参考答案的关键信息?(遗漏重要信息扣分) - relevance(相关性):是否直接回答了问题?(答非所问扣分) - hallucination(幻觉程度):是否编造了参考答案中没有的内容?(5=无编造,1=严重编造) 输出严格的JSON: {{"accuracy": 整数1-5, "completeness": 整数1-5, "relevance": 整数1-5, "hallucination": 整数1-5}}""" try: raw_resp = llm.invoke(judge_prompt) raw_text = raw_resp.content if hasattr(raw_resp, "content") else str(raw_resp) json_match = re.search(r'\{[\s\S]*\}', raw_text) if json_match: scores = json.loads(json_match.group()) scores["query"] = query scores["reference"] = reference scores["generated"] = generated[:300] judge_results.append(scores) # 识别 Bad Case is_bad = (scores.get("accuracy", 5) <= 2 or scores.get("hallucination", 5) <= 2) if is_bad: # 根因分析 retrieved_texts = [d.page_content[:200] for d in retrieved_docs[:3]] # 检查检索结果中是否有与 reference 相关的内容 retrieval_ok = any( any(kw in " ".join(retrieved_texts) for kw in qa.get("section_keywords", [])) ) root_cause = "生成幻觉" if retrieval_ok else "检索失败" bad_cases.append({ "query": query, "reference": reference, "generated": generated[:200], "root_cause": root_cause, "retrieved_preview": retrieved_texts[:2], "scores": scores, }) print(f"[生成评估] [{i+1}/{len(samples)}] " f"acc={scores.get('accuracy','?')} " f"hall={scores.get('hallucination','?')} " f"{'⚠️ BAD' if is_bad else '✅'}") except Exception as e: print(f"[生成评估] 第 {i+1} 个评判失败: {e}") # 汇总指标 if judge_results: avg_acc = sum(r.get("accuracy", 0) for r in judge_results) / len(judge_results) avg_comp = sum(r.get("completeness", 0) for r in judge_results) / len(judge_results) avg_rel = sum(r.get("relevance", 0) for r in judge_results) / len(judge_results) avg_hall = sum(r.get("hallucination", 0) for r in judge_results) / len(judge_results) hall_rate = sum(1 for r in judge_results if r.get("hallucination", 5) <= 2) / len(judge_results) bad_rate = len(bad_cases) / len(judge_results) else: avg_acc = avg_comp = avg_rel = avg_hall = hall_rate = bad_rate = 0 print(f"\n[生成评估] 汇总:") print(f" 准确率: {avg_acc:.2f}/5 | 完整度: {avg_comp:.2f}/5") print(f" 相关度: {avg_rel:.2f}/5 | 抗幻觉: {avg_hall:.2f}/5") print(f" 幻觉率: {hall_rate:.1%} | Bad率: {bad_rate:.1%}") return { "avg_accuracy": avg_acc, "avg_completeness": avg_comp, "avg_relevance": avg_rel, "avg_anti_hallucination": avg_hall, "hallucination_rate": hall_rate, "bad_case_rate": bad_rate, "bad_cases": bad_cases, "details": judge_results, } def bad_case_analysis(gen_results: dict) -> list[dict]: """Bad Case 深度分析与优化建议。 对每个 Bad Case 按根因分类,给出针对性优化方向。 """ bad_cases = gen_results.get("bad_cases", []) print(f"\n{'='*60}") print(f"[Bad Case 分析] 根因定位") if not bad_cases: print("[Bad Case 分析] ✅ 所有案例表现良好!") return [] # 按根因分类 categories = defaultdict(list) for case in bad_cases: categories[case["root_cause"]].append(case) print(f"[Bad Case 分析] 共 {len(bad_cases)} 个 Bad Case:\n") for root_cause, cases in sorted(categories.items()): print(f" 📌 [{root_cause}] ({len(cases)} 例)") for case in cases[:1]: print(f" ❓ {case['query'][:60]}") print(f" 🤖 {case['generated'][:80]}...") print() # 优化建议 print(f"[Bad Case 分析] 针对性优化建议:") if "检索失败" in categories: print(f" 🔧 检索失败 ({len(categories['检索失败'])}例):") print(f" → 降低 SemanticChunker 的 percentile 值(更细粒度切块)") print(f" → 增加 RETRIEVAL_K 召回量") print(f" → 考虑换用更大模型或调整 chunk 粒度") print(f" → 检查查询重写是否改变了语义") if "生成幻觉" in categories: print(f" 🔧 生成幻觉 ({len(categories['生成幻觉'])}例):") print(f" → 在 prompt 中加强对「不知道就说不知道」的约束") print(f" → 降低 LLM temperature(当前默认值)") print(f" → 要求逐句标注引用来源") print(f" → 考虑接入 Reranker 精排后再生成") return bad_cases # ======================================================================== # 10e. 完整评估入口 # ======================================================================== def run_full_evaluation() -> dict: """运行完整评估流程。 三层评估: 第一层: Section 级检索评估 → 客观指标(不依赖 LLM judge) 第二层: 检索交叉验证 → LLM 逐篇判断相关性 第三层: 生成质量评估 → LLM judge 打分 + Bad Case 根因分析 Returns: dict: 完整评估报告 """ print(f"\n{'#'*60}") print(f"# RAG 效果评估(三层体系)") print(f"{'#'*60}") # 使用手动标注的真实测试集(覆盖全部 16 章) test_set = get_manual_test_set() print(f"[评估] 手动标注测试集: {len(test_set)} 条,覆盖全部 16 章") # ---- 第一层: Section 级检索评估 ---- section_results = evaluate_retrieval_section_level(test_set) # ---- 第二层: 检索交叉验证 ---- cross_verify_results = evaluate_retrieval_cross_verify(test_set) # ---- 第三层: 生成质量评估 + Bad Case ---- gen_results = evaluate_generation(test_set, sample_size=min(12, len(test_set))) bad_cases = bad_case_analysis(gen_results) # ---- 综合报告 ---- print(f"\n{'='*60}") print(f"📊 综合评估报告") print(f"{'='*60}") print(f"测试集: {len(test_set)} 条手动标注的真实员工问题\n") # 检索排名 print(f"🏆 检索策略排名 (按 Section Recall):") sorted_sr = sorted(section_results.items(), key=lambda x: x[1]["section_recall"], reverse=True) for rank, (name, m) in enumerate(sorted_sr): medal = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"][rank] if rank < 5 else " " print(f" {medal} {name:<15s} P@3: {m['precision_at_3']:.1%} " f"P@5: {m['precision_at_5']:.1%} " f"SectionRecall: {m['section_recall']:.1%}") print(f"\n🔍 检索交叉验证 (LLM 逐篇判断相关性):") sorted_cv = sorted(cross_verify_results.items(), key=lambda x: x[1]["avg_relevance"], reverse=True) for rank, (name, m) in enumerate(sorted_cv): medal = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"][rank] if rank < 5 else " " print(f" {medal} {name:<15s} 平均相关度: {m['avg_relevance']:.2%} " f"完美相关比: {m['perfect_ratio']:.1%}") print(f"\n📈 生成质量 (LLM Judge 辅助指标):") print(f" 准确率: {gen_results.get('avg_accuracy', 0):.2f}/5") print(f" 完整度: {gen_results.get('avg_completeness', 0):.2f}/5") print(f" 幻觉率: {gen_results.get('hallucination_rate', 0):.1%}") print(f" Bad率: {gen_results.get('bad_case_rate', 0):.1%}") print(f" Bad Case: {len(bad_cases)} 个") # 确定最佳策略 best_strategy = sorted_sr[0][0] if sorted_sr else "ensemble" best_cv = sorted_cv[0][0] if sorted_cv else "ensemble" print(f"\n💡 推荐策略: 检索={best_strategy}, 交叉验证最佳={best_cv}") if best_strategy != best_cv: print(f" ⚠️ Section评估与交叉验证结论不一致,建议综合参考") return { "test_set_size": len(test_set), "section_retrieval": section_results, "cross_verify": cross_verify_results, "generation": gen_results, "bad_cases": bad_cases, "recommended_strategy": best_strategy, } # ============================================================================ # Section 11: 主流程 # ============================================================================ def _print_banner(): print(""" ╔══════════════════════════════════════════════════════════╗ ║ RAG 全链路优化系统 — 员工手册智能问答 ║ ║ ║ ║ 1.员工手册.md 2.Agent工具 3.BGE-Large-Zh(1024d) ║ ║ 4.语义切块 5.多路召回 6.查询增强(HyDE) ║ ║ 7.Reranker精排 8.三层评估体系 ║ ╚══════════════════════════════════════════════════════════╝ """) def main(): """主流程: 初始化 → 示例验证 → 交互测试。 优化:默认跳过完整评估(200+ 次 LLM 调用,耗时 10+ 分钟), 改为运行 1 个示例查询快速验证 RAG 链路可用,再进入交互模式。 完整评估通过命令行参数 `--eval` 或交互命令 `eval` 触发。 """ global _VS, _CHUNKS # 命令行参数: --eval 启动时直接跑完整评估;--demo 只跑示例不进入交互 run_eval_at_startup = "--eval" in sys.argv demo_only = "--demo" in sys.argv _print_banner() # ---- Step 1: 核心初始化(仅加载必要组件)---- print("[初始化] 加载模型和文档...\n") init_embedding_model() # embedding 模型(必须) _CHUNKS = load_and_chunk() # 文档切块(必须) _VS = get_vectorstore(_CHUNKS) # 向量存储(必须) create_ensemble_retriever(_CHUNKS, _VS) # 默认策略检索器(必须) # 以下组件改为按需初始化,避免启动时加载不必要的模型 # - create_mq_retriever(): 仅 multi_query 策略用到,首次调用时再初始化 # - init_reranker(): CrossEncoder 1.1GB,加载慢,rerank() 内部已按需加载 # ---- Step 2: 快速示例验证(1 个查询,验证 RAG 链路可用)---- print(f"\n{'='*60}") print(f"[示例] 快速验证 RAG 链路(不启用查询增强/精排,约 3 秒)") print(f"{'='*60}") demo_q = "试用期是几个月?" print(f"问题: {demo_q}") result = rag_query(demo_q, strategy="ensemble", use_enhance=False, use_rerank=False) print(f"答案: {result['answer'][:200]}") print(f"来源: {len(result['sources'])} 个文档 " f"(top-1: {result['sources'][0].metadata.get('section', '?')})") # ---- Step 3: 可选完整评估(默认跳过)---- if run_eval_at_startup: eval_report = run_full_evaluation() else: print(f"\n[提示] 完整评估已跳过(耗时较长)。") print(f" 如需运行评估:交互模式输入 'eval'," f"或重启时加 --eval 参数") # ---- Step 4: 交互模式 ---- if demo_only: print(f"\n[--demo] 示例完成,不进入交互模式") return print(f"\n{'='*60}") print(f"💬 交互模式") print(f"{'='*60}") print(f" 输入问题开始查询") print(f" 命令: 'strategy ' 切换检索策略") print(f" 策略: bm25 | vector | ensemble | multi_query") print(f" 命令: 'agent ' 使用 Agent 模式") print(f" 命令: 'enhance on/off' 切换查询增强(重写+HyDE)") print(f" 命令: 'rerank on/off' 切换精排") print(f" 命令: 'eval' 运行完整评估(耗时)") print(f" 命令: 'exit' 退出") print(f"{'='*60}\n") current_strategy = "ensemble" use_enhance = False # 默认关闭(每次查询省 2 次 LLM 调用) use_rerank = False # 默认关闭(首次调用时加载 Reranker 模型) while True: try: user_input = input("🔍 > ").strip() except (EOFError, KeyboardInterrupt): print("\n再见!") break if not user_input: continue if user_input.lower() in ("exit", "quit", "q"): print("再见!") break # 命令处理 if user_input.lower().startswith("strategy "): new_strategy = user_input.split(" ", 1)[1].strip() if new_strategy in ("bm25", "vector", "ensemble", "multi_query"): current_strategy = new_strategy print(f"✅ 已切换到: {current_strategy}") else: print(f"❌ 未知策略: {new_strategy}") continue if user_input.lower().startswith("agent "): query = user_input.split(" ", 1)[1].strip() print(f"🤖 Agent 模式(自动判断是否需要检索)...\n") answer = agent_query(query) print(f"\n📝 {answer}\n") continue if user_input.lower().startswith("enhance "): arg = user_input.split(" ", 1)[1].strip().lower() use_enhance = arg in ("on", "true", "1", "yes") print(f"✅ 查询增强: {'开启' if use_enhance else '关闭'}") continue if user_input.lower().startswith("rerank "): arg = user_input.split(" ", 1)[1].strip().lower() use_rerank = arg in ("on", "true", "1", "yes") print(f"✅ 精排: {'开启' if use_rerank else '关闭'}") continue if user_input.lower() == "eval": eval_report = run_full_evaluation() continue # 正常查询 enhance_tag = "查询增强" if use_enhance else "无增强" rerank_tag = "精排" if use_rerank else "无精排" print(f"🔎 检索策略: {current_strategy} | {enhance_tag} | {rerank_tag}") result = rag_query(user_input, strategy=current_strategy, use_enhance=use_enhance, use_rerank=use_rerank) print(f"\n📝 {result['answer']}\n") print(f"📖 参考来源 ({len(result['sources'])} 个文档):") for i, doc in enumerate(result["sources"]): print(f" [{i+1}] {doc.page_content[:100]}...") print() if __name__ == "__main__": main()