Files
anti-plagiarism/scripts/ops/faiss_reconcile.py
jze9 d8630ca0f9 fix(admin): покрытие L3 мерить по индексу, а не по отметкам в базе
Проверка на живых данных вскрыла, что панель отладки врала в мою же пользу:
показывала 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>
2026-08-28 17:17:41 +05:00

102 lines
4.9 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()