Coverage for src/local_deep_research/vector_stores/base.py: 100%

29 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 07:11 +0000

1"""Abstract base class for vector stores. 

2 

3Mirrors the embeddings/LLM provider pattern (``BaseEmbeddingProvider``, 

4``BaseLLM`` + factory): a small typed interface plus a registry so a future 

5release can add backends (Qdrant, pgvector, Milvus, ...) selectable by a 

6setting. **Only FAISS is implemented today** — there is intentionally no 

7settings selector wired yet. 

8 

9Scope / honest boundary 

10----------------------- 

11This abstraction covers the *vector-storage operations* — create / add / 

12search / delete / reconstruct / persist / count. It deliberately does **not** 

13cover the per-user file paths, on-disk integrity checksums, or the per-index 

14write-lock + reload/merge concurrency model that :class:`LibraryRAGService` 

15layers on top. Those are *local-file* concerns (see :attr:`is_local_file`): a 

16future server-backed store (e.g. Qdrant) would set ``is_local_file = False``, 

17no-op :meth:`persist` / :meth:`load`, and the service layer would skip the file 

18lock + integrity machinery for it. So this base class makes adding a backend 

19*easier* (a clean, verified query surface), not *free* — a real server backend 

20still requires refactoring the file/lock/integrity layer in the service. 

21 

22Identity model 

23-------------- 

24Vectors are keyed by an application-supplied **int64 id**. In this codebase 

25that id is ``DocumentChunk.id`` (the encrypted-DB primary key); the store holds 

26only vectors + ids, and all text/metadata is rehydrated from the DB by id. 

27 

28============================================================================ 

29SECURITY INVARIANT — NEVER pass document text to a vector store 

30============================================================================ 

31Every method here accepts ONLY an integer id and its embedding vector. 

32Callers MUST NOT pass document/chunk text (or any other user content) into a 

33vector-store method, and implementations MUST NOT persist such text. 

34 

35The per-user database is encrypted; a vector index is not (it is a local file 

36for FAISS, or an external service for a future backend). Keeping user content 

37out of the vector store keeps the encrypted database the sole home of that 

38content. Because the interface has no text parameter, the protection is 

39STRUCTURAL rather than a matter of discipline — no backend (FAISS today, or a 

40swapped-in Qdrant/pgvector tomorrow) can receive or store the text. The 

41authoritative text lives solely in the encrypted DB 

42(``DocumentChunk.chunk_text``), keyed by the same int id; retrieval rehydrates 

43snippets from there by id (see ``LibraryRAGService`` / the search engines). 

44============================================================================ 

45""" 

46 

47from abc import ABC, abstractmethod 

48from pathlib import Path 

49from typing import List, Optional, Sequence, Tuple 

50 

51import numpy as np 

52 

53 

54class BaseVectorStore(ABC): 

55 """Abstract interface for a per-collection vector index. 

56 

57 An instance wraps a single collection's index (stateful, in-memory, backed 

58 by a persisted file for local-file stores). Construct via :meth:`create` 

59 (new, empty) or :meth:`load` (from disk). 

60 """ 

61 

62 # Override in subclasses. 

63 provider_key: str = "base" # unique id; matches the (future) setting value 

64 provider_name: str = "Base" # display name for logs/UI 

65 # True when the store persists to a local file this process owns end to end 

66 # — i.e. the service's file write-lock + integrity checksum + reload/merge 

67 # model applies. A server-backed store sets this False. 

68 is_local_file: bool = True 

69 # True when the store can invert id -> vector (needed for the in-place 

70 # format migration, which re-keys vectors without re-embedding). 

71 supports_reconstruct: bool = True 

72 # Embedding dimension this instance was constructed/loaded for. Concrete 

73 # subclasses set this in __init__/create()/load(); declared here (no 

74 # default — there is no sensible universal value) so callers like the 

75 # facade can read it via the abstract interface. 

76 dimension: int 

77 

78 # ------------------------------------------------------------------ # 

79 # Construction 

80 # ------------------------------------------------------------------ # 

81 @classmethod 

82 @abstractmethod 

83 def create( 

84 cls, 

85 *, 

86 dimension: int, 

87 index_type: str, 

88 metric: str, 

89 normalize: bool, 

90 **kwargs, 

91 ) -> "BaseVectorStore": 

92 """Create a new, empty store for vectors of ``dimension``. 

93 

94 Args: 

95 dimension: Embedding dimension. 

96 index_type: Backend index family (e.g. ``"flat"``, ``"hnsw"``). 

97 metric: Distance metric (``"l2"``, ``"cosine"``, ``"dot_product"``). 

98 normalize: Whether query/doc vectors are L2-normalized before use 

99 (cosine similarity via inner product on normalized vectors). 

100 """ 

101 

102 @classmethod 

103 @abstractmethod 

104 def load( 

105 cls, 

106 path: Path, 

107 *, 

108 dimension: int, 

109 index_type: str, 

110 metric: str, 

111 normalize: bool, 

112 **kwargs, 

113 ) -> "BaseVectorStore": 

114 """Load a persisted store from ``path`` (local-file stores only). 

115 

116 ``dimension`` / ``index_type`` / ``metric`` / ``normalize`` describe how 

117 the index was built (some backends persist this, some do not); callers 

118 pass the values from settings so search-time normalization matches 

119 build-time normalization. 

120 """ 

121 

122 # ------------------------------------------------------------------ # 

123 # Vector operations 

124 # ------------------------------------------------------------------ # 

125 @abstractmethod 

126 def add(self, ids: List[int], vectors: np.ndarray) -> None: 

127 """Add vectors under the given int64 ids (aligned 1:1, same length). 

128 

129 ids + vectors ONLY — never text (see the module-level "SECURITY 

130 INVARIANT" block). The text stays in the encrypted DB. 

131 """ 

132 

133 @abstractmethod 

134 def search( 

135 self, query_vector: np.ndarray, k: int 

136 ) -> List[Tuple[int, float]]: 

137 """Return up to ``k`` ``(id, distance)`` pairs, nearest first. 

138 

139 ``distance`` is the backend's raw score (L2 distance or inner product); 

140 callers map it to a relevance score. Absent/empty slots are filtered. 

141 """ 

142 

143 @abstractmethod 

144 def delete(self, ids: List[int]) -> int: 

145 """Remove the given ids. Returns the number actually removed. 

146 

147 May raise for index families that do not support removal (e.g. FAISS 

148 HNSW) — callers handle that the same way they did pre-abstraction. 

149 """ 

150 

151 @abstractmethod 

152 def live_ids(self) -> List[int]: 

153 """Return every id currently stored (the authoritative membership).""" 

154 

155 @abstractmethod 

156 def count(self) -> int: 

157 """Return the number of vectors currently stored.""" 

158 

159 @abstractmethod 

160 def apply( 

161 self, 

162 *, 

163 add_ids: List[int], 

164 add_vectors: Optional[np.ndarray], 

165 remove_ids: Sequence[int] = (), 

166 dedup: bool = True, 

167 ) -> dict: 

168 """Durably apply a batch of removals + additions as one atomic unit. 

169 

170 This is the single write primitive. The caller computes *which* ids to 

171 remove and add (from its own authoritative source — for LDR, the 

172 encrypted DB); the store applies them and persists so that a concurrent 

173 writer to the same logical index cannot lose either party's changes. 

174 *How* that atomicity/isolation is achieved is backend-specific (a 

175 local-file backend takes a per-index lock and reload-merges; a 

176 server-backed store issues upserts/deletes) and is not part of this 

177 contract. 

178 

179 Contract: removals are applied before additions, and ``dedup`` skips 

180 additions whose id is already present after removal — so a "replace" is 

181 expressed as ``remove_ids`` + ``add_ids`` of the same id. On failure the 

182 store must not be left durably half-applied. 

183 

184 Returns a stats dict (e.g. ``{"added": n, "removed": m}``). ids + 

185 vectors ONLY — never text (see the module "SECURITY INVARIANT"). 

186 """ 

187 

188 # ------------------------------------------------------------------ # 

189 # Optional (local-file / reconstructable stores) 

190 # ------------------------------------------------------------------ # 

191 def reconstruct(self, id: int) -> Optional[np.ndarray]: 

192 """Return the stored vector for ``id`` (migration helper). 

193 

194 Only meaningful when :attr:`supports_reconstruct` is True. 

195 """ 

196 raise NotImplementedError( 

197 f"{type(self).__name__} does not support reconstruct()" 

198 ) 

199 

200 def persist(self, path: Path) -> None: 

201 """Durably write the store to ``path`` (local-file stores only).""" 

202 raise NotImplementedError( 

203 f"{type(self).__name__} does not support persist()" 

204 )