| """ |
| ============================================================ |
| RAG 检索增强生成问答链 |
| ============================================================ |
| LLM: Qwen3-8B (通过 OpenAI 兼容 API 调用) |
| 嵌入: Qwen3-Embedding (通过 OpenAI 兼容 API 调用) |
| |
| 所有模型均通过 API 调用, 无需本地推理: |
| - Embedding API: /v1/embeddings |
| - LLM API: /v1/chat/completions |
| |
| 支持任意 OpenAI 兼容 API: |
| - vLLM 部署的 Qwen3 / Llama / DeepSeek 等 |
| - 第三方 API (DeepSeek, 通义千问, 智谱 GLM 等) |
| - OpenAI 官方 API |
| |
| 功能: |
| 1. LangChain LCEL RAG 问答链 |
| 2. 多轮对话 |
| 3. 流式输出 |
| 4. 来源引用 |
| """ |
|
|
| from typing import List, Optional, Dict, Any, Iterator |
|
|
| from langchain_core.documents import Document |
| from langchain_core.prompts import ChatPromptTemplate |
| from langchain_core.runnables import RunnableParallel |
| from langchain_core.output_parsers import StrOutputParser |
| from langchain_core.language_models import BaseChatModel |
| from langchain_core.messages import HumanMessage, SystemMessage |
|
|
| from langchain_openai import ChatOpenAI |
|
|
| from loguru import logger |
|
|
| import config |
| from vector_store import VectorStoreManager |
|
|
|
|
| |
| |
| |
|
|
| def create_llm( |
| model_name: Optional[str] = None, |
| api_base: Optional[str] = None, |
| api_key: Optional[str] = None, |
| temperature: Optional[float] = None, |
| max_tokens: Optional[int] = None, |
| ) -> ChatOpenAI: |
| """ |
| 创建 OpenAI 兼容的 LLM 实例 |
| |
| Args: |
| model_name: 模型名称, 如 Qwen/Qwen3-8B |
| api_base: API 地址 |
| api_key: API Key |
| temperature: 生成温度 |
| max_tokens: 最大输出 token 数 |
| |
| Returns: |
| ChatOpenAI 实例 |
| """ |
| return ChatOpenAI( |
| model=model_name or config.LLM_MODEL_NAME, |
| api_key=api_key or config.LLM_API_KEY, |
| base_url=api_base or config.LLM_API_BASE, |
| temperature=temperature or config.LLM_TEMPERATURE, |
| max_tokens=max_tokens or config.LLM_MAX_TOKENS, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class RAGChain: |
| """ |
| RAG 检索增强生成链 |
| |
| 流程: |
| Query → Embedding API 检索 → 上下文格式化 → |
| Prompt 模板 → LLM API 生成 → 结构化回答 (含来源) |
| |
| 用法: |
| rag = RAGChain(vector_store_manager) |
| result = rag.query("文档主要内容是什么?") |
| """ |
|
|
| def __init__( |
| self, |
| vector_store_manager: VectorStoreManager, |
| llm: Optional[BaseChatModel] = None, |
| top_k: int = config.RETRIEVAL_TOP_K, |
| system_prompt: Optional[str] = None, |
| search_type: str = "similarity", |
| ): |
| self.vector_store_manager = vector_store_manager |
| self.llm = llm or create_llm() |
| self.top_k = top_k |
| self.system_prompt = system_prompt or config.SYSTEM_PROMPT |
| self.search_type = search_type |
| self._chain = self._build_chain() |
|
|
| logger.info( |
| f"RAG 问答链初始化完成 (LLM={config.LLM_MODEL_NAME}, " |
| f"top_k={top_k}, search={search_type})" |
| ) |
|
|
| def _build_chain(self): |
| """使用 LangChain LCEL 构建 RAG 链""" |
| prompt = ChatPromptTemplate.from_messages([ |
| ("system", "{system_prompt}"), |
| ("human", config.RAG_PROMPT_TEMPLATE), |
| ]) |
|
|
| chain = ( |
| RunnableParallel({ |
| "context": lambda inputs: self._retrieve_and_format(inputs["query"]), |
| "question": lambda inputs: inputs["query"], |
| "system_prompt": lambda _: self.system_prompt, |
| }) |
| | prompt |
| | self.llm |
| | StrOutputParser() |
| ) |
|
|
| return chain |
|
|
| def _retrieve_and_format(self, query: str) -> str: |
| docs = self._retrieve(query) |
| return self._format_docs(docs) |
|
|
| def _retrieve(self, query: str) -> List[Document]: |
| if self.search_type == "mmr": |
| return self.vector_store_manager.max_marginal_relevance_search( |
| query, k=self.top_k |
| ) |
| elif self.search_type == "similarity_score": |
| results = self.vector_store_manager.similarity_search_with_score( |
| query, k=self.top_k |
| ) |
| return [doc for doc, _ in results] |
| else: |
| return self.vector_store_manager.similarity_search(query, k=self.top_k) |
|
|
| MAX_CONTEXT_CHARS = 1800 |
|
|
| @classmethod |
| def _format_docs(cls, docs: List[Document]) -> str: |
| if not docs: |
| return "(未找到相关文档内容)" |
|
|
| |
| max_chunk_chars = cls.MAX_CONTEXT_CHARS // max(len(docs), 1) |
|
|
| parts = [] |
| for i, doc in enumerate(docs, 1): |
| page = doc.metadata.get("page", "未知") |
| doc_name = doc.metadata.get("document_name", "未知文档") |
|
|
| content = doc.page_content |
| if len(content) > max_chunk_chars: |
| content = content[:max_chunk_chars] + "..." |
|
|
| header = f"[{i}] {doc_name} p{page}" |
| parts.append(f"{header}\n{content}") |
|
|
| return "\n\n---\n\n".join(parts) |
|
|
| |
|
|
| def query(self, question: str) -> Dict[str, Any]: |
| """ |
| 单次问答 |
| |
| Returns: |
| {"query": str, "answer": str, "sources": [...], "context": str} |
| """ |
| logger.info(f"RAG 查询: {question[:100]}...") |
|
|
| retrieved_docs = self._retrieve(question) |
| answer = self._chain.invoke({"query": question}) |
|
|
| sources = self._build_sources(retrieved_docs) |
|
|
| logger.info(f"生成完成: {len(answer)} 字符, {len(sources)} 个来源") |
| return { |
| "query": question, |
| "answer": answer, |
| "sources": sources, |
| "context": self._format_docs(retrieved_docs), |
| } |
|
|
| def query_stream(self, question: str) -> Iterator[str]: |
| """流式问答""" |
| logger.info(f"RAG 流式查询: {question[:100]}...") |
| for chunk in self._chain.stream({"query": question}): |
| yield chunk |
|
|
| def query_with_history( |
| self, |
| question: str, |
| chat_history: Optional[List[Dict[str, str]]] = None, |
| ) -> Dict[str, Any]: |
| """带对话历史的多轮问答""" |
| chat_history = chat_history or [] |
|
|
| history_context = self._format_history(chat_history) |
| retrieved_docs = self._retrieve(question) |
| context = self._format_docs(retrieved_docs) |
|
|
| messages = [ |
| SystemMessage(content=( |
| f"{self.system_prompt}\n\n" |
| f"## 对话历史:\n{history_context}" |
| )), |
| HumanMessage(content=config.RAG_PROMPT_TEMPLATE.format( |
| system_prompt="", |
| context=context, |
| question=question, |
| )), |
| ] |
|
|
| response = self.llm.invoke(messages) |
| answer = response.content |
|
|
| return { |
| "query": question, |
| "answer": answer, |
| "sources": self._build_sources(retrieved_docs), |
| "context": context, |
| } |
|
|
| @staticmethod |
| def _build_sources(docs: List[Document]) -> List[Dict[str, Any]]: |
| return [ |
| { |
| "rank": i, |
| "content": doc.page_content[:300], |
| "page": doc.metadata.get("page", "未知"), |
| "document": doc.metadata.get("document_name", "未知"), |
| "content_type": doc.metadata.get("content_type", "text"), |
| } |
| for i, doc in enumerate(docs, 1) |
| ] |
|
|
| @staticmethod |
| def _format_history(chat_history: List[Dict[str, str]]) -> str: |
| if not chat_history: |
| return "(无历史对话)" |
| parts = [] |
| for turn in chat_history[-8:]: |
| role = "用户" if turn.get("role") == "user" else "助手" |
| parts.append(f"{role}: {turn.get('content', '')}") |
| return "\n".join(parts) |
|
|
|
|
| |
| |
| |
|
|
| class PDFRAGPipeline: |
| """ |
| PDF 智能问答完整流水线 (全 API 模式) |
| |
| 一步完成: 文档上传 → OCR → 清洗 → 分割 → API嵌入 → 入库 → API问答 |
| |
| 用法: |
| pipeline = PDFRAGPipeline() |
| pipeline.ingest("document.pdf") |
| result = pipeline.ask("文档主要内容是什么?") |
| """ |
|
|
| def __init__( |
| self, |
| llm: Optional[BaseChatModel] = None, |
| store_type: Optional[str] = None, |
| chunk_size: int = config.CHUNK_SIZE, |
| chunk_overlap: int = config.CHUNK_OVERLAP, |
| verbose: bool = True, |
| ): |
| self.llm = llm or create_llm() |
| self.store_type = store_type or config.VECTOR_STORE_TYPE |
| self.chunk_size = chunk_size |
| self.chunk_overlap = chunk_overlap |
| self.verbose = verbose |
|
|
| self._vector_store_manager: Optional[VectorStoreManager] = None |
| self._rag_chain: Optional[RAGChain] = None |
|
|
| def ingest(self, file_path: str, clear_existing: bool = True) -> int: |
| """ |
| 处理文档并构建向量数据库 |
| |
| 支持格式: PDF / PNG / JPG / BMP / TIF |
| """ |
| from ocr_loader import PaddleOCRLoader |
| from text_processor import TextProcessingPipeline |
|
|
| logger.info(f"开始入库: {file_path}") |
|
|
| |
| self._log("Step 1/4: PaddleOCR-VL-1.5 识别...") |
| loader = PaddleOCRLoader(file_path, verbose=False) |
| raw_docs = loader.load() |
| self._log(f" ✓ 识别完成: {len(raw_docs)} 页/文档") |
|
|
| |
| self._log("Step 2/4: 文本清洗与分割...") |
| pipeline = TextProcessingPipeline( |
| chunk_size=self.chunk_size, |
| chunk_overlap=self.chunk_overlap, |
| ) |
| chunks = pipeline.process(raw_docs) |
| self._log(f" ✓ 分割完成: {len(chunks)} 个文本块") |
|
|
| |
| self._log("Step 3/4: Embedding API 向量化...") |
| self._vector_store_manager = VectorStoreManager(store_type=self.store_type) |
| if clear_existing: |
| self._vector_store_manager.clear() |
| chunk_count = self._vector_store_manager.add_documents(chunks) |
| self._log(f" ✓ 入库完成: {chunk_count} 个文本块") |
|
|
| |
| self._log("Step 4/4: 初始化 RAG 引擎...") |
| self._rag_chain = RAGChain( |
| vector_store_manager=self._vector_store_manager, |
| llm=self.llm, |
| ) |
| self._log(" ✓ 问答引擎就绪") |
| self._log("入库完成! 可以开始提问。") |
|
|
| return chunk_count |
|
|
| def ingest_multiple(self, file_paths: List[str], clear_existing: bool = True) -> int: |
| total = 0 |
| for i, fp in enumerate(file_paths): |
| total += self.ingest(fp, clear_existing=(clear_existing and i == 0)) |
| return total |
|
|
| def ask(self, question: str) -> Dict[str, Any]: |
| if self._rag_chain is None: |
| self._vector_store_manager = VectorStoreManager(store_type=self.store_type) |
| if self._vector_store_manager.get_document_count() == 0: |
| raise RuntimeError("向量数据库为空! 请先调用 ingest() 处理文档。") |
| self._rag_chain = RAGChain( |
| vector_store_manager=self._vector_store_manager, |
| llm=self.llm, |
| ) |
| return self._rag_chain.query(question) |
|
|
| def ask_stream(self, question: str) -> Iterator[str]: |
| if self._rag_chain is None: |
| raise RuntimeError("请先调用 ingest() 处理文档。") |
| return self._rag_chain.query_stream(question) |
|
|
| def ask_with_history( |
| self, question: str, |
| chat_history: Optional[List[Dict[str, str]]] = None, |
| ) -> Dict[str, Any]: |
| if self._rag_chain is None: |
| raise RuntimeError("请先调用 ingest() 处理文档。") |
| return self._rag_chain.query_with_history(question, chat_history) |
|
|
| @property |
| def is_ready(self) -> bool: |
| try: |
| if self._vector_store_manager is None: |
| self._vector_store_manager = VectorStoreManager(store_type=self.store_type) |
| return self._vector_store_manager.get_document_count() > 0 |
| except Exception: |
| return False |
|
|
| @property |
| def stats(self) -> Dict[str, Any]: |
| if self._vector_store_manager is None: |
| return {"status": "not_initialized"} |
| return self._vector_store_manager.get_stats() |
|
|
| def _log(self, msg: str): |
| if self.verbose: |
| print(msg) |
|
|
|
|
| |
| |
| |
|
|
| def quick_qa(file_path: str, question: str) -> Dict[str, Any]: |
| """便捷函数: 直接对文档提问 (一次性)""" |
| from ocr_loader import PaddleOCRLoader |
| from text_processor import TextProcessingPipeline |
| from vector_store import build_vector_store |
|
|
| loader = PaddleOCRLoader(file_path, verbose=False) |
| raw_docs = loader.load() |
| pipeline = TextProcessingPipeline() |
| chunks = pipeline.process(raw_docs) |
| manager = build_vector_store(chunks, clear_existing=True) |
| chain = RAGChain(vector_store_manager=manager) |
| return chain.query(question) |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| import sys |
|
|
| if len(sys.argv) < 3: |
| print(f"用法: python {__file__} <file_path> <question>") |
| print(f"示例: python {__file__} document.pdf '文档主要内容是什么?'") |
| sys.exit(1) |
|
|
| file_path = sys.argv[1] |
| question = sys.argv[2] |
|
|
| print(f"\n{'='*60}") |
| print(f" PDF/文档 智能问答测试") |
| print(f" 文件: {file_path}") |
| print(f" 问题: {question}") |
| print(f"{'='*60}") |
|
|
| result = quick_qa(file_path, question) |
|
|
| print(f"\n{'='*60}") |
| print(f" 回答:") |
| print(f"{'='*60}") |
| print(result["answer"]) |
|
|
| print(f"\n{'='*60}") |
| print(f" 参考来源:") |
| print(f"{'='*60}") |
| for src in result["sources"]: |
| print(f" [{src['rank']}] {src['document']} 第{src['page']}页 ({src['content_type']})") |
| print(f" {src['content'][:150]}...") |
|
|