kbguard 0.1.0__py3-none-any.whl

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.
Files changed (49) hide show
  1. kbguard/__init__.py +1 -0
  2. kbguard/adapters/__init__.py +0 -0
  3. kbguard/adapters/base.py +70 -0
  4. kbguard/adapters/bridge_langchain.py +151 -0
  5. kbguard/adapters/chroma.py +125 -0
  6. kbguard/adapters/pgvector.py +191 -0
  7. kbguard/adapters/pinecone.py +153 -0
  8. kbguard/adapters/qdrant.py +134 -0
  9. kbguard/checks/__init__.py +0 -0
  10. kbguard/checks/base.py +61 -0
  11. kbguard/checks/chunking_damage.py +269 -0
  12. kbguard/checks/contradictions.py +358 -0
  13. kbguard/checks/duplicates.py +204 -0
  14. kbguard/checks/stale_versions.py +116 -0
  15. kbguard/cli.py +502 -0
  16. kbguard/config.py +191 -0
  17. kbguard/evals/__init__.py +0 -0
  18. kbguard/evals/golden.py +124 -0
  19. kbguard/evals/metrics/__init__.py +0 -0
  20. kbguard/evals/metrics/base.py +24 -0
  21. kbguard/evals/metrics/deepeval_plugin.py +186 -0
  22. kbguard/evals/metrics/ragas_plugin.py +212 -0
  23. kbguard/evals/retrieval.py +115 -0
  24. kbguard/evals/target.py +121 -0
  25. kbguard/ingest/__init__.py +0 -0
  26. kbguard/ingest/embeddings.py +76 -0
  27. kbguard/ingest/exporter.py +48 -0
  28. kbguard/ingest/metadata.py +112 -0
  29. kbguard/ingest/snapshot.py +302 -0
  30. kbguard/judge/__init__.py +0 -0
  31. kbguard/judge/cache.py +47 -0
  32. kbguard/judge/calibrate.py +94 -0
  33. kbguard/judge/client.py +102 -0
  34. kbguard/models.py +70 -0
  35. kbguard/py.typed +0 -0
  36. kbguard/registry.py +52 -0
  37. kbguard/report/__init__.py +0 -0
  38. kbguard/report/enrich.py +176 -0
  39. kbguard/report/html.py +52 -0
  40. kbguard/report/results.py +36 -0
  41. kbguard/report/templates/report.html.j2 +210 -0
  42. kbguard/scoring/__init__.py +0 -0
  43. kbguard/scoring/diff.py +129 -0
  44. kbguard/scoring/health.py +81 -0
  45. kbguard-0.1.0.dist-info/METADATA +195 -0
  46. kbguard-0.1.0.dist-info/RECORD +49 -0
  47. kbguard-0.1.0.dist-info/WHEEL +4 -0
  48. kbguard-0.1.0.dist-info/entry_points.txt +15 -0
  49. kbguard-0.1.0.dist-info/licenses/LICENSE +201 -0
kbguard/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,70 @@
1
+ from collections.abc import Iterator
2
+ from typing import Any, Literal, Protocol
3
+
4
+ from kbguard.models import Chunk
5
+
6
+
7
+ class AdapterConnectionError(Exception):
8
+ """Raised when an adapter can't actually reach/authenticate to its
9
+ store — wrong host/port, server down, bad credentials — as opposed to
10
+ a missing config *value* (that's a plain `ValueError`, raised before
11
+ any real call is attempted) or a missing *env var* (`ConfigError`).
12
+ Every adapter wraps its own client library's real connection failure
13
+ into this one shared type specifically so `cli.py` can print one clean
14
+ message for it; without this, a bad password or an unreachable host
15
+ surfaces as a full raw traceback from psycopg/qdrant_client/pinecone's
16
+ own exception types, confirmed by hand before this existed."""
17
+
18
+
19
+ class VectorStoreAdapter(Protocol):
20
+ """The only contract kbguard knows about a vector store. Everything
21
+ downstream (ingest, checks, evals) works on the `Chunk` records this
22
+ produces — never on a store-specific client — which is what makes any
23
+ vector store pluggable.
24
+ """
25
+
26
+ name: str
27
+
28
+ # "full": iter_chunks() genuinely walks the entire collection (every
29
+ # native adapter). "sampled": it can't — the wrapped store exposes no
30
+ # enumeration API at all, only similarity search, so iter_chunks()
31
+ # approximates coverage via search-based sampling instead (so far,
32
+ # only bridge_langchain.py). SnapshotWriter records this verbatim into
33
+ # the snapshot manifest, so nothing downstream can mistake a sampled
34
+ # scan for a complete one.
35
+ iteration_mode: Literal["full", "sampled"]
36
+
37
+ def connect(self, **settings: Any) -> None: ...
38
+
39
+ def collections(self) -> list[str]: ...
40
+
41
+ def count(self, collection: str) -> int: ...
42
+
43
+ def iter_chunks(
44
+ self,
45
+ collection: str,
46
+ batch_size: int = 500,
47
+ with_embeddings: bool = True,
48
+ ) -> Iterator[Chunk]:
49
+ """Must page through the *entire* collection without ever holding it
50
+ all in memory at once — implementations page internally (keyset or
51
+ cursor-based, store-dependent) and yield one Chunk at a time.
52
+ """
53
+ ...
54
+
55
+ def search(
56
+ self,
57
+ collection: str,
58
+ query_vector: list[float],
59
+ k: int,
60
+ filters: dict[str, Any] | None = None,
61
+ ) -> list[tuple[Chunk, float]]:
62
+ """Results are returned **best-match-first**; callers rely on this
63
+ ordering, not on the numeric score. Scores only need to be
64
+ comparable *within a single call* — never across two different
65
+ adapters, two different collections, or against some assumed
66
+ absolute scale (a Chroma distance and a Qdrant similarity are not
67
+ the same kind of number, and callers must not assume either
68
+ direction — lower-is-better or higher-is-better — holds generically).
69
+ """
70
+ ...
@@ -0,0 +1,151 @@
1
+ import hashlib
2
+ import random
3
+ from collections.abc import Iterator
4
+ from typing import Any
5
+
6
+ from kbguard.models import Chunk
7
+
8
+ # Deterministic seed: sampling coverage should be reproducible between two
9
+ # scans of an unchanged collection (so a `diff` isn't just sampling noise),
10
+ # not a source of real randomness — this is sampling, not security.
11
+ _RANDOM_SEED = 0
12
+
13
+
14
+ def _require_setting(settings: dict[str, Any], key: str) -> Any:
15
+ if key not in settings:
16
+ raise ValueError(f"store.settings.{key} is required for the bridge_langchain adapter")
17
+ return settings[key]
18
+
19
+
20
+ def _stable_id(page_content: str, metadata: dict[str, Any]) -> str:
21
+ # Used only when the wrapped store's own Document.id is None (common —
22
+ # many LangChain VectorStore implementations never set it). Content-
23
+ # based, not random, so the same chunk gets the same id across two
24
+ # separate scans; two genuinely distinct chunks that happen to share
25
+ # identical text and metadata would collide, which is an accepted,
26
+ # documented gap, not a silently-assumed-safe one.
27
+ payload = page_content + repr(sorted(metadata.items()))
28
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
29
+
30
+
31
+ class LangChainBridgeAdapter:
32
+ """Wraps a LangChain VectorStore instance the client already has,
33
+ instead of connecting to a store natively. langchain_core.vectorstores
34
+ .VectorStore (checked against the installed library, not assumed)
35
+ exposes no "iterate everything" or "count" method at all — only
36
+ similarity search — so this adapter is deliberately less complete than
37
+ the native ones: iter_chunks() approximates coverage via search-based
38
+ sampling, reported honestly as iteration_mode = "sampled" (see
39
+ adapters/base.py), and count() refuses to report a number it has no
40
+ way to verify, rather than guessing.
41
+
42
+ A further real gap, worth naming explicitly: none of VectorStore's
43
+ search methods return a hit's own embedding vector, so every Chunk
44
+ this adapter produces has embedding=None regardless of the
45
+ with_embeddings argument — unlike every native adapter, which returns
46
+ real stored vectors. A caller relying on `embeddings.reuse_stored`
47
+ will recompute every chunk's embedding through the configured model
48
+ instead, which is a real cost difference worth knowing about, not a
49
+ bug in how `with_embeddings` is honored here.
50
+ """
51
+
52
+ name = "bridge_langchain"
53
+ iteration_mode = "sampled"
54
+
55
+ def __init__(self) -> None:
56
+ self._vectorstore: Any = None
57
+ self._collection_name = "default"
58
+ self._embedding_dim: int | None = None
59
+ self._sample_queries: list[str] = []
60
+ self._probe_count = 20
61
+
62
+ def connect(self, **settings: Any) -> None:
63
+ self._vectorstore = _require_setting(settings, "vectorstore")
64
+ # Needed to generate the random probe vectors iter_chunks() uses to
65
+ # sample beyond whatever sample_queries covers — there's no API on
66
+ # a generic VectorStore to ask its own embedding dimension.
67
+ self._embedding_dim = _require_setting(settings, "embedding_dim")
68
+ # Real queries a human knows are representative of this
69
+ # collection's content sample far better than random vectors do —
70
+ # optional, but strongly recommended; see docs/guide/adapters/
71
+ # bridge-langchain.md.
72
+ self._sample_queries = settings.get("sample_queries", [])
73
+ self._probe_count = settings.get("probe_count", 20)
74
+ self._collection_name = settings.get("collection", "default")
75
+
76
+ def _require_vectorstore(self) -> Any:
77
+ if self._vectorstore is None:
78
+ raise RuntimeError("LangChainBridgeAdapter.connect() must be called before use")
79
+ return self._vectorstore
80
+
81
+ def collections(self) -> list[str]:
82
+ # One already-constructed VectorStore instance is already scoped
83
+ # to whatever single collection/index it was built against — there
84
+ # is no "list every collection this client has" operation to call.
85
+ return [self._collection_name]
86
+
87
+ def count(self, collection: str) -> int:
88
+ raise NotImplementedError(
89
+ "LangChainBridgeAdapter can't report an exact count -- the wrapped VectorStore has no "
90
+ "universal count/enumerate method. iter_chunks() reports iteration_mode='sampled' instead "
91
+ "of a count it has no way to verify; see docs/guide/adapters/bridge-langchain.md"
92
+ )
93
+
94
+ def iter_chunks(
95
+ self,
96
+ collection: str,
97
+ batch_size: int = 500,
98
+ with_embeddings: bool = True,
99
+ ) -> Iterator[Chunk]:
100
+ vectorstore = self._require_vectorstore()
101
+ seen_ids: set[str] = set()
102
+
103
+ for query in self._sample_queries:
104
+ for doc, _score in vectorstore.similarity_search_with_score(query, k=batch_size):
105
+ chunk = self._to_chunk(doc)
106
+ if chunk.id in seen_ids:
107
+ continue
108
+ seen_ids.add(chunk.id)
109
+ yield chunk
110
+
111
+ # Beyond the (optional) human-provided sample_queries, probe with
112
+ # random unit-ish vectors in the embedding space to pick up content
113
+ # those specific queries wouldn't surface — still not a guarantee
114
+ # of full coverage, just a broader, documented sample.
115
+ assert self._embedding_dim is not None # guaranteed by connect()
116
+ rng = random.Random(_RANDOM_SEED)
117
+ for _ in range(self._probe_count):
118
+ probe_vector = [rng.uniform(-1.0, 1.0) for _ in range(self._embedding_dim)]
119
+ for doc in vectorstore.similarity_search_by_vector(probe_vector, k=batch_size):
120
+ chunk = self._to_chunk(doc)
121
+ if chunk.id in seen_ids:
122
+ continue
123
+ seen_ids.add(chunk.id)
124
+ yield chunk
125
+
126
+ def search(
127
+ self,
128
+ collection: str,
129
+ query_vector: list[float],
130
+ k: int,
131
+ filters: dict[str, Any] | None = None,
132
+ ) -> list[tuple[Chunk, float]]:
133
+ if filters is not None:
134
+ raise NotImplementedError("LangChainBridgeAdapter.search() doesn't support filters yet")
135
+
136
+ vectorstore = self._require_vectorstore()
137
+ # similarity_search_by_vector (checked against the installed
138
+ # library) returns plain Documents, best-match-first, but no
139
+ # scores at all -- unlike every native adapter's search(). A
140
+ # descending rank-based placeholder satisfies the Protocol's
141
+ # best-match-first ordering guarantee; it is not a real similarity
142
+ # value and must never be compared against another adapter's
143
+ # score, which adapters/base.py's own docstring already rules out
144
+ # generically.
145
+ docs = vectorstore.similarity_search_by_vector(query_vector, k=k)
146
+ return [(self._to_chunk(doc), 1.0 - i * 0.001) for i, doc in enumerate(docs)]
147
+
148
+ def _to_chunk(self, doc: Any) -> Chunk:
149
+ metadata = dict(doc.metadata or {})
150
+ chunk_id = doc.id if getattr(doc, "id", None) is not None else _stable_id(doc.page_content, metadata)
151
+ return Chunk(id=chunk_id, text=doc.page_content, embedding=None, metadata=metadata)
@@ -0,0 +1,125 @@
1
+ from collections.abc import Iterator, Sequence
2
+ from typing import Any
3
+
4
+ import chromadb
5
+ from chromadb.api import ClientAPI
6
+ from chromadb.errors import ChromaError
7
+
8
+ from kbguard.adapters.base import AdapterConnectionError
9
+ from kbguard.models import Chunk
10
+
11
+ # kbguard always supplies its own embeddings (ingest/embeddings.py — reuse
12
+ # stored or recompute with the configured model) and never asks Chroma to
13
+ # embed text itself. Passing embedding_function=None everywhere stops Chroma
14
+ # from silently downloading its default embedding model on first use, which
15
+ # would otherwise be a surprise network call this project's "runs entirely
16
+ # in your environment" principle explicitly rules out.
17
+ _NO_EMBEDDING_FUNCTION = None
18
+
19
+
20
+ class ChromaAdapter:
21
+ name = "chroma"
22
+ iteration_mode = "full"
23
+
24
+ def __init__(self) -> None:
25
+ self._client: ClientAPI | None = None
26
+
27
+ def connect(self, **settings: Any) -> None:
28
+ path = settings.get("path")
29
+ # No `path` -> in-process, in-memory client: this is what makes
30
+ # Chroma the fastest adapter to develop and test against (no server,
31
+ # no Docker). Given a `path`, data persists to that local directory
32
+ # between runs instead.
33
+ try:
34
+ if path is not None:
35
+ self._client = chromadb.PersistentClient(path=str(path))
36
+ else:
37
+ self._client = chromadb.EphemeralClient()
38
+ except ChromaError as exc:
39
+ # Local-only (no remote host to misconfigure), but an
40
+ # unwritable/nonexistent path still fails eagerly right here
41
+ # (confirmed by hand) -- chromadb.errors.ChromaError is the
42
+ # real common base, confirmed via the real exception's own
43
+ # __mro__ before relying on it.
44
+ raise AdapterConnectionError(f"could not open the Chroma store at {path!r}: {exc}") from exc
45
+
46
+ def _require_client(self) -> ClientAPI:
47
+ if self._client is None:
48
+ raise RuntimeError("ChromaAdapter.connect() must be called before use")
49
+ return self._client
50
+
51
+ def collections(self) -> list[str]:
52
+ return [c.name for c in self._require_client().list_collections()]
53
+
54
+ def count(self, collection: str) -> int:
55
+ col = self._require_client().get_collection(collection, embedding_function=_NO_EMBEDDING_FUNCTION)
56
+ return col.count()
57
+
58
+ def iter_chunks(
59
+ self,
60
+ collection: str,
61
+ batch_size: int = 500,
62
+ with_embeddings: bool = True,
63
+ ) -> Iterator[Chunk]:
64
+ col = self._require_client().get_collection(collection, embedding_function=_NO_EMBEDDING_FUNCTION)
65
+ include = ["documents", "metadatas", *(["embeddings"] if with_embeddings else [])]
66
+
67
+ offset = 0
68
+ while True:
69
+ batch = col.get(limit=batch_size, offset=offset, include=include) # type: ignore[arg-type]
70
+ ids = batch["ids"]
71
+ if not ids:
72
+ return
73
+
74
+ documents = batch["documents"]
75
+ metadatas = batch["metadatas"]
76
+ embeddings = batch["embeddings"]
77
+
78
+ for i, chunk_id in enumerate(ids):
79
+ text = documents[i] if documents is not None else None
80
+ metadata = metadatas[i] if metadatas is not None else None
81
+ embedding = [float(x) for x in embeddings[i]] if embeddings is not None else None
82
+ yield Chunk(
83
+ id=chunk_id,
84
+ text=text or "",
85
+ embedding=embedding,
86
+ metadata=dict(metadata or {}),
87
+ )
88
+
89
+ offset += len(ids)
90
+
91
+ def search(
92
+ self,
93
+ collection: str,
94
+ query_vector: list[float],
95
+ k: int,
96
+ filters: dict[str, Any] | None = None,
97
+ ) -> list[tuple[Chunk, float]]:
98
+ col = self._require_client().get_collection(collection, embedding_function=_NO_EMBEDDING_FUNCTION)
99
+ query_embeddings: list[Sequence[float] | Sequence[int]] = [query_vector]
100
+ result = col.query(
101
+ query_embeddings=query_embeddings,
102
+ n_results=k,
103
+ where=filters,
104
+ include=["documents", "metadatas", "embeddings", "distances"],
105
+ )
106
+
107
+ # query() nests one list per query vector; we only ever send one.
108
+ ids = result["ids"][0]
109
+ documents = result["documents"][0] if result["documents"] is not None else None
110
+ metadatas = result["metadatas"][0] if result["metadatas"] is not None else None
111
+ embeddings = result["embeddings"][0] if result["embeddings"] is not None else None
112
+ distances = result["distances"][0] if result["distances"] is not None else None
113
+
114
+ pairs: list[tuple[Chunk, float]] = []
115
+ for i, chunk_id in enumerate(ids):
116
+ text = documents[i] if documents is not None else None
117
+ metadata = metadatas[i] if metadatas is not None else None
118
+ embedding = [float(x) for x in embeddings[i]] if embeddings is not None else None
119
+ chunk = Chunk(id=chunk_id, text=text or "", embedding=embedding, metadata=dict(metadata or {}))
120
+ # Chroma's query() already returns nearest-first, satisfying the
121
+ # Protocol's best-match-first guarantee (adapters/base.py)
122
+ # without any re-sorting needed here.
123
+ score = float(distances[i]) if distances is not None else 0.0
124
+ pairs.append((chunk, score))
125
+ return pairs
@@ -0,0 +1,191 @@
1
+ import re
2
+ from collections.abc import Iterator
3
+ from typing import Any
4
+
5
+ import psycopg
6
+ from pgvector import Vector
7
+ from pgvector.psycopg import register_vector
8
+ from psycopg import sql
9
+
10
+ from kbguard.adapters.base import AdapterConnectionError
11
+ from kbguard.config import resolve_env
12
+ from kbguard.models import Chunk
13
+
14
+ # Table/column names come from config (spec: pgvector adapter must work
15
+ # against a client's existing schema, not impose its own). Identifiers
16
+ # can't be passed as query parameters the way values can (no %s for a
17
+ # table/column name) — psycopg.sql.Identifier() safely quotes whatever
18
+ # string it's given, but this still validates against a strict allowlist
19
+ # first and fails with a clear message, rather than silently accepting an
20
+ # unusual identifier that merely happens not to be a SQL injection vector.
21
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
22
+
23
+
24
+ def _safe_identifier(name: str, *, what: str) -> sql.Identifier:
25
+ if not _IDENTIFIER_RE.match(name):
26
+ raise ValueError(
27
+ f"{what} {name!r} is not a valid SQL identifier (letters, digits, underscore, "
28
+ f"can't start with a digit) — refusing to use it in a query"
29
+ )
30
+ return sql.Identifier(name)
31
+
32
+
33
+ def _require_setting(settings: dict[str, Any], key: str) -> Any:
34
+ if key not in settings:
35
+ raise ValueError(f"store.settings.{key} is required for the pgvector adapter")
36
+ return settings[key]
37
+
38
+
39
+ class PgVectorAdapter:
40
+ name = "pgvector"
41
+ iteration_mode = "full"
42
+
43
+ def __init__(self) -> None:
44
+ self._conn: psycopg.Connection[Any] | None = None
45
+ self._id_column = "id"
46
+ self._text_column = "text"
47
+ self._metadata_column = "metadata"
48
+ self._embedding_column = "embedding"
49
+
50
+ def connect(self, **settings: Any) -> None:
51
+ host = settings.get("host", "localhost")
52
+ port = settings.get("port", 5432)
53
+ user = _require_setting(settings, "user")
54
+ dbname = _require_setting(settings, "dbname")
55
+ password_env = settings.get("password_env")
56
+ password = resolve_env(password_env) if password_env else settings.get("password")
57
+
58
+ self._id_column = settings.get("id_column", "id")
59
+ self._text_column = settings.get("text_column", "text")
60
+ self._metadata_column = settings.get("metadata_column", "metadata")
61
+ self._embedding_column = settings.get("embedding_column", "embedding")
62
+ # Validated once here, not per-query — a bad identifier should fail
63
+ # at connect() time, not confusingly deep inside the first scan.
64
+ for value, what in [
65
+ (self._id_column, "id_column"),
66
+ (self._text_column, "text_column"),
67
+ (self._metadata_column, "metadata_column"),
68
+ (self._embedding_column, "embedding_column"),
69
+ ]:
70
+ _safe_identifier(value, what=what)
71
+
72
+ # No explicit connect_timeout, by default, means no timeout at all
73
+ # -- confirmed by hand with a real unresponsive port: libpq (via
74
+ # psycopg) took 260 SECONDS to give up on its own, not an instant
75
+ # connection-refused. Same class of gap as adapters/pinecone.py's
76
+ # own timeout fix earlier -- a misconfigured/unreachable host
77
+ # should fail in seconds, not hang for minutes. Note this is a
78
+ # PER-ADDRESS timeout, not a total deadline -- confirmed by hand
79
+ # that "localhost" resolves to both ::1 and 127.0.0.1, so a real
80
+ # worst case is roughly 2x this value, not exactly this value.
81
+ connect_timeout = settings.get("connect_timeout", 10)
82
+
83
+ # autocommit: kbguard never writes, so there's nothing to commit or
84
+ # roll back — no reason to hold an open transaction across calls.
85
+ try:
86
+ self._conn = psycopg.connect(
87
+ host=host,
88
+ port=port,
89
+ user=user,
90
+ password=password,
91
+ dbname=dbname,
92
+ autocommit=True,
93
+ connect_timeout=connect_timeout,
94
+ )
95
+ except psycopg.Error as exc:
96
+ # psycopg.connect() is eager -- a bad host/port/password/dbname
97
+ # raises right here, confirmed by hand, unlike qdrant/pinecone's
98
+ # lazy clients. psycopg.Error is the real common base for this
99
+ # (confirmed via OperationalError.__mro__ before relying on it).
100
+ raise AdapterConnectionError(f"could not connect to pgvector at {host}:{port}: {exc}") from exc
101
+ # Requires the vector extension already enabled on the target
102
+ # database — kbguard never runs CREATE EXTENSION itself (DDL is a
103
+ # write; read-only is a hard design constraint, not a default).
104
+ register_vector(self._conn)
105
+
106
+ def _require_conn(self) -> psycopg.Connection[Any]:
107
+ if self._conn is None:
108
+ raise RuntimeError("PgVectorAdapter.connect() must be called before use")
109
+ return self._conn
110
+
111
+ def collections(self) -> list[str]:
112
+ rows = self._require_conn().execute(
113
+ "SELECT table_name FROM information_schema.tables "
114
+ "WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name"
115
+ ).fetchall()
116
+ return [row[0] for row in rows]
117
+
118
+ def count(self, collection: str) -> int:
119
+ table = _safe_identifier(collection, what="collection")
120
+ row = self._require_conn().execute(sql.SQL("SELECT count(*) FROM {}").format(table)).fetchone()
121
+ assert row is not None
122
+ return row[0] # type: ignore[no-any-return]
123
+
124
+ def iter_chunks(
125
+ self,
126
+ collection: str,
127
+ batch_size: int = 500,
128
+ with_embeddings: bool = True,
129
+ ) -> Iterator[Chunk]:
130
+ conn = self._require_conn()
131
+ table = _safe_identifier(collection, what="collection")
132
+ id_col = sql.Identifier(self._id_column)
133
+ select_cols = [id_col, sql.Identifier(self._text_column), sql.Identifier(self._metadata_column)]
134
+ if with_embeddings:
135
+ select_cols.append(sql.Identifier(self._embedding_column))
136
+
137
+ # Keyset (id > :last), not OFFSET: a row inserted or deleted mid-scan
138
+ # can make OFFSET skip or repeat rows on a live, multi-writer table
139
+ # — a real risk for pgvector specifically, unlike Chroma, which is
140
+ # typically local and single-writer.
141
+ query = sql.SQL("SELECT {} FROM {} WHERE {} > %s ORDER BY {} LIMIT %s").format(
142
+ sql.SQL(", ").join(select_cols), table, id_col, id_col
143
+ )
144
+
145
+ last_id = ""
146
+ while True:
147
+ rows = conn.execute(query, (last_id, batch_size)).fetchall()
148
+ if not rows:
149
+ return
150
+ for row in rows:
151
+ chunk_id, text, metadata = row[0], row[1], row[2]
152
+ embedding = row[3].to_list() if with_embeddings and row[3] is not None else None
153
+ yield Chunk(id=chunk_id, text=text, embedding=embedding, metadata=dict(metadata or {}))
154
+ last_id = rows[-1][0]
155
+
156
+ def search(
157
+ self,
158
+ collection: str,
159
+ query_vector: list[float],
160
+ k: int,
161
+ filters: dict[str, Any] | None = None,
162
+ ) -> list[tuple[Chunk, float]]:
163
+ if filters is not None:
164
+ # Silently ignoring filters would return unfiltered results to
165
+ # a caller that explicitly asked for filtered ones — wrong in
166
+ # a way that's hard to notice. No caller passes filters yet;
167
+ # pgvector's filter translation lands when one actually needs it.
168
+ raise NotImplementedError("PgVectorAdapter.search() doesn't support filters yet")
169
+
170
+ conn = self._require_conn()
171
+ table = _safe_identifier(collection, what="collection")
172
+ id_col = sql.Identifier(self._id_column)
173
+ text_col = sql.Identifier(self._text_column)
174
+ meta_col = sql.Identifier(self._metadata_column)
175
+ emb_col = sql.Identifier(self._embedding_column)
176
+
177
+ # <=> is pgvector's cosine distance operator (lower = closer);
178
+ # ORDER BY distance ASC satisfies the Protocol's best-match-first
179
+ # guarantee directly — no re-sorting needed.
180
+ query = sql.SQL("SELECT {}, {}, {}, {}, {} <=> %s AS distance FROM {} ORDER BY distance LIMIT %s").format(
181
+ id_col, text_col, meta_col, emb_col, emb_col, table
182
+ )
183
+ rows = conn.execute(query, (Vector(query_vector), k)).fetchall()
184
+
185
+ results: list[tuple[Chunk, float]] = []
186
+ for row in rows:
187
+ chunk_id, text, metadata, raw_embedding, distance = row
188
+ embedding = raw_embedding.to_list() if raw_embedding is not None else None
189
+ chunk = Chunk(id=chunk_id, text=text, embedding=embedding, metadata=dict(metadata or {}))
190
+ results.append((chunk, float(distance)))
191
+ return results