Skip to content

organizer duplicates: nota apagada volta como duplicata (lado B do par não é filtrado) #6

Description

@marcelovolei15

Problema

detect_duplicates() filtra deleted_at/arquivado apenas no lado A do par. O lado B vem do KNN em vec_notes, que guarda o vetor de notas já apagadas — e não passa por filtro nenhum.

Resultado: uma nota removida do vault continua aparecendo como candidata a duplicata. Como titles também filtra deleted_at IS NULL, ela sai com b_title: None.

Onde

skills/obsidian-organizer/scripts/duplicates.py:

  • _active_note_ids() (linha ~54) — filtra deleted_at IS NULL AND status != 'arquivado', e alimenta só o loop externo (for note_id in active).
  • _knn_top() (linha ~72) — SELECT note_id, distance FROM vec_notes WHERE embedding MATCH ?, sem restrição.
  • detect_duplicates() (linha ~139) — consome o vizinho direto, sem checar se ainda está ativo.

Como reproduzir

pairs = detect_duplicates(conn, min_cos=0.7)
apagada = pairs[0]["note_b_id"]
conn.execute("UPDATE notes SET deleted_at = ? WHERE id = ?", (agora, apagada))
conn.commit()

# esperado: nenhum par contendo `apagada`
# obtido:   o par continua, com b_title = None
detect_duplicates(conn, min_cos=0.7)

Em um vault real (121 notas, 3 apagadas com vetor), medido:

min_cos pares totais pares com nota apagada
0.7 1 0
0.6 111 8
0.5 794 20

Exemplo da saída: 79 None × 82 'N8N' 0.6627.

No limiar padrão o efeito depende de quão parecidas as notas apagadas são — pode não aparecer, e voltar depois.

Correção sugerida

Filtrar o vizinho no próprio detect_duplicates(), reaproveitando o conjunto que já é calculado:

active = _active_note_ids(conn)
active_set = set(active)
...
for neigh_id, dist in _knn_top(conn, note_id, vec, scan_k):
    if neigh_id not in active_set:
        continue
    ...

Preferi isso a mexer no SQL de _knn_top() por dois motivos: cobre deleted_at e status = 'arquivado' de graça (mesma regra de _active_note_ids), e evita join com a virtual table vec0.

Verificado no vault acima: 111 → 103 pares em min_cos=0.6, sendo a diferença exatamente os 8 pares fantasma. Nenhum par legítimo removido.

Teste de regressão

Falha no código atual, passa com o patch:

def test_detect_duplicates_ignora_nota_apagada_no_lado_b(dup, vault_com_duplicatas):
    _, conn = vault_com_duplicatas
    antes = dup.detect_duplicates(conn, min_cos=0.7)
    assert antes
    apagada = antes[0]["note_b_id"]
    conn.execute(
        "UPDATE notes SET deleted_at = '2026-07-29T00:00:00+00:00' WHERE id = ?",
        (apagada,),
    )
    conn.commit()
    depois = dup.detect_duplicates(conn, min_cos=0.7)
    assert all(apagada not in (p["note_a_id"], p["note_b_id"]) for p in depois)

Com a fixture vault_com_duplicatas já existente em tests/test_organizer_duplicates.py: 9 passed → 10 passed com o patch; o teste novo falha sem ele.

Contorno enquanto isso

rebuild-db --yes + scan limpa os vetores órfãos.


Abro PR com o patch e o teste, se for útil.

Ambiente: Windows 10, CPython 3.12.13, sqlite-vec 0.1.9, model2vec 0.8.2 (static-similarity-mrl-multilingual-v1, dim 256). Repo em e9b6392.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions