#novel_generator/vectorstore_utils.py # -*- coding: utf-8 -*- """ 向量库相关操作(初始化、更新、检索、清空、文本切分等) """ import os import logging import traceback import nltk import numpy as np import re import ssl import requests import warnings from langchain_chroma import Chroma logging.basicConfig( filename='app.log', # 日志文件名 filemode='a', # 追加模式('w' 会覆盖) level=logging.INFO, # 记录 INFO 及以上级别的日志 format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) # 禁用特定的Torch警告 warnings.filterwarnings('ignore', message='.*Torch was not compiled with flash attention.*') os.environ["TOKENIZERS_PARALLELISM"] = "false" # 禁用tokenizer并行警告 from chromadb.config import Settings from langchain.docstore.document import Document from sklearn.metrics.pairwise import cosine_similarity from .common import call_with_retry def get_vectorstore_dir(filepath: str) -> str: """获取 vectorstore 路径""" return os.path.join(filepath, "vectorstore") def clear_vector_store(filepath: str) -> bool: """清空 清空向量库""" import shutil store_dir = get_vectorstore_dir(filepath) if not os.path.exists(store_dir): logging.info("No vector store found to clear.") return False try: shutil.rmtree(store_dir) logging.info(f"Vector store directory '{store_dir}' removed.") return True except Exception as e: logging.error(f"无法删除向量库文件夹,请关闭程序后手动删除 {store_dir}。\n {str(e)}") traceback.print_exc() return False def init_vector_store(embedding_adapter, texts, filepath: str): """ 在 filepath 下创建/加载一个 Chroma 向量库并插入 texts。 如果Embedding失败,则返回 None,不中断任务。 """ from langchain.embeddings.base import Embeddings as LCEmbeddings store_dir = get_vectorstore_dir(filepath) os.makedirs(store_dir, exist_ok=True) documents = [Document(page_content=str(t)) for t in texts] try: class LCEmbeddingWrapper(LCEmbeddings): def embed_documents(self, texts): return call_with_retry( func=embedding_adapter.embed_documents, max_retries=3, fallback_return=[], texts=texts ) def embed_query(self, query: str): res = call_with_retry( func=embedding_adapter.embed_query, max_retries=3, fallback_return=[], query=query ) return res chroma_embedding = LCEmbeddingWrapper() vectorstore = Chroma.from_documents( documents, embedding=chroma_embedding, persist_directory=store_dir, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) return vectorstore except Exception as e: logging.warning(f"Init vector store failed: {e}") traceback.print_exc() return None def load_vector_store(embedding_adapter, filepath: str): """ 读取已存在的 Chroma 向量库。若不存在则返回 None。 如果加载失败(embedding 或IO问题),则返回 None。 """ from langchain.embeddings.base import Embeddings as LCEmbeddings store_dir = get_vectorstore_dir(filepath) if not os.path.exists(store_dir): logging.info("Vector store not found. Will return None.") return None try: class LCEmbeddingWrapper(LCEmbeddings): def embed_documents(self, texts): return call_with_retry( func=embedding_adapter.embed_documents, max_retries=3, fallback_return=[], texts=texts ) def embed_query(self, query: str): res = call_with_retry( func=embedding_adapter.embed_query, max_retries=3, fallback_return=[], query=query ) return res chroma_embedding = LCEmbeddingWrapper() return Chroma( persist_directory=store_dir, embedding_function=chroma_embedding, client_settings=Settings(anonymized_telemetry=False), collection_name="novel_collection" ) except Exception as e: logging.warning(f"Failed to load vector store: {e}") traceback.print_exc() return None def split_by_length(text: str, max_length: int = 500): """按照 max_length 切分文本""" segments = [] start_idx = 0 while start_idx < len(text): end_idx = min(start_idx + max_length, len(text)) segment = text[start_idx:end_idx] segments.append(segment.strip()) start_idx = end_idx return segments def split_text_for_vectorstore(chapter_text: str, max_length: int = 500, similarity_threshold: float = 0.7): """ 对新的章节文本进行分段后,再用于存入向量库。 使用 embedding 进行文本相似度计算。 """ if not chapter_text.strip(): return [] # nltk.download('punkt', quiet=True) # nltk.download('punkt_tab', quiet=True) sentences = nltk.sent_tokenize(chapter_text) if not sentences: return [] # 直接按长度分段,不做相似度合并 final_segments = [] current_segment = [] current_length = 0 for sentence in sentences: sentence_length = len(sentence) if current_length + sentence_length > max_length: if current_segment: final_segments.append(" ".join(current_segment)) current_segment = [sentence] current_length = sentence_length else: current_segment.append(sentence) current_length += sentence_length if current_segment: final_segments.append(" ".join(current_segment)) return final_segments def update_vector_store(embedding_adapter, new_chapter: str, filepath: str): """ 将最新章节文本插入到向量库中。 若库不存在则初始化;若初始化/更新失败,则跳过。 """ from utils import read_file, clear_file_content, save_string_to_txt splitted_texts = split_text_for_vectorstore(new_chapter) if not splitted_texts: logging.warning("No valid text to insert into vector store. Skipping.") return store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("Vector store does not exist or failed to load. Initializing a new one for new chapter...") store = init_vector_store(embedding_adapter, splitted_texts, filepath) if not store: logging.warning("Init vector store failed, skip embedding.") else: logging.info("New vector store created successfully.") return try: docs = [Document(page_content=str(t)) for t in splitted_texts] store.add_documents(docs) logging.info("Vector store updated with the new chapter splitted segments.") except Exception as e: logging.warning(f"Failed to update vector store: {e}") traceback.print_exc() def get_relevant_context_from_vector_store(embedding_adapter, query: str, filepath: str, k: int = 2) -> str: """ 从向量库中检索与 query 最相关的 k 条文本,拼接后返回。 如果向量库加载/检索失败,则返回空字符串。 最终只返回最多2000字符的检索片段。 """ store = load_vector_store(embedding_adapter, filepath) if not store: logging.info("No vector store found or load failed. Returning empty context.") return "" try: docs = store.similarity_search(query, k=k) if not docs: logging.info(f"No relevant documents found for query '{query}'. Returning empty context.") return "" combined = "\n".join([d.page_content for d in docs]) if len(combined) > 2000: combined = combined[:2000] return combined except Exception as e: logging.warning(f"Similarity search failed: {e}") traceback.print_exc() return "" def _get_sentence_transformer(model_name: str = 'paraphrase-MiniLM-L6-v2'): """获取sentence transformer模型,处理SSL问题""" try: # 设置torch环境变量 os.environ["TORCH_ALLOW_TF32_CUBLAS_OVERRIDE"] = "0" os.environ["TORCH_CUDNN_V8_API_ENABLED"] = "0" # 禁用SSL验证 ssl._create_default_https_context = ssl._create_unverified_context # ...existing code... except Exception as e: logging.error(f"Failed to load sentence transformer model: {e}") traceback.print_exc() return None