Source code for document_stores.implementations.faiss_vector_store

"""
FAISS向量存储实现

基于Facebook AI Similarity Search (FAISS) 的高性能向量存储实现,
支持持久化存储、索引优化和增量更新。
"""

from __future__ import annotations

import json
import os
from dataclasses import replace
from typing import Any, Dict, List, Optional, Tuple, Union

import numpy as np

try:
    import faiss

    FAISS_AVAILABLE = True
except ImportError:
    FAISS_AVAILABLE = False

from haystack import Document as HaystackDocument

from .base_vector_store import BaseVectorStore, VectorStoreConnectionError


[docs] class FAISSVectorStore(BaseVectorStore): """ 基于FAISS的向量存储实现。 提供高性能的向量相似度搜索,支持多种索引类型和持久化存储。 适用于中到大规模的向量检索场景。 """
[docs] def __init__(self, config: Dict[str, Any]) -> None: """ 初始化FAISS向量存储。 Args: config: FAISS配置字典 """ super().__init__(config) if not FAISS_AVAILABLE: raise VectorStoreConnectionError("FAISS未安装,请运行: pip install faiss-cpu") # FAISS特定配置 faiss_config = config.get("faiss", {}) self.storage_path = faiss_config.get("storage_path", "./data/faiss") self.index_type = faiss_config.get("index_type", "Flat") self.metric_type = faiss_config.get("metric_type", "INNER_PRODUCT") # L2, INNER_PRODUCT self.auto_save_interval = faiss_config.get("auto_save_interval", 300) # 5分钟 self.use_gpu = faiss_config.get("use_gpu", False) and hasattr(faiss, "StandardGpuResources") # 索引参数 self.index_params = faiss_config.get("index_params", {}) self.search_params = faiss_config.get("search_params", {}) # 内部状态 self.index: Optional[faiss.Index] = None self.documents: Dict[str, HaystackDocument] = {} # doc_id -> document mapping self.id_to_idx: Dict[str, int] = {} # doc_id -> faiss index mapping self.idx_to_id: Dict[int, str] = {} # faiss index -> doc_id mapping self.next_idx = 0 # GPU资源(如果使用GPU) self.gpu_resources: Optional[faiss.StandardGpuResources] = None # 确保存储目录存在 os.makedirs(self.storage_path, exist_ok=True) self.logger.info(f"FAISSVectorStore初始化完成: {self.index_type}, 维度={self.vector_dim}")
[docs] def initialize(self) -> bool: """ 初始化FAISS索引。 Returns: 初始化成功返回True,失败返回False """ try: # 创建FAISS索引 self.index = self._create_index() # 尝试加载已存在的索引 index_path = os.path.join(self.storage_path, "index.faiss") if os.path.exists(index_path): self.logger.info("发现已存在的FAISS索引,正在加载...") self.load_index(self.storage_path) self.is_initialized = True self.logger.info("FAISS索引初始化成功") return True except Exception as e: self.logger.error(f"初始化FAISS索引失败: {str(e)}") return False
def _create_index(self) -> faiss.Index: """ 根据配置创建FAISS索引。 Returns: FAISS索引对象 """ metric = faiss.METRIC_L2 if self.similarity_function == "euclidean" else faiss.METRIC_INNER_PRODUCT # 创建基础索引 index: faiss.Index if self.index_type.lower() == "flat": index = ( faiss.IndexFlatIP(self.vector_dim) if metric == faiss.METRIC_INNER_PRODUCT else faiss.IndexFlatL2(self.vector_dim) ) elif self.index_type.startswith("IVF"): # 解析IVF参数 (例如: "IVF1024,Flat") parts = self.index_type.split(",") nlist = int(parts[0].replace("IVF", "")) quantizer_type = parts[1] if len(parts) > 1 else "Flat" # 创建量化器 if quantizer_type.lower() == "flat": quantizer = ( faiss.IndexFlatIP(self.vector_dim) if metric == faiss.METRIC_INNER_PRODUCT else faiss.IndexFlatL2(self.vector_dim) ) else: raise ValueError(f"不支持的量化器类型: {quantizer_type}") # 创建IVF索引 index = faiss.IndexIVFFlat(quantizer, self.vector_dim, nlist, metric) elif self.index_type.startswith("HNSW"): # HNSW参数 M = self.index_params.get("M", 16) hnsw_index = faiss.IndexHNSWFlat(self.vector_dim, M, metric) if "efConstruction" in self.index_params: hnsw_index.hnsw.efConstruction = self.index_params["efConstruction"] index = hnsw_index else: self.logger.warning(f"未知索引类型 {self.index_type},使用Flat索引") index = ( faiss.IndexFlatIP(self.vector_dim) if metric == faiss.METRIC_INNER_PRODUCT else faiss.IndexFlatL2(self.vector_dim) ) # GPU支持 if self.use_gpu: try: self.gpu_resources = faiss.StandardGpuResources() index = faiss.index_cpu_to_gpu(self.gpu_resources, 0, index) self.logger.info("FAISS GPU模式已启用") except Exception as e: self.logger.warning(f"GPU初始化失败,回退到CPU模式: {str(e)}") return index
[docs] def add_documents(self, documents: List[HaystackDocument], embeddings: Optional[List[List[float]]] = None) -> bool: """ 批量添加文档到FAISS索引。 Args: documents: Haystack文档列表 embeddings: 对应的向量嵌入列表 Returns: 添加成功返回True,失败返回False """ if not self.is_initialized or self.index is None: self.logger.error("FAISS索引未初始化") return False if not documents: return True if embeddings and len(documents) != len(embeddings): self.logger.error("文档数量与嵌入向量数量不匹配") return False try: vectors_to_add = self._prepare_documents(documents, embeddings) # 添加向量到索引 if vectors_to_add: vectors_array = np.array(vectors_to_add, dtype=np.float32) # 如果是IVF索引且未训练,需要先训练 if hasattr(self.index, "is_trained") and not self.index.is_trained: if len(vectors_to_add) >= 256: # FAISS建议最少256个向量用于训练 self.index.train(vectors_array) self.logger.info("FAISS索引训练完成") else: self.logger.warning("向量数量不足,无法训练IVF索引") return False self.index.add(vectors_array) self.logger.info(f"成功添加 {len(vectors_to_add)} 个文档到FAISS索引") # 自动保存 self._auto_save() return True return False except Exception as e: self.logger.error(f"添加文档到FAISS索引失败: {str(e)}") return False
def _prepare_documents( self, documents: List[HaystackDocument], embeddings: Optional[List[List[float]]] ) -> List[Union[List[float], np.ndarray]]: vectors_to_add: List[Union[List[float], np.ndarray]] = [] for i, doc in enumerate(documents): # 获取或生成文档ID doc_id = doc.id if doc.id else f"doc_{self.next_idx}" # 检查向量嵌入 if embeddings: embedding = embeddings[i] elif doc.embedding: embedding = doc.embedding else: self.logger.warning(f"文档 {doc_id} 缺少向量嵌入,跳过") continue # 验证向量 if not self.validate_embedding(embedding): self.logger.warning(f"文档 {doc_id} 的向量嵌入无效,跳过") continue vector = self._normalize_vector(embedding) if self.similarity_function == "cosine" else embedding vectors_to_add.append(vector) # 存储文档和映射关系 self.documents[doc_id] = doc self.id_to_idx[doc_id] = self.next_idx self.idx_to_id[self.next_idx] = doc_id self.next_idx += 1 return vectors_to_add
[docs] def search( self, query_embedding: List[float], top_k: int = 5, filters: Optional[Dict[str, Any]] = None ) -> List[Tuple[HaystackDocument, float]]: """ 在FAISS索引中进行向量搜索。 Args: query_embedding: 查询向量 top_k: 返回的最相似文档数量 filters: 元数据过滤条件(FAISS本身不支持,需要后处理) Returns: 包含(文档, 相似度得分)的元组列表 """ if not self.is_initialized or self.index is None: self.logger.error("FAISS索引未初始化") return [] if not self.validate_embedding(query_embedding): self.logger.error("查询向量无效") return [] try: # 准备查询向量 query_vector = np.array([query_embedding], dtype=np.float32) # 余弦相似度需要归一化 if self.similarity_function == "cosine": query_vector = self._normalize_vector(query_vector.flatten()).reshape(1, -1) # 设置搜索参数 if hasattr(self.index, "nprobe") and "nprobe" in self.search_params: self.index.nprobe = self.search_params["nprobe"] if hasattr(self.index, "hnsw") and "ef" in self.search_params: self.index.hnsw.efSearch = self.search_params["ef"] # 执行搜索 scores, indices = self.index.search(query_vector, min(top_k, self.index.ntotal)) return self._collect_search_results(scores[0], indices[0], filters, top_k) except Exception as e: self.logger.error(f"FAISS搜索失败: {str(e)}") return []
def _collect_search_results( self, scores: np.ndarray, indices: np.ndarray, filters: Optional[Dict[str, Any]], top_k: int, ) -> List[Tuple[HaystackDocument, float]]: results = [] for i, (score, idx) in enumerate(zip(scores, indices)): if idx == -1: # FAISS返回-1表示无效结果 break doc_id = self.idx_to_id.get(idx) if doc_id and doc_id in self.documents: document = self.documents[doc_id] # 应用元数据过滤(如果提供) if filters and not self._apply_filters(document, filters): continue # 转换分数(FAISS内积分数可能需要调整) similarity_score = float(score) if self.similarity_function == "euclidean": # L2距离转换为相似度 similarity_score = 1.0 / (1.0 + similarity_score) results.append((document, similarity_score)) return results[:top_k]
[docs] def get_document_by_id(self, doc_id: str) -> Optional[HaystackDocument]: """ 根据文档ID获取文档。 Args: doc_id: 文档ID Returns: 文档对象,不存在则返回None """ return self.documents.get(doc_id)
[docs] def update_document(self, doc_id: str, document: HaystackDocument, embedding: Optional[List[float]] = None) -> bool: """ 更新文档(FAISS不支持原地更新,需要重建索引)。 Args: doc_id: 文档ID document: 新的文档对象 embedding: 新的向量嵌入 Returns: 更新成功返回True,失败返回False """ # FAISS不支持单个向量的原地更新 # 这里采用简单的删除+添加策略 # 对于频繁更新的场景,建议使用支持更新的存储后端 if doc_id not in self.documents: self.logger.warning(f"文档 {doc_id} 不存在,无法更新") return False try: # 删除旧文档(仅从映射中删除,FAISS索引项保留) del self.documents[doc_id] # 添加新文档 document = replace(document, id=doc_id) return self.add_documents([document], [embedding] if embedding else None) except Exception as e: self.logger.error(f"更新文档 {doc_id} 失败: {str(e)}") return False
[docs] def delete_document(self, doc_id: str) -> bool: """ 删除文档(FAISS不支持删除,仅从映射中移除)。 Args: doc_id: 文档ID Returns: 删除成功返回True,失败返回False """ try: if doc_id in self.documents: del self.documents[doc_id] # 清理映射关系 if doc_id in self.id_to_idx: idx = self.id_to_idx[doc_id] del self.id_to_idx[doc_id] if idx in self.idx_to_id: del self.idx_to_id[idx] self.logger.info(f"文档 {doc_id} 已从映射中移除") return True else: self.logger.warning(f"文档 {doc_id} 不存在") return False except Exception as e: self.logger.error(f"删除文档 {doc_id} 失败: {str(e)}") return False
[docs] def delete_all_documents(self) -> bool: """ 删除所有文档。 Returns: 删除成功返回True,失败返回False """ try: # 重置索引 self.index = self._create_index() # 清空所有映射 self.documents.clear() self.id_to_idx.clear() self.idx_to_id.clear() self.next_idx = 0 self.logger.info("所有文档已清空") return True except Exception as e: self.logger.error(f"清空文档失败: {str(e)}") return False
[docs] def get_document_count(self) -> int: """ 获取文档总数。 Returns: 文档总数 """ return len(self.documents)
[docs] def save_index(self, path: str) -> bool: """ 保存FAISS索引和元数据到文件。 Args: path: 保存目录路径 Returns: 保存成功返回True,失败返回False """ if self.index is None: self.logger.error("FAISS索引未初始化") return False try: os.makedirs(path, exist_ok=True) # 保存FAISS索引 index_path = os.path.join(path, "index.faiss") if self.use_gpu: # GPU索引需要先移到CPU cpu_index = faiss.index_gpu_to_cpu(self.index) faiss.write_index(cpu_index, index_path) else: faiss.write_index(self.index, index_path) # 保存文档映射 metadata = { "documents": { doc_id: {"content": doc.content, "meta": doc.meta, "embedding": doc.embedding} for doc_id, doc in self.documents.items() }, "id_to_idx": self.id_to_idx, "idx_to_id": {str(k): v for k, v in self.idx_to_id.items()}, "next_idx": self.next_idx, "config": self.config, } metadata_path = os.path.join(path, "metadata.json") with open(metadata_path, "w", encoding="utf-8") as f: json.dump(metadata, f, indent=2, ensure_ascii=False) self.logger.info(f"FAISS索引已保存到 {path}") return True except Exception as e: self.logger.error(f"保存FAISS索引失败: {str(e)}") return False
[docs] def load_index(self, path: str) -> bool: """ 从文件加载FAISS索引和元数据。 Args: path: 索引文件目录路径 Returns: 加载成功返回True,失败返回False """ try: index_path = os.path.join(path, "index.faiss") metadata_path = os.path.join(path, "metadata.json") if not os.path.exists(index_path) or not os.path.exists(metadata_path): self.logger.warning(f"索引文件不完整: {path}") return False # 加载FAISS索引 index = faiss.read_index(index_path) # GPU支持 if self.use_gpu: try: if not self.gpu_resources: self.gpu_resources = faiss.StandardGpuResources() index = faiss.index_cpu_to_gpu(self.gpu_resources, 0, index) except Exception as e: self.logger.warning(f"GPU加载失败,使用CPU模式: {str(e)}") self.index = index # 加载元数据 with open(metadata_path, "r", encoding="utf-8") as f: metadata = json.load(f) # 重构文档对象 self.documents = {} for doc_id, doc_data in metadata["documents"].items(): doc = HaystackDocument( content=doc_data["content"], meta=doc_data["meta"], embedding=doc_data.get("embedding") ) doc = replace(doc, id=doc_id) self.documents[doc_id] = doc self.id_to_idx = metadata["id_to_idx"] self.idx_to_id = {int(k): v for k, v in metadata["idx_to_id"].items()} self.next_idx = metadata["next_idx"] self.is_initialized = True self.logger.info(f"FAISS索引已从 {path} 加载,包含 {len(self.documents)} 个文档") return True except Exception as e: self.logger.error(f"加载FAISS索引失败: {str(e)}") return False
def _normalize_vector(self, vector: Union[List[float], np.ndarray]) -> np.ndarray: """ 归一化向量(用于余弦相似度)。 Args: vector: 输入向量 Returns: 归一化后的向量 """ if isinstance(vector, list): vector = np.array(vector, dtype=np.float32) norm = np.linalg.norm(vector) if norm == 0: return vector return np.asarray(vector / norm) def _apply_filters(self, document: HaystackDocument, filters: Dict[str, Any]) -> bool: """ 应用元数据过滤条件。 Args: document: 文档对象 filters: 过滤条件 Returns: 满足条件返回True,否则返回False """ try: for key, value in filters.items(): if key in document.meta: doc_value = document.meta[key] if isinstance(value, list): if doc_value not in value: return False elif doc_value != value: return False else: return False return True except Exception: return False def _auto_save(self) -> None: """ 自动保存索引(如果启用)。 """ if self.auto_save_interval > 0: try: self.save_index(self.storage_path) except Exception as e: self.logger.warning(f"自动保存失败: {str(e)}")
[docs] def close(self) -> None: """ 关闭FAISS存储,释放资源。 """ try: # 保存索引 if self.is_initialized: self.save_index(self.storage_path) # 释放GPU资源 if self.gpu_resources: del self.gpu_resources self.gpu_resources = None super().close() except Exception as e: self.logger.error(f"关闭FAISS存储失败: {str(e)}")
[docs] def get_index_stats(self) -> Dict[str, Any]: """ 获取索引统计信息。 Returns: 包含索引统计信息的字典 """ if not self.is_initialized or self.index is None: return {} stats = { "total_vectors": self.index.ntotal, "vector_dimension": self.vector_dim, "index_type": self.index_type, "is_trained": getattr(self.index, "is_trained", True), "use_gpu": self.use_gpu, } return stats