Files
NotesAgentic/backend/app/services/index_service.py
T
admin d703ab64e3
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s
docs: 将仓库代码注释统一为中文
2026-09-10 00:40:56 +08:00

321 lines
16 KiB
Python

"""索引服务:后台重建、快照校验与原子替换,不在模型计算期间锁住笔记编辑。"""
from __future__ import annotations
import asyncio
import logging
from app.operation_logs import log_event
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from app import repository
from app.config import get_settings
from app.contracts import IndexJob, IndexRebuildRequest, IndexStatus
from app.errors import ApiError
from app.knowledge.parser import parse_note
from app.services.note_service import index_note, prepare_note_index
from app.database.db import connect_knowledge as connect, transaction
from app.services.coordination import vault_mutation_lock
from app.retrieval.vectorstore import SqliteVecStore
from app.local_models.runtime import LocalEmbedding
from app.services import note_service
vector_store = SqliteVecStore()
_jobs: dict[str, IndexJob] = {}
_active_job_id: str | None = None
_active_scope: str | None = None
_last_completed_at: datetime | None = None
_last_error: str | None = None
MAX_JOBS = 100
_background_task: asyncio.Task | None = None
_logger = logging.getLogger(__name__)
def _remember_job(job: IndexJob) -> None:
log_event('vectors', 'index.' + job.status, job_id=job.job_id, status=job.status)
_jobs[job.job_id] = job
while len(_jobs) > MAX_JOBS:
oldest = next(iter(_jobs))
_jobs.pop(oldest, None)
def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
"""扫描 Vault 下所有 Markdown,返回 (rel_path, folder, markdown, created, updated)。
先读入内存:若文件读取失败,rebuild 尚未清空旧索引,不会造成数据损失。
"""
if get_settings().environment == 'desktop':
from app.services.desktop_projection import entries
from app.services import desktop_notes
result = []
for entry in entries():
note = desktop_notes.note_from_document(desktop_notes.call('read', file_id=entry['file_id']))
result.append((note.file_path, note.file_path.rpartition('/')[0], note.markdown, note.created_at, note.updated_at))
return result
vault = get_settings().vault_path.resolve()
result: list[tuple[str, str, str, datetime, datetime]] = []
if not vault.exists():
return result
for path in sorted(vault.rglob("*.md")):
resolved = path.resolve()
if not resolved.is_relative_to(vault):
continue
rel = path.relative_to(vault).as_posix()
folder = path.relative_to(vault).parent.as_posix()
if folder == ".":
folder = ""
stat = resolved.stat()
created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
updated = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
result.append((rel, folder, resolved.read_text(encoding="utf-8"), created, updated))
return result
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
global _active_job_id, _active_scope, _last_completed_at, _last_error
if _active_job_id is not None:
raise ApiError(409, "INDEX_BUSY", "索引正在后台计算,请稍后重试。")
job_id = "job_" + uuid4().hex[:12]
# 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建
if request.scope != "all" or request.note_ids:
raise ApiError(
400,
"UNSUPPORTED_SCOPE",
"only full rebuild (scope='all' with empty note_ids) is supported",
{"scope": request.scope, "note_ids": request.note_ids},
)
if get_settings().environment == 'desktop':
from app.services.desktop_projection import refresh
await refresh()
docs = _scan_vault()
record_ids = [entry.note_id for entry in repository.list_note_locations()] if get_settings().environment == 'desktop' else _pending_notes()
saved_records = {key: repository.get_note_record(key) for key in record_ids}
saved_paths = {record.file_path: record for record in saved_records.values() if record is not None}
_active_job_id = job_id
_active_scope = 'all'
_last_error = None
_remember_job(IndexJob(
job_id=job_id, status="running", scope=request.scope,
created_at=datetime.now(timezone.utc),
))
try:
prepared_notes = []
semantic_spaces = {}
for rel, folder, markdown, created, updated in docs:
parsed = parse_note(
markdown=markdown, file_path=rel, folder=folder, tags=None,
created_at=created, updated_at=updated,
)
if saved := saved_paths.get(rel):
parsed = parse_note(markdown=markdown, file_path=rel, folder=folder, tags=saved.tags,
created_at=saved.created_at, updated_at=saved.updated_at, note_id=saved.note_id)
parsed.title = saved.title
prepared = await prepare_note_index(parsed, strict=True) if isinstance(note_service.embedding, LocalEmbedding) else await prepare_note_index(parsed)
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks:
batch = prepared[1]
if batch is None:
raise ApiError(503, "EMBEDDING_UNAVAILABLE", "Embedding 未生成向量,重建已停止,原索引已保留。")
space = (batch.space_id, batch.dimensions)
policy = parsed.embedding_local_only
if policy in semantic_spaces and semantic_spaces[policy] != space:
raise ApiError(409, "EMBEDDING_SPACE_CHANGED", "重建期间 Embedding 模型发生切换,原索引已保留,请待模型服务稳定后重试。")
semantic_spaces[policy] = space
prepared_notes.append((parsed, prepared))
# 所有网络/模型都在事务之前等待。下面的具体 SQLite 方法尽管具有异步接口,但仍同步完成。
async with vault_mutation_lock():
current_ids = [entry.note_id for entry in repository.list_note_locations()] if get_settings().environment == 'desktop' else _pending_notes()
if _scan_vault() != docs or saved_records != {key: repository.get_note_record(key) for key in current_ids}:
raise ApiError(409, "INDEX_SNAPSHOT_CHANGED", "笔记在计算期间发生变化,稍后重新计算。")
conn = connect()
try:
with transaction(conn):
task_note_links = dict(conn.execute(
"SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL"
).fetchall())
media_links = conn.execute("SELECT job_id,revision,options_hash,note_id FROM media_notes").fetchall()
repository.clear_all(conn=conn)
await vector_store.clear(conn=conn)
for parsed, prepared in prepared_notes:
await index_note(parsed, prepared=prepared, conn=conn)
for policy, space in semantic_spaces.items():
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='routed_block_vectors'").fetchone()
missing = not exists or conn.execute(
"SELECT 1 FROM blocks b LEFT JOIN routed_block_vectors r "
"ON r.block_id=b.block_id AND r.space_id=? AND r.dimensions=? "
"WHERE b.embedding_local_only=? AND r.block_id IS NULL LIMIT 1", (*space, int(policy)),
).fetchone()
if missing:
raise ApiError(500, "SEMANTIC_INDEX_WRITE_FAILED", "向量索引写入失败,原索引已保留,请检查数据库和磁盘状态。")
for task_id, note_id in task_note_links.items():
conn.execute(
"UPDATE tasks SET note_id = ? WHERE task_id = ? "
"AND EXISTS (SELECT 1 FROM notes WHERE note_id = ?)",
(note_id, task_id, note_id),
)
for link in media_links:
conn.execute("INSERT OR IGNORE INTO media_notes SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM notes WHERE note_id=?)",
(*link, link["note_id"]))
repository.set_index_meta({"workspace_vectors_pending": "0"}, conn=conn)
finally:
conn.close()
except BaseException as exc:
log_event('vectors', 'index.failed', level='WARNING' if isinstance(exc, asyncio.CancelledError) else 'ERROR', error=exc, job_id=job_id)
_remember_job(IndexJob(
job_id=job_id, status="failed", scope=request.scope,
created_at=datetime.now(timezone.utc),
))
_last_error = str(exc)
raise
finally:
_active_job_id = None
_active_scope = None
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
_remember_job(job)
_last_completed_at = job.created_at
if _pending_notes():
schedule_workspace_rebuild()
return job
def get_status() -> IndexStatus:
from app.retrieval import activity
counts = repository.stats()
workspace_pending = repository.get_index_meta().get('workspace_vectors_pending') == '1'
notes_pending = len(_pending_notes())
vector_refresh_required = workspace_pending or bool(notes_pending)
running = int(_active_job_id is not None)
# 整个保管库重建是一项作业,而不是每个块/笔记一项作业。
pending = 1 if running and _active_scope == 'all' else (1 + running if workspace_pending else max(notes_pending, running))
activity_fields = dict(running_jobs=running, active_searches=activity.active,
completed_searches=activity.completed, failed_searches=activity.failed,
cancelled_searches=activity.cancelled)
if _active_job_id is not None:
return IndexStatus(**activity_fields, status="running", pending_jobs=pending, active_job_id=_active_job_id, vector_refresh_required=vector_refresh_required,
total_notes=counts["notes"], total_blocks=counts["blocks"])
return IndexStatus(
**activity_fields,
vector_refresh_required=vector_refresh_required,
total_notes=counts["notes"], total_blocks=counts["blocks"],
status="failed" if _last_error else "idle",
pending_jobs=pending,
last_completed_at=_last_completed_at,
error_message=_last_error,
)
def get_job(job_id: str) -> IndexJob | None:
return _jobs.get(job_id)
def schedule_workspace_rebuild() -> None:
"""单进程去重;任务失败保留待重建标记,重新打开 Vault 可重试。"""
global _background_task
if _background_task is not None and not _background_task.done():
return
if _active_job_id is not None:
return
async def run():
while True:
try:
if repository.get_index_meta().get('workspace_vectors_pending') == '1':
await rebuild(IndexRebuildRequest())
elif pending := _pending_notes():
await _refresh_saved_note(pending[0])
else:
return
except ApiError as exc:
if exc.code == 'INDEX_SNAPSHOT_CHANGED':
await asyncio.sleep(1)
continue
_logger.warning('Background index failed: %s', exc.code)
return
except Exception:
_logger.exception('Background index failed')
return
_background_task = asyncio.create_task(run(), name='workspace-vector-index')
async def shutdown() -> None:
global _background_task
if _background_task is not None:
_background_task.cancel()
await asyncio.gather(_background_task, return_exceptions=True)
_background_task = None
def _pending_notes() -> list[str]:
return [key.split(':', 1)[1] for key, value in repository.get_index_meta().items()
if key.startswith('note_vectors_pending:') and value == '1']
async def _refresh_saved_note(note_id: str) -> None:
global _active_job_id, _active_scope, _last_error, _last_completed_at
record = repository.get_note_record(note_id)
key = f'note_vectors_pending:{note_id}'
if record is None:
repository.set_index_meta({key: '0'})
return
markdown = note_service._read_markdown(record.file_path)
parsed = parse_note(markdown=markdown, file_path=record.file_path, folder=record.folder,
tags=record.tags, created_at=record.created_at,
updated_at=record.updated_at, note_id=note_id)
parsed.title = record.title
job_id = 'job_' + uuid4().hex[:12]
_active_job_id = job_id
_active_scope = 'note'
_last_error = None
_remember_job(IndexJob(job_id=job_id, status='running', scope='all', created_at=datetime.now(timezone.utc)))
try:
prepared = await prepare_note_index(parsed, strict=True)
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks and prepared[1] is None:
raise ApiError(503, "EMBEDDING_UNAVAILABLE", "笔记已保存,后台向量计算未完成。")
async with vault_mutation_lock():
current = repository.get_note_record(note_id)
if current != record or note_service._read_markdown(record.file_path) != markdown:
# 另一次保存或重命名已先完成;保留持久队列条目不变。
return
conn = connect()
try:
with transaction(conn):
existing_ids = {row[0] for row in conn.execute('SELECT block_id FROM blocks WHERE note_id=?', (note_id,))}
if existing_ids != {block.block_id for block in parsed.blocks}:
# 在推理运行时,外部编辑器更改了新注册的笔记。仅核对该笔记;上面的快照检查可以保护较新的保存。
parsed.title = parse_note(markdown=markdown, file_path=record.file_path,
folder=record.folder, tags=record.tags, created_at=record.created_at,
updated_at=record.updated_at, note_id=note_id).title
await index_note(parsed, prepared=prepared, conn=conn)
# 只写向量:元数据和 FTS 已经代表保存的修订。
vectors, remote = prepared
from app.retrieval.vectorstore import VectorRecord
from app.retrieval import routed_vectors
await vector_store.upsert([VectorRecord(id=b.block_id, vector=v)
for b, v in zip(parsed.blocks, vectors)], conn=conn)
routed_vectors.store_remote(conn, [b.block_id for b in parsed.blocks], remote)
if isinstance(note_service.embedding, LocalEmbedding) and parsed.blocks:
from app.retrieval.space_index import table_name
if remote is None:
raise ApiError(503, 'EMBEDDING_UNAVAILABLE', '笔记已保存,向量计算未完成。')
table = table_name(remote.space_id, remote.dimensions)
missing = conn.execute(f'SELECT 1 FROM blocks b LEFT JOIN {table} v ON v.block_id=b.block_id WHERE b.note_id=? AND v.block_id IS NULL LIMIT 1', (note_id,)).fetchone()
if missing:
raise ApiError(500, 'SEMANTIC_INDEX_WRITE_FAILED', '向量写入未完成,保留待处理标记。')
repository.set_index_meta({key: '0'}, conn=conn)
finally:
conn.close()
_last_completed_at = datetime.now(timezone.utc)
_remember_job(IndexJob(job_id=job_id, status='completed', scope='all', created_at=_last_completed_at))
except BaseException as exc:
log_event('vectors', 'index.failed', level='WARNING' if isinstance(exc, asyncio.CancelledError) else 'ERROR', error=exc, job_id=job_id)
_last_error = str(exc) or '后台向量计算已中断,笔记已保存。'
_remember_job(IndexJob(job_id=job_id, status='failed', scope='all', created_at=datetime.now(timezone.utc)))
raise
finally:
_active_job_id = None
_active_scope = None