Проверка на живых данных вскрыла, что панель отладки врала в мою же пользу: показывала 154 046 «документов с эмбеддингом» (87%), тогда как в FAISS реально 93 053 вектора (52.5%). Колонка documents.faiss_id для этого непригодна — отметка остаётся после пересоздания индекса (смена модели: 768 → 1024) и после сбоев worker-gpu. Выборочная проверка: у 8 из 20 «отмеченных» вектора нет. - gpu.index_stats — новая задача, отдаёт реальное содержимое активного векторного бэкенда; API спрашивает её для панели отладки; - панель показывает «векторов в индексе» и отдельно предупреждает о ложных отметках (их 60 993), потому что такие документы молча выпадают из L3: в индексе их нет, а на пересчёт они не попадут — reembed ищет faiss_id IS NULL; - scripts/ops/faiss_reconcile.py — сверяет отметки с индексом и обнуляет ложные, после чего reembed_missing.py отправляет их на пересчёт. Проверен вживую (dry-run на проде: 93 053 в индексе, 60 993 ложных отметок). Документация: зафиксирована реальная глубина корпуса — полный текст только у 27.5% документов, у 79% меньше 500 отпечатков (уровень аннотации). Система ловит списывание из того, что есть целиком; это граница, а не поломка. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
4.9 KiB
Python
Executable File
102 lines
4.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Сверить отметки `documents.faiss_id` с реальным содержимым векторного индекса.
|
||
|
||
Проблема, которую он решает: отметка в базе НЕ означает, что вектор есть в
|
||
индексе. Она остаётся после пересоздания индекса (например, при смене модели
|
||
эмбеддингов и размерности) и после сбоев worker-gpu. Из-за этого документ
|
||
навсегда выпадает из L3: в индексе его нет, а на пересчёт он не попадёт —
|
||
`reembed_missing.py` ищет только `faiss_id IS NULL`.
|
||
|
||
Замер на проде 2026-08-28: в индексе 93 053 вектора, отмечено в базе 154 046 —
|
||
60 993 документа считались векторизованными, не будучи ими.
|
||
|
||
Скрипт обнуляет отметки у документов, которых в индексе нет. После него
|
||
`reembed_missing.py` отправит их на пересчёт.
|
||
|
||
Запуск (нужен доступ к самому индексу → контейнер worker-gpu):
|
||
cd /home/user/anti-plagiarism
|
||
C="docker compose -f docker-compose.prod.yml exec -T worker-gpu python -"
|
||
$C < scripts/ops/faiss_reconcile.py # dry-run, только показывает
|
||
$C --apply < scripts/ops/faiss_reconcile.py # обнулить ложные отметки
|
||
|
||
Работает только с FAISS (`VECTOR_BACKEND=faiss`): у Qdrant идентификаторы
|
||
хранит сам сервис, и сверка там делается иначе.
|
||
"""
|
||
|
||
import argparse
|
||
import sys
|
||
|
||
|
||
def stale_marks(marked_ids: set[int], indexed_ids: set[int]) -> set[int]:
|
||
"""Документы, отмеченные как векторизованные, но отсутствующие в индексе."""
|
||
return marked_ids - indexed_ids
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument("--apply", action="store_true", help="реально обнулить faiss_id (иначе dry-run)")
|
||
args = ap.parse_args()
|
||
|
||
from app.config import settings
|
||
|
||
if settings.VECTOR_BACKEND != "faiss":
|
||
sys.exit(f"VECTOR_BACKEND={settings.VECTOR_BACKEND}: скрипт рассчитан на faiss")
|
||
|
||
import faiss # noqa: F401 (нужен для vector_to_array)
|
||
from app.faiss_manager import FAISSManager
|
||
|
||
FAISSManager.load_or_create()
|
||
index = FAISSManager._index
|
||
if index is None or not hasattr(index, "id_map"):
|
||
sys.exit("индекс не загрузился или не хранит id (нет id_map)")
|
||
|
||
indexed = {int(i) for i in faiss.vector_to_array(index.id_map)}
|
||
print(f"векторов в индексе: {index.ntotal} (уникальных id: {len(indexed)})")
|
||
|
||
import psycopg2
|
||
|
||
conn = psycopg2.connect(
|
||
host=settings.POSTGRES_HOST, port=settings.POSTGRES_PORT,
|
||
dbname=settings.POSTGRES_DB, user=settings.POSTGRES_USER,
|
||
password=settings.POSTGRES_PASSWORD,
|
||
)
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT id FROM documents WHERE faiss_id IS NOT NULL")
|
||
marked = {r[0] for r in cur.fetchall()}
|
||
cur.execute("SELECT count(*) FROM documents")
|
||
total = cur.fetchone()[0]
|
||
|
||
stale = stale_marks(marked, indexed)
|
||
print(f"документов всего: {total}")
|
||
print(f"отмечено как векторизованные: {len(marked)}")
|
||
print(f"ложных отметок (нет в индексе): {len(stale)}")
|
||
print(f"реальное покрытие L3: {len(indexed & marked) * 100.0 / total:.1f}%")
|
||
|
||
if not stale:
|
||
print("Сверка чистая, делать нечего.")
|
||
conn.close()
|
||
return
|
||
|
||
if not args.apply:
|
||
print(f"\n[dry-run] первые id: {sorted(stale)[:10]}")
|
||
print("Запустите с --apply, чтобы обнулить faiss_id у этих документов,")
|
||
print("затем reembed_missing.py отправит их на пересчёт эмбеддингов.")
|
||
conn.close()
|
||
return
|
||
|
||
ids = list(stale)
|
||
with conn.cursor() as cur:
|
||
# Пачками: один UPDATE с десятками тысяч id упирается в лимиты параметров
|
||
for i in range(0, len(ids), 5000):
|
||
chunk = ids[i : i + 5000]
|
||
cur.execute("UPDATE documents SET faiss_id = NULL WHERE id = ANY(%s)", (chunk,))
|
||
conn.commit()
|
||
conn.close()
|
||
print(f"\nОбнулено отметок: {len(ids)}")
|
||
print("Дальше: reembed_missing.py --apply (отправит их на пересчёт).")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|