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.
- pgvector_template-0.1.0/LICENSE +21 -0
- pgvector_template-0.1.0/PKG-INFO +44 -0
- pgvector_template-0.1.0/README.md +21 -0
- pgvector_template-0.1.0/pgvector_template/__init__.py +6 -0
- pgvector_template-0.1.0/pgvector_template/core/__init__.py +23 -0
- pgvector_template-0.1.0/pgvector_template/core/document.py +171 -0
- pgvector_template-0.1.0/pgvector_template/core/embedder.py +20 -0
- pgvector_template-0.1.0/pgvector_template/core/manager.py +167 -0
- pgvector_template-0.1.0/pgvector_template/core/retriever.py +45 -0
- pgvector_template-0.1.0/pgvector_template/core/search.py +71 -0
- pgvector_template-0.1.0/pgvector_template/db/__init__.py +4 -0
- pgvector_template-0.1.0/pgvector_template/db/connection.py +66 -0
- pgvector_template-0.1.0/pgvector_template/db/document_db.py +85 -0
- pgvector_template-0.1.0/pgvector_template/search/__init__.py +0 -0
- pgvector_template-0.1.0/pgvector_template/types.py +6 -0
- pgvector_template-0.1.0/pgvector_template/utils/__init__.py +0 -0
- pgvector_template-0.1.0/pgvector_template.egg-info/PKG-INFO +44 -0
- pgvector_template-0.1.0/pgvector_template.egg-info/SOURCES.txt +21 -0
- pgvector_template-0.1.0/pgvector_template.egg-info/dependency_links.txt +1 -0
- pgvector_template-0.1.0/pgvector_template.egg-info/requires.txt +10 -0
- pgvector_template-0.1.0/pgvector_template.egg-info/top_level.txt +1 -0
- pgvector_template-0.1.0/pyproject.toml +44 -0
- pgvector_template-0.1.0/setup.cfg +4 -0
|
@@ -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,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,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}")
|
|
File without changes
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|