feat: index semantic records in qdrant
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
from tht.adapters.dwh import PostgresDwhAdapter, ThothRestDwhAdapter
|
||||
from tht.adapters.evidence import FilesystemEvidenceSource, HttpManifestEvidenceSource
|
||||
from tht.adapters.evidence.s3 import S3EvidenceSource
|
||||
from tht.adapters.vector import PgVectorStore, ThothHttpVectorStore
|
||||
from tht.adapters.vector import PgVectorStore, QdrantVectorStore, ThothHttpVectorStore
|
||||
from tht.config import Config, ConfigError
|
||||
from tht.db.connection import make_engine
|
||||
from tht.ports.dwh import DwhAdapter
|
||||
@@ -56,6 +56,14 @@ def build_vector_store(cfg: Config, *, require_write: bool = False) -> VectorSto
|
||||
VectorRestClient(resource.writer) if resource.writer is not None else None,
|
||||
expected_dimension=cfg.embeddings.dim if cfg.embeddings is not None else None,
|
||||
)
|
||||
case "qdrant":
|
||||
return QdrantVectorStore(
|
||||
base_url=resource.base_url,
|
||||
collection=resource.collection,
|
||||
workspace_id=cfg._workspace_id,
|
||||
workspace_revision=cfg._workspace_revision,
|
||||
expected_dimension=cfg.embeddings.dim if cfg.embeddings is not None else None,
|
||||
)
|
||||
case other: # pragma: no cover - Pydantic's discriminator rejects this first.
|
||||
raise ConfigError(f"Adapter vector non supportato: {other}")
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ _KEYWORD_INDEXES = (
|
||||
"record_kind",
|
||||
"vector_generation",
|
||||
"workspace_id",
|
||||
"workspace_revision",
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +53,7 @@ class QdrantVectorStore:
|
||||
base_url: str,
|
||||
collection: str,
|
||||
workspace_id: str,
|
||||
workspace_revision: str | None = None,
|
||||
expected_dimension: int | None = None,
|
||||
request: Callable[..., object] | None = None,
|
||||
connect_timeout: float = 2.0,
|
||||
@@ -60,6 +62,7 @@ class QdrantVectorStore:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._collection = collection
|
||||
self._workspace_id = workspace_id
|
||||
self._workspace_revision = workspace_revision
|
||||
self._expected_dimension = expected_dimension
|
||||
self._request = request or requests.request
|
||||
self._timeout = (connect_timeout, read_timeout)
|
||||
@@ -198,6 +201,7 @@ class QdrantVectorStore:
|
||||
write_record.record,
|
||||
content_hash=write_record.content_hash,
|
||||
workspace_id=self._workspace_id,
|
||||
workspace_revision=self._workspace_revision,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,17 +10,18 @@ from pathlib import Path
|
||||
import typer
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.cli.schema_cmd import _load_config_or_exit
|
||||
from tht.cli._guards import (
|
||||
has_vector_write_rest,
|
||||
require_server_profile,
|
||||
require_vector_write_allowed,
|
||||
)
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.cli.schema_cmd import _load_config_or_exit
|
||||
from tht.cli.session_cmd import load_snapshot_or_exit
|
||||
from tht.cli.vector_cmd import require_vector_cfg
|
||||
|
||||
memory_app = typer.Typer(help="Review memory (registro canonico + indice pgvector)")
|
||||
DECISION_OPT = typer.Option(None, "--decision", help="Seq da promuovere (ripetibile).")
|
||||
|
||||
|
||||
def registry_path(cfg) -> Path:
|
||||
@@ -29,18 +30,23 @@ def registry_path(cfg) -> Path:
|
||||
|
||||
def _resync_memory(cfg):
|
||||
"""Risincronizza l'indice pgvector col registro corrente (incrementale)."""
|
||||
from tht.cli.vector_cmd import make_embedder, open_store
|
||||
from tht.adapters.factory import build_vector_store
|
||||
from tht.cli.vector_cmd import make_embedder, sync_canonical_records
|
||||
from tht.memory import load_registry, memory_vector_records
|
||||
|
||||
records = memory_vector_records(load_registry(registry_path(cfg)))
|
||||
store = open_store(cfg, "memory")
|
||||
return store.sync(records, make_embedder(cfg.embeddings), kinds={"memory"})
|
||||
return sync_canonical_records(
|
||||
"memory",
|
||||
records,
|
||||
store=build_vector_store(cfg, require_write=True),
|
||||
embedder=make_embedder(cfg.embeddings),
|
||||
)
|
||||
|
||||
|
||||
@memory_app.command("promote")
|
||||
def promote_cmd(
|
||||
session: str = typer.Option(..., "--session"),
|
||||
decision: list[int] = typer.Option(None, "--decision", help="Seq da promuovere (ripetibile)."),
|
||||
decision: list[int] = DECISION_OPT,
|
||||
preview: bool = typer.Option(False, "--preview", help="Mostra i candidati in JSON, non scrive."),
|
||||
json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."),
|
||||
config: Path = CONFIG_OPT,
|
||||
@@ -55,7 +61,9 @@ def promote_cmd(
|
||||
|
||||
if preview:
|
||||
from tht.memory import (
|
||||
MAX_PROMOTION_CANDIDATES, preview_promotions_snapshot, reusable_promotions_snapshot,
|
||||
MAX_PROMOTION_CANDIDATES,
|
||||
preview_promotions_snapshot,
|
||||
reusable_promotions_snapshot,
|
||||
)
|
||||
cand = preview_promotions_snapshot(snapshot, registry_path(cfg))
|
||||
extra = len(reusable_promotions_snapshot(snapshot, registry_path(cfg))) - len(cand)
|
||||
@@ -304,8 +312,8 @@ def update_cmd(
|
||||
"""Modifica i campi di merito di una memoria (provenienza immutabile)."""
|
||||
from typing import get_args
|
||||
|
||||
from tht.memory import MemoryNotFound, update_record
|
||||
from tht.decisions import DecisionType
|
||||
from tht.memory import MemoryNotFound, update_record
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
|
||||
|
||||
@@ -2,9 +2,15 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from tht.cli._guards import has_vector_write_rest, require_server_profile, require_vector_write_allowed
|
||||
from tht.cli._guards import (
|
||||
has_vector_write_rest,
|
||||
require_server_profile,
|
||||
require_vector_write_allowed,
|
||||
)
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.cli.schema_cmd import _load_config_or_exit, annotations_path, physical_path
|
||||
from tht.ports.vector import VectorWriteRecord
|
||||
from tht.vectorstore.store import SyncStats, content_hash
|
||||
|
||||
vector_app = typer.Typer(help="Indice semantico pgvector (derivato, rigenerabile)")
|
||||
|
||||
@@ -69,6 +75,31 @@ def open_searcher(cfg):
|
||||
return AdapterSearcher()
|
||||
|
||||
|
||||
def sync_canonical_records(collection, records, *, store, embedder):
|
||||
kinds = sorted({record.kind for record in records})
|
||||
existing = store.existing_hashes(collection, kinds)
|
||||
pending = []
|
||||
stats = SyncStats()
|
||||
changed = []
|
||||
for record in records:
|
||||
hashed = content_hash(record.content)
|
||||
current = existing.get(record.id)
|
||||
if current == hashed:
|
||||
stats.unchanged += 1
|
||||
continue
|
||||
changed.append((record, hashed, current is None))
|
||||
if changed:
|
||||
embeddings = embedder.embed_documents([record.content for record, *_ in changed])
|
||||
for (record, hashed, is_added), embedding in zip(changed, embeddings, strict=True):
|
||||
pending.append(VectorWriteRecord(record=record, embedding=embedding, content_hash=hashed))
|
||||
if is_added:
|
||||
stats.added += 1
|
||||
else:
|
||||
stats.updated += 1
|
||||
store.upsert(collection, pending)
|
||||
return stats
|
||||
|
||||
|
||||
def _print_stats(stats) -> None:
|
||||
typer.secho(
|
||||
f"OK: {stats.added} nuovi, {stats.updated} aggiornati, "
|
||||
@@ -88,7 +119,6 @@ def init_cmd(
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from tht.vectorstore.embeddings import EmbeddingsError
|
||||
|
||||
from tht.vectorstore.reader import ALL_TABLES
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
@@ -131,8 +161,12 @@ def index_schema_cmd(config: Path = CONFIG_OPT) -> None:
|
||||
physical = PhysicalSchema.from_yaml(phys_file)
|
||||
annotations = Annotations.from_yaml(annotations_path(cfg))
|
||||
records = schema_records(physical, annotations)
|
||||
store = open_store(cfg, "schema_records")
|
||||
stats = store.sync(
|
||||
records, make_embedder(cfg.embeddings), kinds={"schema_table", "schema_column"}
|
||||
from tht.adapters.factory import build_vector_store
|
||||
|
||||
stats = sync_canonical_records(
|
||||
"schema_records",
|
||||
records,
|
||||
store=build_vector_store(cfg, require_write=True),
|
||||
embedder=make_embedder(cfg.embeddings),
|
||||
)
|
||||
_print_stats(stats)
|
||||
|
||||
+78
-1
@@ -152,8 +152,14 @@ class ThothVectorHttpConfig(BaseModel):
|
||||
direct: DatabaseConfig | None = None
|
||||
|
||||
|
||||
class QdrantConfig(BaseModel):
|
||||
type: Literal["qdrant"]
|
||||
base_url: str
|
||||
collection: str = Field(min_length=1)
|
||||
|
||||
|
||||
VectorResourceConfig = Annotated[
|
||||
PgvectorDirectConfig | ThothVectorHttpConfig,
|
||||
PgvectorDirectConfig | ThothVectorHttpConfig | QdrantConfig,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -397,6 +403,7 @@ def load_config(path: Path) -> Config:
|
||||
raise ConfigError(f"Configurazione non valida (atteso un mapping YAML): {path}")
|
||||
expanded = _resolve_secret_files(_expand_env(raw))
|
||||
_validate_internal_embedding_contract(expanded, path)
|
||||
_validate_internal_vector_contract(expanded, path)
|
||||
translated, used_legacy = translate_legacy_config(expanded)
|
||||
_populate_legacy_views(translated)
|
||||
try:
|
||||
@@ -455,6 +462,7 @@ def load_config(path: Path) -> Config:
|
||||
else path.resolve().as_posix()
|
||||
)
|
||||
_validate_active_embeddings_config(cfg.embeddings, path)
|
||||
_validate_active_vector_config(cfg.vectors, path)
|
||||
return cfg
|
||||
|
||||
|
||||
@@ -500,6 +508,42 @@ def _validate_internal_embedding_contract(raw: dict[str, Any], path: Path) -> No
|
||||
)
|
||||
|
||||
|
||||
def _validate_internal_vector_contract(raw: dict[str, Any], path: Path) -> None:
|
||||
resources = raw.get("resources")
|
||||
if not isinstance(resources, dict):
|
||||
return
|
||||
vector = resources.get("vector")
|
||||
if not isinstance(vector, dict):
|
||||
return
|
||||
|
||||
engine = vector.get("engine")
|
||||
base_url = vector.get("base_url")
|
||||
collection = vector.get("collection")
|
||||
allowed = {"engine", "base_url", "collection"}
|
||||
unexpected = sorted(set(vector) - allowed)
|
||||
if unexpected:
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}:\n"
|
||||
f"resources.vector non supporta: {', '.join(unexpected)}"
|
||||
)
|
||||
if engine != "qdrant":
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}:\n"
|
||||
"resources.vector.engine deve essere 'qdrant'"
|
||||
)
|
||||
if not isinstance(collection, str) or not collection:
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}:\n"
|
||||
"resources.vector.collection deve essere valorizzato"
|
||||
)
|
||||
if not _is_allowed_internal_qdrant_url(base_url):
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}:\n"
|
||||
"resources.vector.base_url deve usare http://qdrant:6333 "
|
||||
"oppure un endpoint loopback di sviluppo su porta 6333"
|
||||
)
|
||||
|
||||
|
||||
def _validate_active_embeddings_config(
|
||||
embeddings: "EmbeddingsConfig | None",
|
||||
path: Path,
|
||||
@@ -529,6 +573,20 @@ def _validate_active_embeddings_config(
|
||||
)
|
||||
|
||||
|
||||
def _validate_active_vector_config(
|
||||
vectors: "VectorResourceConfig | None",
|
||||
path: Path,
|
||||
) -> None:
|
||||
if vectors is None or vectors.type != "qdrant":
|
||||
return
|
||||
if not _is_allowed_internal_qdrant_url(vectors.base_url):
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}:\n"
|
||||
"vectors.base_url deve usare http://qdrant:6333 "
|
||||
"oppure un endpoint loopback di sviluppo su porta 6333"
|
||||
)
|
||||
|
||||
|
||||
def _is_allowed_internal_embedding_url(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
@@ -548,6 +606,25 @@ def _is_allowed_internal_embedding_url(value: Any) -> bool:
|
||||
return host.is_loopback
|
||||
|
||||
|
||||
def _is_allowed_internal_qdrant_url(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme != "http" or not parsed.hostname or parsed.port != 6333:
|
||||
return False
|
||||
if parsed.params or parsed.query or parsed.fragment:
|
||||
return False
|
||||
if parsed.path not in ("", "/"):
|
||||
return False
|
||||
if parsed.hostname == "qdrant":
|
||||
return True
|
||||
try:
|
||||
host = ip_address(parsed.hostname)
|
||||
except ValueError:
|
||||
return parsed.hostname == "localhost"
|
||||
return host.is_loopback
|
||||
|
||||
|
||||
def _populate_legacy_views(raw: dict[str, Any]) -> None:
|
||||
"""Populate old Config attributes for command compatibility during migration."""
|
||||
dwh = raw.get("dwh")
|
||||
|
||||
@@ -33,6 +33,14 @@ def translate_legacy_config(raw: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
translated["embeddings"] = embedding
|
||||
if "dimensions" in translated["embeddings"] and "dim" not in translated["embeddings"]:
|
||||
translated["embeddings"]["dim"] = translated["embeddings"].pop("dimensions")
|
||||
if isinstance(resources, dict) and "vector" in resources and "vectors" not in translated:
|
||||
vector = _as_mapping(resources.get("vector"))
|
||||
if isinstance(vector, dict):
|
||||
translated["vectors"] = {
|
||||
"type": "qdrant",
|
||||
"base_url": vector.get("base_url"),
|
||||
"collection": vector.get("collection"),
|
||||
}
|
||||
legacy = any(key in raw for key in _LEGACY_RESOURCE_KEYS)
|
||||
if not legacy:
|
||||
return translated, False
|
||||
|
||||
@@ -27,11 +27,18 @@ def qdrant_semantic_kind(kind: str) -> str:
|
||||
raise ValueError(f"Unsupported vector kind: {kind}")
|
||||
|
||||
|
||||
def qdrant_payload(record: VectorRecord, *, content_hash: str, workspace_id: str) -> dict:
|
||||
def qdrant_payload(
|
||||
record: VectorRecord,
|
||||
*,
|
||||
content_hash: str,
|
||||
workspace_id: str,
|
||||
workspace_revision: str | None = None,
|
||||
) -> dict:
|
||||
semantic_kind = qdrant_semantic_kind(record.kind)
|
||||
return {
|
||||
**record.metadata,
|
||||
"workspace_id": workspace_id,
|
||||
**({"workspace_revision": workspace_revision} if workspace_revision else {}),
|
||||
"kind": semantic_kind,
|
||||
"record_kind": record.kind,
|
||||
"record_key": record.id,
|
||||
|
||||
Reference in New Issue
Block a user