Initial commit

This commit is contained in:
2026-05-12 09:41:56 +08:00
commit 572283e101
936 changed files with 133949 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
"""
RAG系统配置文件
"""
from dataclasses import dataclass
from typing import Dict, Any
@dataclass
class RAGConfig:
"""RAG系统配置类"""
# 路径配置
data_path: str = "../../data/C8/cook"
index_save_path: str = "./vector_index"
# 模型配置
embedding_model: str = "BAAI/bge-small-zh-v1.5"
llm_model: str = "kimi-k2-0711-preview"
# 检索配置
top_k: int = 3
# 生成配置
temperature: float = 0.1
max_tokens: int = 2048
def __post_init__(self):
"""初始化后的处理"""
pass
@classmethod
def from_dict(cls, config_dict: Dict[str, Any]) -> 'RAGConfig':
"""从字典创建配置对象"""
return cls(**config_dict)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
'data_path': self.data_path,
'index_save_path': self.index_save_path,
'embedding_model': self.embedding_model,
'llm_model': self.llm_model,
'top_k': self.top_k,
'temperature': self.temperature,
'max_tokens': self.max_tokens
}
# 默认配置实例
DEFAULT_CONFIG = RAGConfig()
+369
View File
@@ -0,0 +1,369 @@
"""
RAG系统主程序
"""
import os
import sys
import logging
from pathlib import Path
from typing import List
# 添加模块路径
sys.path.append(str(Path(__file__).parent))
from dotenv import load_dotenv
from config import DEFAULT_CONFIG, RAGConfig
from rag_modules import (
DataPreparationModule,
IndexConstructionModule,
RetrievalOptimizationModule,
GenerationIntegrationModule
)
# 加载环境变量
load_dotenv()
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class RecipeRAGSystem:
"""食谱RAG系统主类"""
def __init__(self, config: RAGConfig = None):
"""
初始化RAG系统
Args:
config: RAG系统配置,默认使用DEFAULT_CONFIG
"""
self.config = config or DEFAULT_CONFIG
self.data_module = None
self.index_module = None
self.retrieval_module = None
self.generation_module = None
# 检查数据路径
if not Path(self.config.data_path).exists():
raise FileNotFoundError(f"数据路径不存在: {self.config.data_path}")
# 检查API密钥
if not os.getenv("MOONSHOT_API_KEY"):
raise ValueError("请设置 MOONSHOT_API_KEY 环境变量")
def initialize_system(self):
"""初始化所有模块"""
print("🚀 正在初始化RAG系统...")
# 1. 初始化数据准备模块
print("初始化数据准备模块...")
self.data_module = DataPreparationModule(self.config.data_path)
# 2. 初始化索引构建模块
print("初始化索引构建模块...")
self.index_module = IndexConstructionModule(
model_name=self.config.embedding_model,
index_save_path=self.config.index_save_path
)
# 3. 初始化生成集成模块
print("🤖 初始化生成集成模块...")
self.generation_module = GenerationIntegrationModule(
model_name=self.config.llm_model,
temperature=self.config.temperature,
max_tokens=self.config.max_tokens
)
print("✅ 系统初始化完成!")
def build_knowledge_base(self):
"""构建知识库"""
print("\n正在构建知识库...")
# 1. 尝试加载已保存的索引
vectorstore = self.index_module.load_index()
if vectorstore is not None:
print("✅ 成功加载已保存的向量索引!")
# 仍需要加载文档和分块用于检索模块
print("加载食谱文档...")
self.data_module.load_documents()
print("进行文本分块...")
chunks = self.data_module.chunk_documents()
else:
print("未找到已保存的索引,开始构建新索引...")
# 2. 加载文档
print("加载食谱文档...")
self.data_module.load_documents()
# 3. 文本分块
print("进行文本分块...")
chunks = self.data_module.chunk_documents()
# 4. 构建向量索引
print("构建向量索引...")
vectorstore = self.index_module.build_vector_index(chunks)
# 5. 保存索引
print("保存向量索引...")
self.index_module.save_index()
# 6. 初始化检索优化模块
print("初始化检索优化...")
self.retrieval_module = RetrievalOptimizationModule(vectorstore, chunks)
# 7. 显示统计信息
stats = self.data_module.get_statistics()
print(f"\n📊 知识库统计:")
print(f" 文档总数: {stats['total_documents']}")
print(f" 文本块数: {stats['total_chunks']}")
print(f" 菜品分类: {list(stats['categories'].keys())}")
print(f" 难度分布: {stats['difficulties']}")
print("✅ 知识库构建完成!")
def ask_question(self, question: str, stream: bool = False):
"""
回答用户问题
Args:
question: 用户问题
stream: 是否使用流式输出
Returns:
生成的回答或生成器
"""
if not all([self.retrieval_module, self.generation_module]):
raise ValueError("请先构建知识库")
print(f"\n❓ 用户问题: {question}")
# 1. 查询路由
route_type = self.generation_module.query_router(question)
print(f"🎯 查询类型: {route_type}")
# 2. 智能查询重写(根据路由类型)
if route_type == 'list':
# 列表查询保持原查询
rewritten_query = question
print(f"📝 列表查询保持原样: {question}")
else:
# 详细查询和一般查询使用智能重写
print("🤖 智能分析查询...")
rewritten_query = self.generation_module.query_rewrite(question)
# 3. 检索相关子块(自动应用元数据过滤)
print("🔍 检索相关文档...")
filters = self._extract_filters_from_query(question)
if filters:
print(f"应用过滤条件: {filters}")
relevant_chunks = self.retrieval_module.metadata_filtered_search(rewritten_query, filters, top_k=self.config.top_k)
else:
relevant_chunks = self.retrieval_module.hybrid_search(rewritten_query, top_k=self.config.top_k)
# 显示检索到的子块信息
if relevant_chunks:
chunk_info = []
for chunk in relevant_chunks:
dish_name = chunk.metadata.get('dish_name', '未知菜品')
# 尝试从内容中提取章节标题
content_preview = chunk.page_content[:100].strip()
if content_preview.startswith('#'):
# 如果是标题开头,提取标题(仅取第一行)
title_end = content_preview.find('\n') if '\n' in content_preview else len(content_preview)
section_title = content_preview[:title_end].replace('#', '').strip()
chunk_info.append(f"{dish_name}({section_title})")
else:
chunk_info.append(f"{dish_name}(内容片段)")
print(f"找到 {len(relevant_chunks)} 个相关文档块: {', '.join(chunk_info)}")
else:
print(f"找到 {len(relevant_chunks)} 个相关文档块")
# 4. 检查是否找到相关内容
if not relevant_chunks:
return "抱歉,没有找到相关的食谱信息。请尝试其他菜品名称或关键词。"
# 5. 根据路由类型选择回答方式
if route_type == 'list':
# 列表查询:直接返回菜品名称列表
print("📋 生成菜品列表...")
relevant_docs = self.data_module.get_parent_documents(relevant_chunks)
# 显示找到的文档名称
doc_names = []
for doc in relevant_docs:
dish_name = doc.metadata.get('dish_name', '未知菜品')
doc_names.append(dish_name)
if doc_names:
print(f"找到文档: {', '.join(doc_names)}")
return self.generation_module.generate_list_answer(question, relevant_docs)
else:
# 详细查询:获取完整文档并生成详细回答
print("获取完整文档...")
relevant_docs = self.data_module.get_parent_documents(relevant_chunks)
# 显示找到的文档名称
doc_names = []
for doc in relevant_docs:
dish_name = doc.metadata.get('dish_name', '未知菜品')
doc_names.append(dish_name)
if doc_names:
print(f"找到文档: {', '.join(doc_names)}")
else:
print(f"对应 {len(relevant_docs)} 个完整文档")
print("✍️ 生成详细回答...")
# 根据路由类型自动选择回答模式
if route_type == "detail":
# 详细查询使用分步指导模式
if stream:
return self.generation_module.generate_step_by_step_answer_stream(question, relevant_docs)
else:
return self.generation_module.generate_step_by_step_answer(question, relevant_docs)
else:
# 一般查询使用基础回答模式
if stream:
return self.generation_module.generate_basic_answer_stream(question, relevant_docs)
else:
return self.generation_module.generate_basic_answer(question, relevant_docs)
def _extract_filters_from_query(self, query: str) -> dict:
"""
从用户问题中提取元数据过滤条件
"""
filters = {}
# 分类关键词
category_keywords = DataPreparationModule.get_supported_categories()
for cat in category_keywords:
if cat in query:
filters['category'] = cat
break
# 难度关键词
difficulty_keywords = DataPreparationModule.get_supported_difficulties()
for diff in sorted(difficulty_keywords, key=len, reverse=True):
if diff in query:
filters['difficulty'] = diff
break
return filters
def search_by_category(self, category: str, query: str = "") -> List[str]:
"""
按分类搜索菜品
Args:
category: 菜品分类
query: 可选的额外查询条件
Returns:
菜品名称列表
"""
if not self.retrieval_module:
raise ValueError("请先构建知识库")
# 使用元数据过滤搜索
search_query = query if query else category
filters = {"category": category}
docs = self.retrieval_module.metadata_filtered_search(search_query, filters, top_k=10)
# 提取菜品名称
dish_names = []
for doc in docs:
dish_name = doc.metadata.get('dish_name', '未知菜品')
if dish_name not in dish_names:
dish_names.append(dish_name)
return dish_names
def get_ingredients_list(self, dish_name: str) -> str:
"""
获取指定菜品的食材信息
Args:
dish_name: 菜品名称
Returns:
食材信息
"""
if not all([self.retrieval_module, self.generation_module]):
raise ValueError("请先构建知识库")
# 搜索相关文档
docs = self.retrieval_module.hybrid_search(dish_name, top_k=3)
# 生成食材信息
answer = self.generation_module.generate_basic_answer(f"{dish_name}需要什么食材?", docs)
return answer
def run_interactive(self):
"""运行交互式问答"""
print("=" * 60)
print("🍽️ 尝尝咸淡RAG系统 - 交互式问答 🍽️")
print("=" * 60)
print("💡 解决您的选择困难症,告别'今天吃什么'的世纪难题!")
# 初始化系统
self.initialize_system()
# 构建知识库
self.build_knowledge_base()
print("\n交互式问答 (输入'退出'结束):")
while True:
try:
user_input = input("\n您的问题: ").strip()
if user_input.lower() in ['退出', 'quit', 'exit', '']:
break
# 询问是否使用流式输出
stream_choice = input("是否使用流式输出? (y/n, 默认y): ").strip().lower()
use_stream = stream_choice != 'n'
print("\n回答:")
if use_stream:
# 流式输出
for chunk in self.ask_question(user_input, stream=True):
print(chunk, end="", flush=True)
print("\n")
else:
# 普通输出
answer = self.ask_question(user_input, stream=False)
print(f"{answer}\n")
except KeyboardInterrupt:
break
except Exception as e:
print(f"处理问题时出错: {e}")
print("\n感谢使用尝尝咸淡RAG系统!")
def main():
"""主函数"""
try:
# 创建RAG系统
rag_system = RecipeRAGSystem()
# 运行交互式问答
rag_system.run_interactive()
except Exception as e:
logger.error(f"系统运行出错: {e}")
print(f"系统错误: {e}")
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
from .data_preparation import DataPreparationModule
from .index_construction import IndexConstructionModule
from .retrieval_optimization import RetrievalOptimizationModule
from .generation_integration import GenerationIntegrationModule
__all__ = [
'DataPreparationModule',
'IndexConstructionModule',
'RetrievalOptimizationModule',
'GenerationIntegrationModule'
]
__version__ = "1.0.0"
+363
View File
@@ -0,0 +1,363 @@
"""
数据准备模块
"""
import logging
import hashlib
from pathlib import Path
from typing import List, Dict, Any
from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_core.documents import Document
from pathlib import Path
import uuid
logger = logging.getLogger(__name__)
class DataPreparationModule:
"""数据准备模块 - 负责数据加载、清洗和预处理"""
# 统一维护的分类与难度配置,供外部复用,避免关键词重复定义
CATEGORY_MAPPING = {
'meat_dish': '荤菜',
'vegetable_dish': '素菜',
'soup': '汤品',
'dessert': '甜品',
'breakfast': '早餐',
'staple': '主食',
'aquatic': '水产',
'condiment': '调料',
'drink': '饮品'
}
CATEGORY_LABELS = list(set(CATEGORY_MAPPING.values()))
DIFFICULTY_LABELS = ['非常简单', '简单', '中等', '困难', '非常困难']
def __init__(self, data_path: str):
"""
初始化数据准备模块
Args:
data_path: 数据文件夹路径
"""
self.data_path = data_path
self.documents: List[Document] = [] # 父文档(完整食谱)
self.chunks: List[Document] = [] # 子文档(按标题分割的小块)
self.parent_child_map: Dict[str, str] = {} # 子块ID -> 父文档ID的映射
def load_documents(self) -> List[Document]:
"""
加载文档数据
Returns:
加载的文档列表
"""
logger.info(f"正在从 {self.data_path} 加载文档...")
# 直接读取Markdown文件以保持原始格式
documents = []
data_path_obj = Path(self.data_path)
for md_file in data_path_obj.rglob("*.md"):
try:
# 直接读取文件内容,保持Markdown格式
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
# 为每个父文档分配确定性的唯一ID(基于数据根目录的相对路径)
try:
data_root = Path(self.data_path).resolve()
relative_path = Path(md_file).resolve().relative_to(data_root).as_posix()
except Exception:
relative_path = Path(md_file).as_posix()
parent_id = hashlib.md5(relative_path.encode("utf-8")).hexdigest()
# 创建Document对象
doc = Document(
page_content=content,
metadata={
"source": str(md_file),
"parent_id": parent_id,
"doc_type": "parent" # 标记为父文档
}
)
documents.append(doc)
except Exception as e:
logger.warning(f"读取文件 {md_file} 失败: {e}")
# 增强文档元数据
for doc in documents:
self._enhance_metadata(doc)
self.documents = documents
logger.info(f"成功加载 {len(documents)} 个文档")
return documents
def _enhance_metadata(self, doc: Document):
"""
增强文档元数据
Args:
doc: 需要增强元数据的文档
"""
file_path = Path(doc.metadata.get('source', ''))
path_parts = file_path.parts
# 提取菜品分类
doc.metadata['category'] = '其他'
for key, value in self.CATEGORY_MAPPING.items():
if key in path_parts:
doc.metadata['category'] = value
break
# 提取菜品名称
doc.metadata['dish_name'] = file_path.stem
# 分析难度等级
content = doc.page_content
if '★★★★★' in content:
doc.metadata['difficulty'] = '非常困难'
elif '★★★★' in content:
doc.metadata['difficulty'] = '困难'
elif '★★★' in content:
doc.metadata['difficulty'] = '中等'
elif '★★' in content:
doc.metadata['difficulty'] = '简单'
elif '' in content:
doc.metadata['difficulty'] = '非常简单'
else:
doc.metadata['difficulty'] = '未知'
@classmethod
def get_supported_categories(cls) -> List[str]:
"""对外提供支持的分类标签列表"""
return cls.CATEGORY_LABELS
@classmethod
def get_supported_difficulties(cls) -> List[str]:
"""对外提供支持的难度标签列表"""
return cls.DIFFICULTY_LABELS
def chunk_documents(self) -> List[Document]:
"""
Markdown结构感知分块
Returns:
分块后的文档列表
"""
logger.info("正在进行Markdown结构感知分块...")
if not self.documents:
raise ValueError("请先加载文档")
# 使用Markdown标题分割器
chunks = self._markdown_header_split()
# 为每个chunk添加基础元数据
for i, chunk in enumerate(chunks):
if 'chunk_id' not in chunk.metadata:
# 如果没有chunk_id(比如分割失败的情况),则生成一个
chunk.metadata['chunk_id'] = str(uuid.uuid4())
chunk.metadata['batch_index'] = i # 在当前批次中的索引
chunk.metadata['chunk_size'] = len(chunk.page_content)
self.chunks = chunks
logger.info(f"Markdown分块完成,共生成 {len(chunks)} 个chunk")
return chunks
def _markdown_header_split(self) -> List[Document]:
"""
使用Markdown标题分割器进行结构化分割
Returns:
按标题结构分割的文档列表
"""
# 定义要分割的标题层级
headers_to_split_on = [
("#", "主标题"), # 菜品名称
("##", "二级标题"), # 必备原料、计算、操作等
("###", "三级标题") # 简易版本、复杂版本等
]
# 创建Markdown分割器
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False # 保留标题,便于理解上下文
)
all_chunks = []
for doc in self.documents:
try:
# 检查文档内容是否包含Markdown标题
content_preview = doc.page_content[:200]
has_headers = any(line.strip().startswith('#') for line in content_preview.split('\n'))
if not has_headers:
logger.warning(f"文档 {doc.metadata.get('dish_name', '未知')} 内容中没有发现Markdown标题")
logger.debug(f"内容预览: {content_preview}")
# 对每个文档进行Markdown分割
md_chunks = markdown_splitter.split_text(doc.page_content)
logger.debug(f"文档 {doc.metadata.get('dish_name', '未知')} 分割成 {len(md_chunks)} 个chunk")
# 如果没有分割成功,说明文档可能没有标题结构
if len(md_chunks) <= 1:
logger.warning(f"文档 {doc.metadata.get('dish_name', '未知')} 未能按标题分割,可能缺少标题结构")
# 为每个子块建立与父文档的关系
parent_id = doc.metadata["parent_id"]
for i, chunk in enumerate(md_chunks):
# 为子块分配唯一ID
child_id = str(uuid.uuid4())
# 合并原文档元数据和新的标题元数据
chunk.metadata.update(doc.metadata)
chunk.metadata.update({
"chunk_id": child_id,
"parent_id": parent_id,
"doc_type": "child", # 标记为子文档
"chunk_index": i # 在父文档中的位置
})
# 建立父子映射关系
self.parent_child_map[child_id] = parent_id
all_chunks.extend(md_chunks)
except Exception as e:
logger.warning(f"文档 {doc.metadata.get('source', '未知')} Markdown分割失败: {e}")
# 如果Markdown分割失败,将整个文档作为一个chunk
all_chunks.append(doc)
logger.info(f"Markdown结构分割完成,生成 {len(all_chunks)} 个结构化块")
return all_chunks
def filter_documents_by_category(self, category: str) -> List[Document]:
"""
按分类过滤文档
Args:
category: 菜品分类
Returns:
过滤后的文档列表
"""
return [doc for doc in self.documents if doc.metadata.get('category') == category]
def filter_documents_by_difficulty(self, difficulty: str) -> List[Document]:
"""
按难度过滤文档
Args:
difficulty: 难度等级
Returns:
过滤后的文档列表
"""
return [doc for doc in self.documents if doc.metadata.get('difficulty') == difficulty]
def get_statistics(self) -> Dict[str, Any]:
"""
获取数据统计信息
Returns:
统计信息字典
"""
if not self.documents:
return {}
categories = {}
difficulties = {}
for doc in self.documents:
# 统计分类
category = doc.metadata.get('category', '未知')
categories[category] = categories.get(category, 0) + 1
# 统计难度
difficulty = doc.metadata.get('difficulty', '未知')
difficulties[difficulty] = difficulties.get(difficulty, 0) + 1
return {
'total_documents': len(self.documents),
'total_chunks': len(self.chunks),
'categories': categories,
'difficulties': difficulties,
'avg_chunk_size': sum(chunk.metadata.get('chunk_size', 0) for chunk in self.chunks) / len(self.chunks) if self.chunks else 0
}
def export_metadata(self, output_path: str):
"""
导出元数据到JSON文件
Args:
output_path: 输出文件路径
"""
import json
metadata_list = []
for doc in self.documents:
metadata_list.append({
'source': doc.metadata.get('source'),
'dish_name': doc.metadata.get('dish_name'),
'category': doc.metadata.get('category'),
'difficulty': doc.metadata.get('difficulty'),
'content_length': len(doc.page_content)
})
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(metadata_list, f, ensure_ascii=False, indent=2)
logger.info(f"元数据已导出到: {output_path}")
def get_parent_documents(self, child_chunks: List[Document]) -> List[Document]:
"""
根据子块获取对应的父文档(智能去重)
Args:
child_chunks: 检索到的子块列表
Returns:
对应的父文档列表(去重,按相关性排序)
"""
# 统计每个父文档被匹配的次数(相关性指标)
parent_relevance = {}
parent_docs_map = {}
# 收集所有相关的父文档ID和相关性分数
for chunk in child_chunks:
parent_id = chunk.metadata.get("parent_id")
if parent_id:
# 增加相关性计数
parent_relevance[parent_id] = parent_relevance.get(parent_id, 0) + 1
# 缓存父文档(避免重复查找)
if parent_id not in parent_docs_map:
for doc in self.documents:
if doc.metadata.get("parent_id") == parent_id:
parent_docs_map[parent_id] = doc
break
# 按相关性排序(匹配次数多的排在前面)
sorted_parent_ids = sorted(parent_relevance.keys(),
key=lambda x: parent_relevance[x],
reverse=True)
# 构建去重后的父文档列表
parent_docs = []
for parent_id in sorted_parent_ids:
if parent_id in parent_docs_map:
parent_docs.append(parent_docs_map[parent_id])
# 收集父文档名称和相关性信息用于日志
parent_info = []
for doc in parent_docs:
dish_name = doc.metadata.get('dish_name', '未知菜品')
parent_id = doc.metadata.get('parent_id')
relevance_count = parent_relevance.get(parent_id, 0)
parent_info.append(f"{dish_name}({relevance_count}块)")
logger.info(f"{len(child_chunks)} 个子块中找到 {len(parent_docs)} 个去重父文档: {', '.join(parent_info)}")
return parent_docs
@@ -0,0 +1,401 @@
"""
生成集成模块
"""
import os
import logging
from typing import List
from langchain_core.prompts import ChatPromptTemplate, PromptTemplate
from langchain_community.chat_models.moonshot import MoonshotChat
from langchain_core.documents import Document
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
logger = logging.getLogger(__name__)
class GenerationIntegrationModule:
"""生成集成模块 - 负责LLM集成和回答生成"""
def __init__(self, model_name: str = "kimi-k2-0711-preview", temperature: float = 0.1, max_tokens: int = 2048):
"""
初始化生成集成模块
Args:
model_name: 模型名称
temperature: 生成温度
max_tokens: 最大token数
"""
self.model_name = model_name
self.temperature = temperature
self.max_tokens = max_tokens
self.llm = None
self.setup_llm()
def setup_llm(self):
"""初始化大语言模型"""
logger.info(f"正在初始化LLM: {self.model_name}")
api_key = os.getenv("MOONSHOT_API_KEY")
if not api_key:
raise ValueError("请设置 MOONSHOT_API_KEY 环境变量")
self.llm = MoonshotChat(
model=self.model_name,
temperature=self.temperature,
max_tokens=self.max_tokens,
moonshot_api_key=api_key
)
logger.info("LLM初始化完成")
def generate_basic_answer(self, query: str, context_docs: List[Document]) -> str:
"""
生成基础回答
Args:
query: 用户查询
context_docs: 上下文文档列表
Returns:
生成的回答
"""
context = self._build_context(context_docs)
prompt = ChatPromptTemplate.from_template("""
你是一位专业的烹饪助手。请根据以下食谱信息回答用户的问题。
用户问题: {question}
相关食谱信息:
{context}
请提供详细、实用的回答。如果信息不足,请诚实说明。
回答:""")
# 使用LCEL构建链
chain = (
{"question": RunnablePassthrough(), "context": lambda _: context}
| prompt
| self.llm
| StrOutputParser()
)
response = chain.invoke(query)
return response
def generate_step_by_step_answer(self, query: str, context_docs: List[Document]) -> str:
"""
生成分步骤回答
Args:
query: 用户查询
context_docs: 上下文文档列表
Returns:
分步骤的详细回答
"""
context = self._build_context(context_docs)
prompt = ChatPromptTemplate.from_template("""
你是一位专业的烹饪导师。请根据食谱信息,为用户提供详细的分步骤指导。
用户问题: {question}
相关食谱信息:
{context}
请灵活组织回答,建议包含以下部分(可根据实际内容调整):
## 🥘 菜品介绍
[简要介绍菜品特点和难度]
## 🛒 所需食材
[列出主要食材和用量]
## 👨‍🍳 制作步骤
[详细的分步骤说明,每步包含具体操作和大概所需时间]
## 💡 制作技巧
[仅在有实用技巧时包含。优先使用原文中的实用技巧,如果原文的"附加内容"与烹饪无关或为空,可以基于制作步骤总结关键要点,或者完全省略此部分]
注意:
- 根据实际内容灵活调整结构
- 不要强行填充无关内容或重复制作步骤中的信息
- 重点突出实用性和可操作性
- 如果没有额外的技巧要分享,可以省略制作技巧部分
回答:""")
chain = (
{"question": RunnablePassthrough(), "context": lambda _: context}
| prompt
| self.llm
| StrOutputParser()
)
response = chain.invoke(query)
return response
def query_rewrite(self, query: str) -> str:
"""
智能查询重写 - 让大模型判断是否需要重写查询
Args:
query: 原始查询
Returns:
重写后的查询或原查询
"""
prompt = PromptTemplate(
template="""
你是一个智能查询分析助手。请分析用户的查询,判断是否需要重写以提高食谱搜索效果。
原始查询: {query}
分析规则:
1. **具体明确的查询**(直接返回原查询):
- 包含具体菜品名称:如"宫保鸡丁怎么做""红烧肉的制作方法"
- 明确的制作询问:如"蛋炒饭需要什么食材""糖醋排骨的步骤"
- 具体的烹饪技巧:如"如何炒菜不粘锅""怎样调制糖醋汁"
2. **模糊不清的查询**(需要重写):
- 过于宽泛:如"做菜""有什么好吃的""推荐个菜"
- 缺乏具体信息:如"川菜""素菜""简单的"
- 口语化表达:如"想吃点什么""有饮品推荐吗"
重写原则:
- 保持原意不变
- 增加相关烹饪术语
- 优先推荐简单易做的
- 保持简洁性
示例:
- "做菜""简单易做的家常菜谱"
- "有饮品推荐吗""简单饮品制作方法"
- "推荐个菜""简单家常菜推荐"
- "川菜""经典川菜菜谱"
- "宫保鸡丁怎么做""宫保鸡丁怎么做"(保持原查询)
- "红烧肉需要什么食材""红烧肉需要什么食材"(保持原查询)
请输出最终查询(如果不需要重写就返回原查询):""",
input_variables=["query"]
)
chain = (
{"query": RunnablePassthrough()}
| prompt
| self.llm
| StrOutputParser()
)
response = chain.invoke(query).strip()
# 记录重写结果
if response != query:
logger.info(f"查询已重写: '{query}''{response}'")
else:
logger.info(f"查询无需重写: '{query}'")
return response
def query_router(self, query: str) -> str:
"""
查询路由 - 根据查询类型选择不同的处理方式
Args:
query: 用户查询
Returns:
路由类型 ('list', 'detail', 'general')
"""
prompt = ChatPromptTemplate.from_template("""
根据用户的问题,将其分类为以下三种类型之一:
1. 'list' - 用户想要获取菜品列表或推荐,只需要菜名
例如:推荐几个素菜、有什么川菜、给我3个简单的菜
2. 'detail' - 用户想要具体的制作方法或详细信息
例如:宫保鸡丁怎么做、制作步骤、需要什么食材
3. 'general' - 其他一般性问题
例如:什么是川菜、制作技巧、营养价值
请只返回分类结果:list、detail 或 general
用户问题: {query}
分类结果:""")
chain = (
{"query": RunnablePassthrough()}
| prompt
| self.llm
| StrOutputParser()
)
result = chain.invoke(query).strip().lower()
# 确保返回有效的路由类型
if result in ['list', 'detail', 'general']:
return result
else:
return 'general' # 默认类型
def generate_list_answer(self, query: str, context_docs: List[Document]) -> str:
"""
生成列表式回答 - 适用于推荐类查询
Args:
query: 用户查询
context_docs: 上下文文档列表
Returns:
列表式回答
"""
if not context_docs:
return "抱歉,没有找到相关的菜品信息。"
# 提取菜品名称
dish_names = []
for doc in context_docs:
dish_name = doc.metadata.get('dish_name', '未知菜品')
if dish_name not in dish_names:
dish_names.append(dish_name)
# 构建简洁的列表回答
if len(dish_names) == 1:
return f"为您推荐:{dish_names[0]}"
elif len(dish_names) <= 3:
return f"为您推荐以下菜品:\n" + "\n".join([f"{i+1}. {name}" for i, name in enumerate(dish_names)])
else:
return f"为您推荐以下菜品:\n" + "\n".join([f"{i+1}. {name}" for i, name in enumerate(dish_names[:3])]) + f"\n\n还有其他 {len(dish_names)-3} 道菜品可供选择。"
def generate_basic_answer_stream(self, query: str, context_docs: List[Document]):
"""
生成基础回答 - 流式输出
Args:
query: 用户查询
context_docs: 上下文文档列表
Yields:
生成的回答片段
"""
context = self._build_context(context_docs)
prompt = ChatPromptTemplate.from_template("""
你是一位专业的烹饪助手。请根据以下食谱信息回答用户的问题。
用户问题: {question}
相关食谱信息:
{context}
请提供详细、实用的回答。如果信息不足,请诚实说明。
回答:""")
chain = (
{"question": RunnablePassthrough(), "context": lambda _: context}
| prompt
| self.llm
| StrOutputParser()
)
for chunk in chain.stream(query):
yield chunk
def generate_step_by_step_answer_stream(self, query: str, context_docs: List[Document]):
"""
生成详细步骤回答 - 流式输出
Args:
query: 用户查询
context_docs: 上下文文档列表
Yields:
详细步骤回答片段
"""
context = self._build_context(context_docs)
prompt = ChatPromptTemplate.from_template("""
你是一位专业的烹饪导师。请根据食谱信息,为用户提供详细的分步骤指导。
用户问题: {question}
相关食谱信息:
{context}
请灵活组织回答,建议包含以下部分(可根据实际内容调整):
## 🥘 菜品介绍
[简要介绍菜品特点和难度]
## 🛒 所需食材
[列出主要食材和用量]
## 👨‍🍳 制作步骤
[详细的分步骤说明,每步包含具体操作和大概所需时间]
## 💡 制作技巧
[仅在有实用技巧时包含。如果原文的"附加内容"与烹饪无关或为空,可以基于制作步骤总结关键要点,或者完全省略此部分]
注意:
- 根据实际内容灵活调整结构
- 不要强行填充无关内容
- 重点突出实用性和可操作性
回答:""")
chain = (
{"question": RunnablePassthrough(), "context": lambda _: context}
| prompt
| self.llm
| StrOutputParser()
)
for chunk in chain.stream(query):
yield chunk
def _build_context(self, docs: List[Document], max_length: int = 2000) -> str:
"""
构建上下文字符串
Args:
docs: 文档列表
max_length: 最大长度
Returns:
格式化的上下文字符串
"""
if not docs:
return "暂无相关食谱信息。"
context_parts = []
current_length = 0
for i, doc in enumerate(docs, 1):
# 添加元数据信息
metadata_info = f"【食谱 {i}"
if 'dish_name' in doc.metadata:
metadata_info += f" {doc.metadata['dish_name']}"
if 'category' in doc.metadata:
metadata_info += f" | 分类: {doc.metadata['category']}"
if 'difficulty' in doc.metadata:
metadata_info += f" | 难度: {doc.metadata['difficulty']}"
# 构建文档文本
doc_text = f"{metadata_info}\n{doc.page_content}\n"
# 检查长度限制
if current_length + len(doc_text) > max_length:
break
context_parts.append(doc_text)
current_length += len(doc_text)
return "\n" + "="*50 + "\n".join(context_parts)
+135
View File
@@ -0,0 +1,135 @@
"""
索引构建模块
"""
import logging
from typing import List
from pathlib import Path
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
logger = logging.getLogger(__name__)
class IndexConstructionModule:
"""索引构建模块 - 负责向量化和索引构建"""
def __init__(self, model_name: str = "BAAI/bge-small-zh-v1.5", index_save_path: str = "./vector_index"):
"""
初始化索引构建模块
Args:
model_name: 嵌入模型名称
index_save_path: 索引保存路径
"""
self.model_name = model_name
self.index_save_path = index_save_path
self.embeddings = None
self.vectorstore = None
self.setup_embeddings()
def setup_embeddings(self):
"""初始化嵌入模型"""
logger.info(f"正在初始化嵌入模型: {self.model_name}")
self.embeddings = HuggingFaceEmbeddings(
model_name=self.model_name,
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
logger.info("嵌入模型初始化完成")
def build_vector_index(self, chunks: List[Document]) -> FAISS:
"""
构建向量索引
Args:
chunks: 文档块列表
Returns:
FAISS向量存储对象
"""
logger.info("正在构建FAISS向量索引...")
if not chunks:
raise ValueError("文档块列表不能为空")
# 构建FAISS向量存储
self.vectorstore = FAISS.from_documents(
documents=chunks,
embedding=self.embeddings
)
logger.info(f"向量索引构建完成,包含 {len(chunks)} 个向量")
return self.vectorstore
def add_documents(self, new_chunks: List[Document]):
"""
向现有索引添加新文档
Args:
new_chunks: 新的文档块列表
"""
if not self.vectorstore:
raise ValueError("请先构建向量索引")
logger.info(f"正在添加 {len(new_chunks)} 个新文档到索引...")
self.vectorstore.add_documents(new_chunks)
logger.info("新文档添加完成")
def save_index(self):
"""
保存向量索引到配置的路径
"""
if not self.vectorstore:
raise ValueError("请先构建向量索引")
# 确保保存目录存在
Path(self.index_save_path).mkdir(parents=True, exist_ok=True)
self.vectorstore.save_local(self.index_save_path)
logger.info(f"向量索引已保存到: {self.index_save_path}")
def load_index(self):
"""
从配置的路径加载向量索引
Returns:
加载的向量存储对象,如果加载失败返回None
"""
if not self.embeddings:
self.setup_embeddings()
if not Path(self.index_save_path).exists():
logger.info(f"索引路径不存在: {self.index_save_path},将构建新索引")
return None
try:
self.vectorstore = FAISS.load_local(
self.index_save_path,
self.embeddings,
allow_dangerous_deserialization=True
)
logger.info(f"向量索引已从 {self.index_save_path} 加载")
return self.vectorstore
except Exception as e:
logger.warning(f"加载向量索引失败: {e},将构建新索引")
return None
def similarity_search(self, query: str, k: int = 5) -> List[Document]:
"""
相似度搜索
Args:
query: 查询文本
k: 返回结果数量
Returns:
相似文档列表
"""
if not self.vectorstore:
raise ValueError("请先构建或加载向量索引")
return self.vectorstore.similarity_search(query, k=k)
@@ -0,0 +1,162 @@
"""
检索优化模块
"""
import logging
from typing import List, Dict, Any
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
logger = logging.getLogger(__name__)
class RetrievalOptimizationModule:
"""检索优化模块 - 负责混合检索和过滤"""
def __init__(self, vectorstore: FAISS, chunks: List[Document]):
"""
初始化检索优化模块
Args:
vectorstore: FAISS向量存储
chunks: 文档块列表
"""
self.vectorstore = vectorstore
self.chunks = chunks
self.setup_retrievers()
def setup_retrievers(self):
"""设置向量检索器和BM25检索器"""
logger.info("正在设置检索器...")
# 向量检索器
self.vector_retriever = self.vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)
# BM25检索器
self.bm25_retriever = BM25Retriever.from_documents(
self.chunks,
k=5
)
logger.info("检索器设置完成")
def hybrid_search(self, query: str, top_k: int = 3) -> List[Document]:
"""
混合检索 - 结合向量检索和BM25检索,使用RRF重排
Args:
query: 查询文本
top_k: 返回结果数量
Returns:
检索到的文档列表
"""
# 分别获取向量检索和BM25检索结果
vector_docs = self.vector_retriever.invoke(query)
bm25_docs = self.bm25_retriever.invoke(query)
# 使用RRF重排
reranked_docs = self._rrf_rerank(vector_docs, bm25_docs)
return reranked_docs[:top_k]
def metadata_filtered_search(self, query: str, filters: Dict[str, Any], top_k: int = 5) -> List[Document]:
"""
带元数据过滤的检索
Args:
query: 查询文本
filters: 元数据过滤条件
top_k: 返回结果数量
Returns:
过滤后的文档列表
"""
# 先进行混合检索,获取更多候选
docs = self.hybrid_search(query, top_k * 3)
# 应用元数据过滤
filtered_docs = []
for doc in docs:
match = True
for key, value in filters.items():
if key in doc.metadata:
if isinstance(value, list):
if doc.metadata[key] not in value:
match = False
break
else:
if doc.metadata[key] != value:
match = False
break
else:
match = False
break
if match:
filtered_docs.append(doc)
if len(filtered_docs) >= top_k:
break
return filtered_docs
def _rrf_rerank(self, vector_docs: List[Document], bm25_docs: List[Document], k: int = 60) -> List[Document]:
"""
使用RRF (Reciprocal Rank Fusion) 算法重排文档
Args:
vector_docs: 向量检索结果
bm25_docs: BM25检索结果
k: RRF参数,用于平滑排名
Returns:
重排后的文档列表
"""
doc_scores = {}
doc_objects = {}
# 计算向量检索结果的RRF分数
for rank, doc in enumerate(vector_docs):
# 使用文档内容的哈希作为唯一标识
doc_id = hash(doc.page_content)
doc_objects[doc_id] = doc
# RRF公式: 1 / (k + rank)
rrf_score = 1.0 / (k + rank + 1)
doc_scores[doc_id] = doc_scores.get(doc_id, 0) + rrf_score
logger.debug(f"向量检索 - 文档{rank+1}: RRF分数 = {rrf_score:.4f}")
# 计算BM25检索结果的RRF分数
for rank, doc in enumerate(bm25_docs):
doc_id = hash(doc.page_content)
doc_objects[doc_id] = doc
rrf_score = 1.0 / (k + rank + 1)
doc_scores[doc_id] = doc_scores.get(doc_id, 0) + rrf_score
logger.debug(f"BM25检索 - 文档{rank+1}: RRF分数 = {rrf_score:.4f}")
# 按最终RRF分数排序
sorted_docs = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
# 构建最终结果
reranked_docs = []
for doc_id, final_score in sorted_docs:
if doc_id in doc_objects:
doc = doc_objects[doc_id]
# 将RRF分数添加到文档元数据中
doc.metadata['rrf_score'] = final_score
reranked_docs.append(doc)
logger.debug(f"最终排序 - 文档: {doc.page_content[:50]}... 最终RRF分数: {final_score:.4f}")
logger.info(f"RRF重排完成: 向量检索{len(vector_docs)}个文档, BM25检索{len(bm25_docs)}个文档, 合并后{len(reranked_docs)}个文档")
return reranked_docs
+12
View File
@@ -0,0 +1,12 @@
langchain==0.3.26
langchain-huggingface==0.3.1
langchain-text-splitters==0.3.8
langchain-unstructured==0.1.6
langchain-community==0.3.27
faiss-cpu>=1.7.0
unstructured==0.18.11
Markdown==3.8.2
sentence-transformers>=3.0.0
lazy_loader==0.4
rank_bm25==0.2.2
openai>=1.86.0,<2.0.0