pgvector-template 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 David Ge Liu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.1
2
+ Name: pgvector-template
3
+ Version: 0.1.0
4
+ Summary: Template library for flexible PGVector RAG implementations
5
+ Author-email: DL <v49t9zpqd@mozmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/DavidLiuGit/PGVector-Template
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: psycopg[binary]>=3.1.0
15
+ Requires-Dist: pgvector>=0.2.0
16
+ Requires-Dist: pydantic<3.0,>=2.11
17
+ Requires-Dist: sqlalchemy>=2.0.0
18
+ Requires-Dist: typing-extensions>=4.0.0
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7.0.0; extra == "test"
21
+ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
22
+ Requires-Dist: python-dotenv>=1.0.0; extra == "test"
23
+
24
+ # PGVector-Template
25
+
26
+ Template library for flexible PGVector RAG implementations
27
+
28
+
29
+ ## Testing
30
+
31
+ Install dependencies (preferably in a virtualenv) before running tests:
32
+ ```bash
33
+ pip install -e .[test]
34
+ ```
35
+
36
+ ### Unit tests
37
+ ```bash
38
+ python -m unittest
39
+ ```
40
+
41
+ ### Integration tests
42
+ ```bash
43
+ python -m unittest discover -s integ-tests
44
+ ```
@@ -0,0 +1,21 @@
1
+ # PGVector-Template
2
+
3
+ Template library for flexible PGVector RAG implementations
4
+
5
+
6
+ ## Testing
7
+
8
+ Install dependencies (preferably in a virtualenv) before running tests:
9
+ ```bash
10
+ pip install -e .[test]
11
+ ```
12
+
13
+ ### Unit tests
14
+ ```bash
15
+ python -m unittest
16
+ ```
17
+
18
+ ### Integration tests
19
+ ```bash
20
+ python -m unittest discover -s integ-tests
21
+ ```
@@ -0,0 +1,6 @@
1
+ # Import key classes for easier access
2
+ from pgvector_template.db.connection import DatabaseManager
3
+ from pgvector_template.db.document_db import DocumentDatabaseManager
4
+
5
+ # Re-export at the top level
6
+ __all__ = ["DatabaseManager", "DocumentDatabaseManager"]
@@ -0,0 +1,23 @@
1
+ from pgvector_template.core.document import BaseDocument, BaseDocumentMetadata, Corpus, BaseDocumentOptionalProps
2
+ from pgvector_template.core.embedder import BaseEmbeddingProvider
3
+ from pgvector_template.core.manager import BaseCorpusManager, BaseCorpusManagerConfig
4
+ from pgvector_template.core.retriever import RetrievalResult, SearchQuery
5
+ from pgvector_template.core.search import BaseSearchClient
6
+
7
+
8
+ __all__ = [
9
+ ### document
10
+ "BaseDocumentOptionalProps",
11
+ "BaseDocument",
12
+ "BaseDocumentMetadata",
13
+ "Corpus",
14
+ ### embedder
15
+ "BaseEmbeddingProvider",
16
+ ### manager
17
+ "BaseCorpusManager",
18
+ "BaseCorpusManagerConfig",
19
+ ### search
20
+ "RetrievalResult",
21
+ "SearchQuery",
22
+ "BaseSearchClient",
23
+ ]
@@ -0,0 +1,171 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from typing import Any, ClassVar, Type, TypeVar, Annotated
4
+ from uuid import uuid4, UUID
5
+
6
+ from pydantic import BaseModel, Field, field_validator, model_validator
7
+ from sqlalchemy import (
8
+ Column,
9
+ String,
10
+ Text,
11
+ DateTime,
12
+ Boolean,
13
+ Integer,
14
+ Float,
15
+ Index,
16
+ text,
17
+ )
18
+ from sqlalchemy.orm import declarative_base
19
+ from sqlalchemy.dialects.postgresql import UUID, JSONB
20
+ from pgvector.sqlalchemy import Vector
21
+
22
+ Base = declarative_base()
23
+
24
+
25
+ class BaseDocumentOptionalProps(BaseModel):
26
+ """Optional properties for document creation"""
27
+
28
+ title: str | None = None
29
+ """Optional title or summary for the document"""
30
+ collection: str | None = Field(None, max_length=64)
31
+ """Collection name for grouping documents of the same type"""
32
+ original_url: str | None = Field(None, max_length=2048)
33
+ """Optional source URL for the document"""
34
+ language: str | None = Field("en", pattern=r"^[a-z]{2}(-[A-Z]{2})?$")
35
+ """Language of the content (ISO 639-1 code), e.g., 'en', 'es', 'zh'"""
36
+ score: float | None = Field(None, ge=0.0, le=1.0)
37
+ """Optional score assigned during ingestion (e.g., relevance, confidence)"""
38
+ tags: list[str] | None = None
39
+ """List of tags or keywords for filtering, categorization, or faceted search"""
40
+
41
+ @field_validator("tags")
42
+ @classmethod
43
+ def validate_tags(cls, v):
44
+ if v is not None:
45
+ # Ensure all tags are strings and not empty
46
+ if not all(isinstance(tag, str) and tag.strip() for tag in v):
47
+ raise ValueError("All tags must be non-empty strings")
48
+ # Remove duplicates while preserving order
49
+ return list(dict.fromkeys(v))
50
+ return v
51
+
52
+
53
+ T = TypeVar("T", bound="BaseDocument")
54
+
55
+
56
+ class BaseDocument(Base):
57
+ """
58
+ Template table for Documents, that works for all collection types.
59
+ Each row represents a single retrievable document (could be chunk or full doc).
60
+
61
+ Glossary:
62
+ - `corpus` - a full text document, consisting of 1-or-more documents.
63
+ - `corpus_id` is associated with these entries
64
+ - `document` - a chunk (or entirety) of an corpus. `id` is associated with these chunks
65
+ """
66
+
67
+ __abstract__ = True
68
+
69
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
70
+ """Primary key of the Document table. Represents unique ID of a Document"""
71
+
72
+ # Hierarchy: original_id groups chunks from same source
73
+ collection = Column(String(64), nullable=True)
74
+ """Collection name. Used for filtering and grouping documents of the same type."""
75
+ corpus_id = Column(UUID(as_uuid=True), index=True)
76
+ """An `corpus` is the original, full text that chunks are a part (or all) of"""
77
+ chunk_index = Column(Integer, default=0)
78
+ """Index of this chunk within an `corpus`. Starts from 0."""
79
+
80
+ # Content
81
+ content = Column(Text, nullable=False)
82
+ """String content of the chunk"""
83
+ title = Column(String(500))
84
+ """Optional chunk title/summary"""
85
+ document_metadata = Column(JSONB, nullable=False, default=dict)
86
+ """Flexible metadata as JSON"""
87
+ origin_url = Column(String(2048), nullable=True)
88
+ """Optional source URL"""
89
+ language = Column(String(10), default="en")
90
+ """Language of the content (ISO 639-1 code), e.g., 'en', 'es', 'zh'."""
91
+ score = Column(Float, nullable=True)
92
+ """Optional score assigned during ingestion (e.g., relevance, confidence)."""
93
+ tags = Column(JSONB, nullable=True, default=list)
94
+ """List of tags or keywords for filtering, categorization, or faceted search."""
95
+
96
+ # Vector embedding
97
+ embedding = Column(Vector(1024))
98
+ """Embedding vector. 1024 dimensions by default. Adjust as-needed."""
99
+
100
+ # Audit fields
101
+ created_at = Column(DateTime, default=datetime.utcnow)
102
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
103
+ is_deleted = Column(Boolean, default=False)
104
+ """Entries can be logically marked for deletion before they are permanently deleted."""
105
+
106
+ @classmethod
107
+ def from_props(
108
+ cls: Type[T],
109
+ corpus_id: UUID,
110
+ chunk_index: int,
111
+ content: str,
112
+ embedding: list[float],
113
+ metadata: dict[str, Any] = {},
114
+ optional_props: BaseDocumentOptionalProps | None = None,
115
+ ) -> T:
116
+ """
117
+ Create a BaseDocument instance from mandatory and optional properties.
118
+
119
+ Args:
120
+ corpus_id: UUID of the corpus this document belongs to
121
+ chunk_index: Index of this chunk within the corpus
122
+ content: Text content of the document
123
+ embedding: Vector embedding of the content
124
+ optional_props: Optional properties for the document
125
+
126
+ Returns:
127
+ A new BaseDocument instance of the calling class type
128
+ """
129
+ if optional_props is None:
130
+ optional_props = BaseDocumentOptionalProps()
131
+
132
+ return cls(
133
+ corpus_id=corpus_id,
134
+ chunk_index=chunk_index,
135
+ content=content,
136
+ embedding=embedding,
137
+ title=optional_props.title,
138
+ document_metadata=metadata,
139
+ collection=optional_props.collection,
140
+ origin_url=optional_props.original_url,
141
+ language=optional_props.language,
142
+ score=optional_props.score,
143
+ tags=optional_props.tags,
144
+ )
145
+
146
+ # Index("ix_corpus_chunk", "corpus_id", "chunk_index")
147
+ # Index("ix_content_trgm", text("content gin_trgm_ops"), postgresql_using="gin") # For fuzzy text search
148
+ # Index("ix_metadata_gin", "metadata", postgresql_using="gin")
149
+
150
+
151
+ class BaseDocumentMetadata(BaseModel):
152
+ """Base metadata structure"""
153
+
154
+ document_type: str = Field(..., description="Description for type of document, e.g. markdown, pdf, etc")
155
+ schema_version: str = Field("1.0", description="Schema version for the metadata")
156
+
157
+ def to_dict(self) -> dict[str, Any]:
158
+ return self.model_dump()
159
+
160
+
161
+ @dataclass
162
+ class Corpus:
163
+ """
164
+ Logical grouping of one or more documents (chunks) belonging to the same original source.
165
+
166
+ Typically all documents in a corpus share the same `corpus_id` and are ordered by `chunk_index`.
167
+ """
168
+
169
+ corpus_id: UUID
170
+ documents: list[BaseDocument]
171
+ metadata: dict[str, Any] # e.g. source, tags, etc.
@@ -0,0 +1,20 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BaseEmbeddingProvider(ABC):
5
+ """Abstract base for embedding generation"""
6
+
7
+ @abstractmethod
8
+ def embed_text(self, text: str) -> list[float]:
9
+ """Generate embedding vector for text"""
10
+ raise NotImplementedError("Subclasses must implement embed_text method")
11
+
12
+ @abstractmethod
13
+ def embed_batch(self, texts: list[str]) -> list[list[float]]:
14
+ """Generate embeddings for multiple texts efficiently"""
15
+ raise NotImplementedError("Subclasses must implement embed_batch method")
16
+
17
+ @abstractmethod
18
+ def get_dimensions(self) -> int:
19
+ """Return embedding vector dimensions count"""
20
+ raise NotImplementedError("Subclasses must implement get_dimensions method")
@@ -0,0 +1,167 @@
1
+ from abc import ABC, abstractmethod
2
+ from logging import getLogger
3
+ from typing import Any, Optional, Type
4
+ from uuid import UUID, uuid4
5
+
6
+ from pydantic import BaseModel
7
+ from sqlalchemy.orm import Session
8
+
9
+ from pgvector_template.core.document import BaseDocument, BaseDocumentMetadata, BaseDocumentOptionalProps
10
+ from pgvector_template.core.embedder import BaseEmbeddingProvider
11
+
12
+
13
+ logger = getLogger(__name__)
14
+
15
+
16
+ class BaseCorpusManagerConfig(BaseModel):
17
+ """Base configuration for `Corpus` & `Document` management operations"""
18
+
19
+ schema_name: str
20
+ document_cls: Type[BaseDocument]
21
+ embedding_provider: BaseEmbeddingProvider
22
+ document_metadata: Type[BaseDocumentMetadata]
23
+
24
+ model_config = {"arbitrary_types_allowed": True}
25
+
26
+
27
+ class BaseCorpusManager(ABC):
28
+ """
29
+ Template class for `Corpus` & `Document` management operations.
30
+ Each instance should be able to handle multiple collections, of the same Corpus/Document type.
31
+ For example, if the document class is Jira tickets, multiple teams should be able to share the same
32
+ `CorpusManager` implementation, with a slightly different config.
33
+ """
34
+
35
+ @property
36
+ def config(self) -> BaseCorpusManagerConfig:
37
+ return self._cfg
38
+
39
+ def __init__(
40
+ self,
41
+ session: Session,
42
+ config: BaseCorpusManagerConfig,
43
+ ) -> None:
44
+ self.session = session
45
+ self._cfg = config
46
+ self.schema_name = config.schema_name
47
+
48
+ def get_full_corpus(self, corpus_id: str, chunk_delimiter: str = "\n") -> Optional[dict[str, Any]]:
49
+ """Reconstruct full corpus from its individual documents/chunks"""
50
+ chunks = (
51
+ self.session.query(BaseDocument)
52
+ .filter(BaseDocument.corpus_id == corpus_id, BaseDocument.is_deleted == False)
53
+ .order_by(BaseDocument.chunk_index)
54
+ .all()
55
+ )
56
+
57
+ if not chunks:
58
+ return None
59
+
60
+ # Full document is chunk_index = 0, or reconstruct from chunks
61
+ full_doc = next((c for c in chunks if c.chunk_index == 0), None)
62
+ if full_doc:
63
+ return {
64
+ "id": full_doc.original_id,
65
+ "content": full_doc.content,
66
+ "metadata": full_doc.document_metadata,
67
+ "chunks": [{"id": c.id, "index": c.chunk_index, "title": c.title} for c in chunks if c.chunk_index > 0],
68
+ }
69
+
70
+ # Reconstruct from chunks
71
+ reconstructed_content = chunk_delimiter.join([c.content for c in chunks])
72
+ return {
73
+ "id": corpus_id,
74
+ "content": reconstructed_content,
75
+ "metadata": chunks[0].document_metadata, # Use first chunk's metadata
76
+ "chunks": [{"id": c.id, "index": c.chunk_index, "title": c.title} for c in chunks],
77
+ }
78
+
79
+ def insert_corpus(
80
+ self,
81
+ content: str,
82
+ corpus_metadata: dict[str, Any],
83
+ optional_props: BaseDocumentOptionalProps,
84
+ ) -> int:
85
+ """
86
+ Insert a new `Corpus`, which will be split into 1-or-more `Document`s, depending on its length.
87
+ Each `Document` chunk shall have its own embedding vector, but reference the parent corpus_id.
88
+
89
+ Args:
90
+ content: The text content to be inserted as a corpus
91
+ corpus_metadata: Dictionary of metadata associated with the corpus
92
+ optional_props: Optional properties for the documents (title, collection, etc.)
93
+
94
+ Returns:
95
+ int: The number of **documents** inserted for the provided corpus
96
+ """
97
+ corpus_id = uuid4()
98
+ document_contents = self._split_corpus(content)
99
+ document_embeddings = self.config.embedding_provider.embed_batch(document_contents)
100
+ return self.insert_documents(corpus_id, document_contents, document_embeddings, corpus_metadata, optional_props)
101
+
102
+ def insert_documents(
103
+ self,
104
+ corpus_id: UUID,
105
+ document_contents: list[str],
106
+ document_embeddings: list[list[float]],
107
+ corpus_metadata: dict[str, Any],
108
+ optional_props: BaseDocumentOptionalProps | None = None,
109
+ ) -> int:
110
+ """
111
+ Insert a list of documents (usually from a chunked + embedded corpus).
112
+
113
+ Args:
114
+ corpus_id: UUID of the corpus these documents belong to
115
+ document_contents: List of text content for each document
116
+ document_embeddings: List of embedding vectors corresponding to each document
117
+ corpus_metadata: Dictionary of metadata to associate with all documents
118
+ optional_props: Optional properties for the documents (title, collection, etc.)
119
+
120
+ Returns:
121
+ int: The number of documents inserted (0 if input lists are empty)
122
+
123
+ Raises:
124
+ ValueError: If the length of document_contents doesn't match document_embeddings
125
+ """
126
+ if len(document_contents) != len(document_embeddings):
127
+ raise ValueError("Number of embeddings does not match number of documents")
128
+ if len(document_contents) == 0:
129
+ return 0
130
+ documents_to_insert = []
131
+ for i in range(len(document_contents)):
132
+ chunk_md = self._extract_chunk_metadata(document_contents[i])
133
+ base_metadata = self.config.document_metadata(**(corpus_metadata | chunk_md))
134
+ documents_to_insert.append(
135
+ self.config.document_cls.from_props(
136
+ corpus_id=corpus_id,
137
+ chunk_index=i,
138
+ content=document_contents[i],
139
+ embedding=document_embeddings[i],
140
+ metadata=base_metadata.model_dump(),
141
+ optional_props=optional_props,
142
+ )
143
+ )
144
+ self.session.add_all(documents_to_insert)
145
+ self.session.commit()
146
+ return len(documents_to_insert)
147
+
148
+ def _split_corpus(self, content: str, **kwargs) -> list[str]:
149
+ """
150
+ **It is highly recommended to override this method.**
151
+ Split a corpus into chunks.
152
+ """
153
+ if self.__class__ is not BaseCorpusManager:
154
+ logger.warning("Using default _split_corpus. Override this method to improve performance.")
155
+ split_content = [content[i : i + 1000] for i in range(0, len(content), 1000)]
156
+ return [c for c in split_content if len(c.strip()) > 0]
157
+
158
+ def _extract_chunk_metadata(self, content: str) -> dict[str, Any]:
159
+ """
160
+ **It is highly recommended to override this method.**
161
+ Extract metadata from a chunk of content, to be appended to corpus metadata
162
+ """
163
+ if self.__class__ is not BaseCorpusManager:
164
+ logger.warning("Using default _extract_chunk_metadata. It is highly recommended to override this method.")
165
+ return {
166
+ "chunk_length": len(content),
167
+ }
@@ -0,0 +1,45 @@
1
+ from dataclasses import dataclass, asdict
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Optional
5
+
6
+
7
+ # class RetrievalStrategy(Enum):
8
+ # """Supported retrieval strategies"""
9
+ # SIMILARITY = "similarity" # Pure vector similarity
10
+ # KEYWORD = "keyword" # Metadata-based keyword search
11
+ # HYBRID = "hybrid" # Combined similarity + keyword
12
+ # TEMPORAL = "temporal" # Time-based search (if applicable)
13
+
14
+
15
+ @dataclass
16
+ class RetrievalResult:
17
+ """Standardized result structure for all retrieval operations"""
18
+
19
+ document_id: str
20
+ original_id: str
21
+ chunk_index: int
22
+ content: str
23
+ title: Optional[str]
24
+ metadata: dict[str, Any]
25
+ similarity_score: Optional[float] = None
26
+ keyword_matches: Optional[list[str]] = None
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ return asdict(self)
30
+
31
+
32
+ @dataclass
33
+ class SearchQuery:
34
+ """Standardized search query structure"""
35
+
36
+ text: Optional[str] = None
37
+ keywords: Optional[list[str]] = None
38
+ metadata_filters: Optional[dict[str, Any]] = None
39
+ date_range: Optional[tuple[datetime, datetime]] = None
40
+ limit: int = 10
41
+ # strategy: RetrievalStrategy = RetrievalStrategy.HYBRID
42
+
43
+ def validate(self) -> bool:
44
+ """Ensure query has at least one search criterion"""
45
+ return any([self.text, self.keywords, self.metadata_filters, self.date_range])
@@ -0,0 +1,71 @@
1
+ from abc import ABC, abstractmethod
2
+ from logging import getLogger
3
+ from typing import Any, Generic, Optional
4
+
5
+ from sqlalchemy import text
6
+
7
+ from pgvector_template.core import BaseEmbeddingProvider, RetrievalResult, SearchQuery, BaseDocument
8
+ from pgvector_template.types import DocumentType
9
+ from sqlalchemy.orm import Session
10
+
11
+
12
+ class BaseSearchClient(ABC, Generic[DocumentType]):
13
+ """Abstract base for document retrieval"""
14
+
15
+ def __init__(self, session: Session, embedding_provider: BaseEmbeddingProvider):
16
+ self.session = session
17
+ self.embedding_provider = embedding_provider
18
+ self.logger = getLogger(self.__class__.__name__)
19
+
20
+ @abstractmethod
21
+ def search(self, query: SearchQuery) -> list[RetrievalResult]:
22
+ """Main search interface"""
23
+ raise NotImplementedError("Subclasses must implement this method")
24
+
25
+ def search_by_metadata(self, filters: dict[str, Any], limit: int = 10) -> list[BaseDocument]:
26
+ """Generic JSON-based metadata search"""
27
+ query = self.session.query(BaseDocument).filter(BaseDocument.is_deleted == False)
28
+
29
+ # Apply JSON-based filters
30
+ for key, value in filters.items():
31
+ if isinstance(value, list):
32
+ # Array contains search
33
+ query = query.filter(text(f"metadata->>'{key}' = ANY(:value)")).params(value=value)
34
+ elif isinstance(value, dict):
35
+ # Nested JSON search
36
+ for nested_key, nested_value in value.items():
37
+ query = query.filter(text(f"metadata->'{key}'->>'{nested_key}' = :value")).params(
38
+ value=nested_value
39
+ )
40
+ else:
41
+ # Simple equality
42
+ query = query.filter(text(f"metadata->>'{key}' = :value")).params(value=str(value))
43
+
44
+ return query.limit(limit).all()
45
+
46
+ @abstractmethod
47
+ def get_full_document(self, original_id: str) -> Optional[dict[str, Any]]:
48
+ """Reconstruct full document from chunks"""
49
+ raise NotImplementedError("Subclasses must implement this method")
50
+
51
+ # Template methods with default implementations
52
+ def similarity_search(self, text: str, limit: int = 10) -> list[RetrievalResult]:
53
+ """Vector similarity search"""
54
+ if not text.strip():
55
+ return []
56
+
57
+ embedding = self.embedding_provider.embed_text(text)
58
+ return self._vector_search(embedding, limit)
59
+
60
+ def keyword_search(self, keywords: list[str], limit: int = 10) -> list[RetrievalResult]:
61
+ """Metadata-based keyword search"""
62
+ return self._metadata_search({"keywords": keywords}, limit)
63
+
64
+ def _vector_search(self, embedding: list[float], limit: int) -> list[RetrievalResult]:
65
+ """Internal vector similarity search"""
66
+ # This will be implemented by the template using the DocumentType
67
+ raise NotImplementedError("Subclasses must implement vector search")
68
+
69
+ def _metadata_search(self, filters: dict[str, Any], limit: int) -> list[RetrievalResult]:
70
+ """Internal metadata search"""
71
+ raise NotImplementedError("Subclasses must implement metadata search")
@@ -0,0 +1,4 @@
1
+ from pgvector_template.db.connection import DatabaseManager
2
+ from pgvector_template.db.document_db import DocumentDatabaseManager, TempDocumentDatabaseManager
3
+
4
+ __all__ = ["DatabaseManager", "DocumentDatabaseManager", "TempDocumentDatabaseManager"]
@@ -0,0 +1,66 @@
1
+ from contextlib import contextmanager
2
+ from logging import getLogger
3
+ from typing import Generator, Type
4
+
5
+ from sqlalchemy import create_engine, text
6
+ from sqlalchemy.orm import sessionmaker, Session
7
+ from sqlalchemy.ext.declarative import DeclarativeMeta
8
+
9
+
10
+ class DatabaseManager:
11
+ """Manages database connections and schema setup"""
12
+
13
+ def __init__(self, database_url: str):
14
+ self.database_url = database_url
15
+ self.engine = None
16
+ self.SessionLocal = None
17
+ self.logger = getLogger(self.__class__.__name__)
18
+
19
+ def initialize(self):
20
+ """Initialize database connection and session factory"""
21
+ self.engine = create_engine(
22
+ self.database_url, pool_pre_ping=True, pool_recycle=300, echo=False # Set to True for SQL debugging
23
+ )
24
+ self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
25
+ self._ensure_pgvector_extension()
26
+
27
+ def create_schema(self, schema_name: str) -> None:
28
+ """Create a new schema for a collection type"""
29
+ with self.engine.connect() as conn:
30
+ conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_name}"))
31
+ conn.commit()
32
+
33
+ self.logger.info(f"Created schema: {schema_name}")
34
+
35
+ def create_tables(self, base_class: Type[DeclarativeMeta], schema_name: str) -> None:
36
+ """Create tables for a specific schema"""
37
+ # Ensure all tables in this base class use the specified schema
38
+ for table in base_class.metadata.tables.values():
39
+ table.schema = schema_name
40
+ base_class.metadata.create_all(self.engine, checkfirst=True)
41
+ self.logger.info(f"Created tables for schema: {schema_name}")
42
+
43
+ def _ensure_pgvector_extension(self):
44
+ """Ensure pgvector extension is available"""
45
+ with self.engine.connect() as conn:
46
+ conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
47
+ conn.commit()
48
+
49
+ @contextmanager
50
+ def get_session(self) -> Generator[Session, None, None]:
51
+ """
52
+ Get a database session with automatic cleanup. Usage:
53
+
54
+ ```python
55
+ with db_mgr.get_session() as session:
56
+ # do stuff with the Session here
57
+ ```
58
+ """
59
+ session = self.SessionLocal()
60
+ try:
61
+ yield session
62
+ except Exception:
63
+ session.rollback()
64
+ raise
65
+ finally:
66
+ session.close()
@@ -0,0 +1,85 @@
1
+ from typing import List, Type
2
+
3
+ from sqlalchemy import text
4
+
5
+ from pgvector_template.core.document import BaseDocument
6
+ from pgvector_template.db.connection import DatabaseManager
7
+
8
+
9
+ class DocumentDatabaseManager(DatabaseManager):
10
+ """
11
+ Specialized database manager for document collections with streamlined setup
12
+
13
+ Usage example:
14
+ ```python
15
+ db = DocumentDatabaseManager(
16
+ database_url="postgresql://user:pass@localhost/db",
17
+ schema_suffix="fpga_operations",
18
+ document_classes=[TextDocument, ImageDocument, AudioDocument]
19
+ )
20
+ db.setup()
21
+ ```
22
+ """
23
+
24
+ SCHEMA_PREFIX = "knowledge_base_"
25
+
26
+ def __init__(self, database_url: str, schema_suffix: str, document_classes: List[Type[BaseDocument]]):
27
+ """
28
+ Initialize a document-oriented database manager
29
+
30
+ Args:
31
+ database_url: PostgreSQL connection string
32
+ schema_suffix: Suffix for schema name (will be prefixed with SCHEMA_PREFIX)
33
+ document_classes: List of concrete subclasses of BaseDocument
34
+ """
35
+ super().__init__(database_url)
36
+ self.schema_suffix = schema_suffix
37
+ self.schema_name = f"{self.SCHEMA_PREFIX}{schema_suffix}"
38
+ self.document_classes = document_classes
39
+
40
+ def setup(self) -> None:
41
+ """One-step setup: initialize connection, create schema and tables for all document classes"""
42
+ self.initialize()
43
+ self.create_schema(self.schema_name)
44
+
45
+ for doc_class in self.document_classes:
46
+ # Set schema for each document class
47
+ doc_class.__table__.schema = self.schema_name
48
+ self.create_tables(doc_class, self.schema_name)
49
+
50
+ self.logger.info(
51
+ f"Document database setup complete for {self.schema_name} with {len(self.document_classes)} tables"
52
+ )
53
+
54
+
55
+ class TempDocumentDatabaseManager(DocumentDatabaseManager):
56
+ def setup(self) -> str:
57
+ """
58
+ Create a temporary schema with a unique name for testing
59
+ Format: `temp_knowledge_base_<schema_suffix>_<uuid_snippet>`
60
+ """
61
+ from uuid import uuid4
62
+
63
+ temp_schema_name = f"temp_{self.schema_name}_{uuid4().hex[:8]}"
64
+
65
+ self.initialize()
66
+ self.create_schema(temp_schema_name)
67
+
68
+ for doc_class in self.document_classes:
69
+ # Create a copy of the table with the temporary schema
70
+ doc_class.__table__.schema = temp_schema_name
71
+ self.create_tables(doc_class, temp_schema_name)
72
+
73
+ self.logger.info(f"Created temporary schema: {temp_schema_name}")
74
+ return temp_schema_name
75
+
76
+ def cleanup(self, schema_name: str) -> None:
77
+ """Drop a temporary schema and all its objects"""
78
+ if not schema_name.startswith("temp_"):
79
+ raise ValueError("Can only drop schemas with 'temp_' prefix for safety")
80
+
81
+ with self.engine.connect() as conn:
82
+ conn.execute(text(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE"))
83
+ conn.commit()
84
+
85
+ self.logger.info(f"Dropped temporary schema: {schema_name}")
@@ -0,0 +1,6 @@
1
+ from typing import TypeVar
2
+
3
+ from pgvector_template.core.document import BaseDocument, BaseDocumentMetadata
4
+
5
+ T = TypeVar("T", bound="BaseDocumentMetadata")
6
+ DocumentType = TypeVar("DocumentType", bound="BaseDocument")
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.1
2
+ Name: pgvector-template
3
+ Version: 0.1.0
4
+ Summary: Template library for flexible PGVector RAG implementations
5
+ Author-email: DL <v49t9zpqd@mozmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/DavidLiuGit/PGVector-Template
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: psycopg[binary]>=3.1.0
15
+ Requires-Dist: pgvector>=0.2.0
16
+ Requires-Dist: pydantic<3.0,>=2.11
17
+ Requires-Dist: sqlalchemy>=2.0.0
18
+ Requires-Dist: typing-extensions>=4.0.0
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7.0.0; extra == "test"
21
+ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
22
+ Requires-Dist: python-dotenv>=1.0.0; extra == "test"
23
+
24
+ # PGVector-Template
25
+
26
+ Template library for flexible PGVector RAG implementations
27
+
28
+
29
+ ## Testing
30
+
31
+ Install dependencies (preferably in a virtualenv) before running tests:
32
+ ```bash
33
+ pip install -e .[test]
34
+ ```
35
+
36
+ ### Unit tests
37
+ ```bash
38
+ python -m unittest
39
+ ```
40
+
41
+ ### Integration tests
42
+ ```bash
43
+ python -m unittest discover -s integ-tests
44
+ ```
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pgvector_template/__init__.py
5
+ pgvector_template/types.py
6
+ pgvector_template.egg-info/PKG-INFO
7
+ pgvector_template.egg-info/SOURCES.txt
8
+ pgvector_template.egg-info/dependency_links.txt
9
+ pgvector_template.egg-info/requires.txt
10
+ pgvector_template.egg-info/top_level.txt
11
+ pgvector_template/core/__init__.py
12
+ pgvector_template/core/document.py
13
+ pgvector_template/core/embedder.py
14
+ pgvector_template/core/manager.py
15
+ pgvector_template/core/retriever.py
16
+ pgvector_template/core/search.py
17
+ pgvector_template/db/__init__.py
18
+ pgvector_template/db/connection.py
19
+ pgvector_template/db/document_db.py
20
+ pgvector_template/search/__init__.py
21
+ pgvector_template/utils/__init__.py
@@ -0,0 +1,10 @@
1
+ psycopg[binary]>=3.1.0
2
+ pgvector>=0.2.0
3
+ pydantic<3.0,>=2.11
4
+ sqlalchemy>=2.0.0
5
+ typing-extensions>=4.0.0
6
+
7
+ [test]
8
+ pytest>=7.0.0
9
+ pytest-cov>=4.0.0
10
+ python-dotenv>=1.0.0
@@ -0,0 +1 @@
1
+ pgvector_template
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pgvector-template"
7
+ version = "0.1.0"
8
+ description = "Template library for flexible PGVector RAG implementations"
9
+ authors = [{ name="DL", email="v49t9zpqd@mozmail.com" }]
10
+ license = { text = "MIT" }
11
+ dependencies = [
12
+ "psycopg[binary]>=3.1.0",
13
+ "pgvector>=0.2.0",
14
+ "pydantic>=2.11,<3.0",
15
+ "sqlalchemy>=2.0.0",
16
+ "typing-extensions>=4.0.0",
17
+ ]
18
+ readme = "README.md"
19
+ requires-python = ">=3.11"
20
+ classifiers = [
21
+ "Programming Language :: Python :: 3",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ test = [
28
+ "pytest>=7.0.0",
29
+ "pytest-cov>=4.0.0",
30
+ "python-dotenv>=1.0.0",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/DavidLiuGit/PGVector-Template"
35
+
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["."] # Look for packages in the root directory
39
+ include = ["pgvector_template*"] # Include all packages starting with pgvector_template
40
+
41
+
42
+ [tool.black]
43
+ line-length = 120
44
+ target-version = ["py311", "py312", "py313"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+