lllin000_PaperForge/paperforge/embedding/_chroma.py
LLLin000 06fec04651 feat: Phase 0, 1, 3a — legacy OCR fix, ChromaDB migration, search bar UI
Phase 0 (#35): _needs_derived_rebuild returns (False, 'legacy_ocr') for
papers without structured blocks and derived_version. _can_rebuild adds
has_structured check. No misleading rebuild suggestions for legacy users.

Phase 1 (#36): migrate_chroma_to_vec0() copies existing ChromaDB vectors
to vec0 tables with zero API calls. Idempotent (skips by paper_id).
delete_paper_vectors() cleans both ChromaDB and vec0. CLI: embed migrate.
5 new tests.

Phase 3a (#39): Search bar in collection panel — two-mode input
(M metadata / @ deep) with card results display. JSON output parsed
from search/retrieve CLI. 289 lines TypeScript, 175 lines CSS.
Build passes clean.

Co-authored-by: Phase0LegacyOcr <agent>
Co-authored-by: ChromaMigration <agent>
Co-authored-by: SearchBarUI <agent>
2026-07-10 00:02:02 +08:00

183 lines
6.2 KiB
Python

from __future__ import annotations
import json
import logging
from pathlib import Path
from paperforge.memory.db import ensure_vec_extension, get_connection, get_memory_db_path
from paperforge.memory.schema import ensure_schema
_VEC_TABLE_MAP = {
"paperforge_fulltext": ("vec_fulltext", "vec_fulltext_meta"),
"paperforge_body": ("vec_body", "vec_body_meta"),
"paperforge_objects": ("vec_objects", "vec_objects_meta"),
}
logger = logging.getLogger(__name__)
_COLLECTION_NAMES = ["paperforge_fulltext", "paperforge_body", "paperforge_objects"]
def get_vector_db_path(vault: Path) -> Path:
from paperforge.config import paperforge_paths
paths = paperforge_paths(vault)
return (paths.get("memory_db", paths.get("index", vault / "System" / "PaperForge"))).parent / "vectors"
def _get_chroma():
import chromadb
return chromadb
def get_collection(vault: Path, name: str = "paperforge_fulltext"):
chroma = _get_chroma()
db_path = get_vector_db_path(vault)
db_path.mkdir(parents=True, exist_ok=True)
client = chroma.PersistentClient(path=str(db_path))
return client.get_or_create_collection(
name=name,
metadata={"hnsw:space": "cosine"},
)
def _delete_from_chromadb(vault: Path, zotero_key: str) -> None:
"""Delete vectors for a paper from ChromaDB collections if it exists."""
try:
import chromadb
chroma_dir = get_vector_db_path(vault)
if not chroma_dir.exists():
return
client = chromadb.PersistentClient(path=str(chroma_dir))
for coll_name in _COLLECTION_NAMES:
try:
coll = client.get_collection(name=coll_name)
coll.delete(where={"paper_id": zotero_key})
except Exception:
pass
except ImportError:
pass
def delete_paper_vectors(vault: Path, zotero_key: str) -> int:
_delete_from_chromadb(vault, zotero_key)
db_path = get_memory_db_path(vault)
conn = get_connection(db_path)
ensure_vec_extension(conn)
ensure_schema(conn)
total = 0
for vec_table, meta_table in _VEC_TABLE_MAP.values():
rows = conn.execute(f"SELECT rowid FROM {meta_table} WHERE paper_id = ?", (zotero_key,)).fetchall()
rowids = [r["rowid"] for r in rows]
if rowids:
placeholders = ",".join("?" for _ in rowids)
conn.execute(f"DELETE FROM {vec_table} WHERE rowid IN ({placeholders})", rowids)
conn.execute(f"DELETE FROM {meta_table} WHERE paper_id = ?", (zotero_key,))
total += len(rowids)
conn.commit()
conn.close()
return total
def migrate_chroma_to_vec0(vault: Path) -> int:
"""Copy vectors from existing ChromaDB to vec0 tables.
Pure local copy — no API calls. Idempotent: skips papers already
present in vec0 meta tables. Returns count of vectors migrated.
"""
chroma_dir = get_vector_db_path(vault)
if not chroma_dir.exists():
return 0
try:
import chromadb # noqa: F811
except ImportError:
logger.info("chromadb not installed, cannot migrate")
return 0
try:
client = chromadb.PersistentClient(path=str(chroma_dir))
except Exception as exc:
logger.warning("failed to open ChromaDB at %s: %s", chroma_dir, exc)
return 0
db_path = get_memory_db_path(vault)
conn = get_connection(db_path)
ensure_vec_extension(conn)
ensure_schema(conn)
total = 0
for chroma_name, (vec_table, meta_table) in _VEC_TABLE_MAP.items():
try:
coll = client.get_collection(name=chroma_name)
except Exception:
logger.debug("ChromaDB collection %s not found, skipping", chroma_name)
continue
data = coll.get(include=["embeddings", "documents", "metadatas"])
ids = data.get("ids", [])
if not ids:
continue
embeddings = data["embeddings"] if data.get("embeddings") is not None else []
documents = data["documents"] if data.get("documents") is not None else []
metadatas = data["metadatas"] if data.get("metadatas") is not None else []
# Group by paper_id for idempotency check
entries_by_paper: dict[str, list[dict]] = {}
for i, doc_id in enumerate(ids):
meta = metadatas[i] if metadatas and i < len(metadatas) else {}
if isinstance(meta, dict):
paper_id = meta.get("paper_id", "")
else:
paper_id = ""
if not paper_id:
# Fallback: extract from ChromaDB id like "paperforge_fulltext:KEY_0"
parts = doc_id.split(":", 1)
if len(parts) > 1:
paper_id = parts[1].rsplit("_", 1)[0]
if not paper_id:
continue
if paper_id not in entries_by_paper:
entries_by_paper[paper_id] = []
entries_by_paper[paper_id].append(
{
"embedding": embeddings[i] if embeddings is not None and i < len(embeddings) else [],
"text": documents[i] if documents is not None and i < len(documents) else "",
"chunk_index": meta.get("chunk_index", i) if isinstance(meta, dict) else i,
"paper_id": paper_id,
}
)
for paper_id, entries in entries_by_paper.items():
existing = conn.execute(
f"SELECT 1 FROM {meta_table} WHERE paper_id = ? LIMIT 1", (paper_id,)
).fetchone()
if existing:
continue
for entry in entries:
emb = entry["embedding"]
if hasattr(emb, "tolist"):
emb = emb.tolist()
embedding_json = json.dumps(emb)
cur = conn.execute(f"INSERT INTO {vec_table}(embedding) VALUES (?)", [embedding_json])
rowid = cur.lastrowid
conn.execute(
f"INSERT INTO {meta_table}(rowid, paper_id, chunk_index, text) VALUES (?, ?, ?, ?)",
[rowid, entry["paper_id"], entry["chunk_index"], entry["text"]],
)
conn.commit()
total += len(entries)
conn.close()
return total