"""RAG 管道:素材导入、增量重建与检索。""" import shutil import hashlib import uuid import logging import threading import time import os import re import unicodedata from collections import deque from concurrent.futures import ThreadPoolExecutor from pathlib import Path from sqlmodel import Session, select from sqlalchemy import text, or_ from ..config import KB_DIR from ..models import Campaign, KnowledgeBase, KnowledgeUsage, KnowledgeCategory, KnowledgeFile, now from ..billing import estimate_tokens, record_usage_event from . import backend, parse, chunk, embed, store, dedup from . import hybrid from .quarantine import file_exclusions from .context import source_excerpt from .. import perf_metrics logger = logging.getLogger("yawen.rag.pipeline") # Reuse a bounded set of recall workers across requests. Creating a fresh # executor for every multi-library query defeated thread-local SQLite/FTS # reuse and could create an unbounded number of threads under concurrent load. # The queue is intentionally bounded by the executor's fixed worker count; # callers still get the same per-request fan-out semantics. _recall_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="rag-recall") _PREWARM_LOCK = threading.Lock() _PREWARMING: set[int] = set() # A bounded, process-local rolling window keeps retrieval latency observable # without adding a database write to every query. The worker process logs a # compact P50/P95 summary periodically; callers still receive per-query phase # timings in ``retrieval_timings`` below. _RETRIEVAL_METRICS_LOCK = threading.Lock() _RETRIEVAL_METRICS: deque[dict[str, float | int | bool]] = deque(maxlen=512) _RETRIEVAL_METRICS_LOG_EVERY = 25 _LAST_RETRIEVAL_METRIC: dict | None = None def _record_query_embedding_usage(*, idempotency_key: str, user_id: int, organization_id: int, campaign_id: int, trace_id: str, model: str, quantity: float, metadata: dict) -> None: """Write embedding telemetry in its own short transaction. Retrieval is read-heavy and can fan out across several content tasks. A usage event flushed through the caller's long-lived transaction made those readers compete for SQLite's single writer and surfaced ``database is locked`` as a false retrieval failure. The event is accounting metadata; it must never make a successful retrieval fail. """ try: from ..db import engine with Session(engine) as usage_session: # Telemetry must never wait for the full 30 s application timeout # behind a content-task write transaction. If SQLite is busy, # skip this non-critical event quickly and keep retrieval usable. usage_session.connection().exec_driver_sql("PRAGMA busy_timeout=500") record_usage_event( usage_session, idempotency_key=idempotency_key, user_id=user_id, organization_id=organization_id, campaign_id=campaign_id, trace_id=trace_id, model=model, service_type="embedding", unit="token", quantity=quantity, status="estimated", metadata=metadata) usage_session.commit() except Exception as exc: # pragma: no cover - telemetry must be best effort logger.warning("Embedding 用量记录失败(不阻断知识库检索):%s", exc) def retrieval_metrics_snapshot() -> dict: """Return process-local retrieval latency percentiles for diagnostics. This is intentionally read-only and bounded. It is safe to expose from a diagnostics endpoint later, while keeping the search API unchanged. """ with _RETRIEVAL_METRICS_LOCK: rows = list(_RETRIEVAL_METRICS) if not rows: return {"samples": 0, "p50_ms": 0.0, "p95_ms": 0.0, "cache_hit_rate": 0.0, "phases": {}} def percentile(values: list[float], fraction: float) -> float: values = sorted(values) if not values: return 0.0 index = min(len(values) - 1, max(0, int(round((len(values) - 1) * fraction)))) return round(values[index], 2) total = [float(row.get("total_ms", 0.0)) for row in rows] phase_names = ("validation_ms", "embedding_ms", "recall_ms", "postprocess_ms") phases = {name: {"p50_ms": percentile([float(row.get(name, 0.0)) for row in rows], 0.5), "p95_ms": percentile([float(row.get(name, 0.0)) for row in rows], 0.95)} for name in phase_names} return {"samples": len(rows), "p50_ms": percentile(total, 0.5), "p95_ms": percentile(total, 0.95), "cache_hit_rate": round(sum(bool(row.get("cache_hit")) for row in rows) / len(rows), 4), "phases": phases} def _record_retrieval_metrics(timings: dict, result_count: int) -> None: """Record a bounded sample and emit a periodic percentile summary.""" global _RETRIEVAL_METRICS_LOG_EVERY, _LAST_RETRIEVAL_METRIC _LAST_RETRIEVAL_METRIC = {**timings, "result_count": int(result_count)} perf_metrics.ingest_rag(_LAST_RETRIEVAL_METRIC) with _RETRIEVAL_METRICS_LOCK: _RETRIEVAL_METRICS.append({**timings, "result_count": int(result_count)}) count = len(_RETRIEVAL_METRICS) should_log = count == 1 or count % _RETRIEVAL_METRICS_LOG_EVERY == 0 if should_log: summary = retrieval_metrics_snapshot() # ``rag_worker`` deliberately has no logging configuration because its # stdout is a JSON protocol. WARNING is handled by Python's fallback # logger, so the summary remains visible in rag-worker.log without # touching that protocol or requiring a global logging setup. logger.warning("RAG检索指标:样本=%s,P50=%.2fms,P95=%.2fms,缓存命中率=%.1f%%,阶段=%s", summary["samples"], summary["p50_ms"], summary["p95_ms"], summary["cache_hit_rate"] * 100, summary["phases"]) def last_retrieval_metric() -> dict: return dict(_LAST_RETRIEVAL_METRIC or {}) def _schedule_lexical_prewarm(kb_id: int, organization_id: int = 1) -> None: """Warm the default full-library FTS scope after a successful import. FTS construction can scan a large library, so it must never delay the upload response or the first user query. The hybrid module's file lock makes retries and multiple API workers safe. """ kid = int(kb_id) with _PREWARM_LOCK: if kid in _PREWARMING: return _PREWARMING.add(kid) def run() -> None: started = __import__("time").perf_counter() try: from . import hybrid hybrid.prewarm_index(int(organization_id or 1), kid) logger.info("知识库全文索引已后台预热:知识库 %s,%.3f 秒", kid, __import__("time").perf_counter() - started) except Exception as exc: # Vector import has already succeeded; a failed warmup only means # the next query will retry it under the normal index lock. logger.warning("知识库全文索引预热失败:知识库 %s,原因:%s", kid, exc) finally: with _PREWARM_LOCK: _PREWARMING.discard(kid) threading.Thread(target=run, name=f"rag-prewarm-{kid}", daemon=True).start() SUPPORTED_SUFFIXES = {".doc", ".docx", ".srt", ".md", ".txt", ".json", ".jsonl", ".pdf"} # 音视频后缀(whisper 本地转写) MEDIA_SUFFIXES = {".mp4", ".mov", ".m4a", ".mp3", ".wav", ".flv", ".mkv", ".avi", ".webm", ".aac", ".ogg", ".flac"} def _source_dir(kb: KnowledgeBase) -> Path: return Path(kb.source_dir) if kb.source_dir else KB_DIR / f"{kb.id}_{kb.name}" def _disk_files(source: Path) -> list[Path]: if not source.exists() or not source.is_dir(): return [] return sorted(path for path in source.iterdir() if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES | MEDIA_SUFFIXES) def reconcile_kb_files(session: Session, kb_id: int | None = None) -> dict: """启动时对账物理文件、文件表和向量计数,不自动重建大库。""" query = select(KnowledgeBase) if kb_id is not None: query = query.where(KnowledgeBase.id == kb_id) repaired = [] for kb in session.exec(query).all(): source = _source_dir(kb) files = _disk_files(source) known = {item.file_name: item for item in session.exec( select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id) ).all()} for path in files: digest = dedup.file_hash(path) record = known.get(path.name) if record is None: record = KnowledgeFile(kb_id=kb.id, file_name=path.name, content_hash=digest, revision=1) elif not record.content_hash: # 旧数据首次补指纹时不把未知历史误报为内容变更。 record.content_hash = digest record.revision = max(1, int(record.revision or 1)) elif record.content_hash != digest: record.content_hash = digest record.revision = max(1, int(record.revision or 1)) + 1 session.add(record) try: chunk_count = store.count(kb.id) except Exception: chunk_count = 0 registered_count = len(known) or len(session.exec( select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id) ).all()) kb.file_count = len(files) kb.chunk_count = chunk_count if backend.is_disabled(): kb.index_status = "disabled" if files else "empty" elif files and chunk_count == 0: kb.index_status = "needs_reindex" elif not files and registered_count: kb.index_status = "error" elif not files: kb.index_status = "empty" else: kb.index_status = "ready" session.add(kb) repaired.append({"kb_id": kb.id, "files": len(files), "chunks": chunk_count, "needs_reindex": bool(files and chunk_count == 0)}) session.commit() return {"kbs": repaired} def category_path(session: Session, category_id: int | None) -> str: """把分类节点解析成“父级 / 子级”路径,写入每个向量的元数据。""" if not category_id: return "未分类" names = [] seen = set() current = session.get(KnowledgeCategory, category_id) while current and current.id not in seen: seen.add(current.id) names.append(current.name) current = session.get(KnowledgeCategory, current.parent_id) if current.parent_id else None return " / ".join(reversed(names)) or "未分类" def _parse_source(path: Path, kb: KnowledgeBase, progress_cb=None) -> tuple[list[dict], int]: if path.suffix.lower() in MEDIA_SUFFIXES: from . import media as media_mod if progress_cb: progress_cb(f"转写 {path.name}(本地 whisper,长音频需几分钟)", -1) return media_mod.transcribe_media(path), 2200 configured = int(getattr(kb, "chunk_target", 0) or 0) target = configured or (2200 if path.suffix.lower() == ".srt" else chunk.TARGET) return parse.parse_file(path), target def _indexed_sources(session: Session, kb: KnowledgeBase, source: Path, cache) -> list[dict]: """只将原文件和索引版本一致、且实际有块的资料作为跳过上传的依据。""" counts = store.count_by_file(kb.id) if not counts: return [] first_meta, legacy_texts, versions = {}, {}, {} for item in store.iter_chunks(kb.id): meta = item.get("meta") or {} name = meta.get("file") if not name: continue first_meta.setdefault(name, meta) versions.setdefault(name, set()).add(str(meta.get("file_hash") or "")) if not meta.get("file_hash") or not meta.get("file_chunk_count"): legacy_texts.setdefault(name, []).append( (int(meta.get("seq", len(legacy_texts.get(name, [])))), (dedup.normalized_text(meta.get("heading", "")), dedup.normalized_text(item.get("text", ""))))) candidates = [] for record in session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id)).all(): path = source / record.file_name if not counts.get(record.file_name) or not path.is_file(): continue raw_hash = dedup.file_hash(path) meta = first_meta.get(record.file_name, {}) if meta.get("file_hash") and versions.get(record.file_name) != {raw_hash}: continue if meta.get("file_chunk_count") and int(meta["file_chunk_count"]) != counts.get(record.file_name): continue body_hash = cache.get(raw_hash) if not meta.get("file_hash") or not meta.get("file_chunk_count"): # 没有版本指纹的旧索引必须与当前原文逐块核对,不能只看文件表。 if path.suffix.lower() in MEDIA_SUFFIXES: continue units, target = _parse_source(path, kb) raw_chunks = chunk.chunk_texts(units, target=target) unique, _ = dedup.unique_chunks(raw_chunks) def canonical(items): return [(dedup.normalized_text((item.get("meta") or {}).get("heading", "")), dedup.normalized_text(item["text"])) for item in items] expected = canonical(raw_chunks) indexed = [text for _, text in sorted(legacy_texts.get(record.file_name, []))] if expected != indexed and canonical(unique) != indexed: continue body_hash = dedup.document_hash(units) cache.set(raw_hash, body_hash) candidates.append({"file_name": record.file_name, "raw_hash": raw_hash, "body_hash": body_hash, "path": path}) return sorted(candidates, key=lambda item: item["file_name"]) def _duplicate_of(candidates: list[dict], raw_hash: str, body_hash: str = "") -> str: for candidate in candidates: if candidate["raw_hash"] == raw_hash or (body_hash and candidate["body_hash"] == body_hash): return candidate["file_name"] return "" def _fill_body_hashes(candidates: list[dict], kb: KnowledgeBase, cache): for candidate in candidates: if candidate["body_hash"]: continue # 相同音视频先按文件字节去重;不同媒体不为去重而重复转写。 if candidate["path"].suffix.lower() in MEDIA_SUFFIXES: continue units, _ = _parse_source(candidate["path"], kb) candidate["body_hash"] = dedup.document_hash(units) cache.set(candidate["raw_hash"], candidate["body_hash"]) def _store_usage(session, kb, path, file_hash, revision, chunks, operation): record_usage_event( session, idempotency_key=f"storage:kb:{kb.id}:{file_hash}", user_id=kb.owner_user_id, organization_id=kb.organization_id, metadata={"kb_id": kb.id, "file_name": path.name, "kind": "knowledge_file"}, service_type="storage", unit="byte", quantity=path.stat().st_size, status="measured") record_usage_event( session, idempotency_key=f"embedding:index:{kb.id}:{file_hash}:{revision}:{operation}", user_id=kb.owner_user_id, organization_id=kb.organization_id, model=getattr(embed, "MODEL_NAME", "local-embedding"), service_type="embedding", unit="token", quantity=sum(estimate_tokens(item["text"]) for item in chunks), status="estimated", metadata={"kb_id": kb.id, "file_name": path.name, "chunk_count": len(chunks), "operation": operation}) def _import_kb_files_locked(session: Session, kb_id: int, file_paths: list[str], progress_cb=None, *, operation="import", candidates=None) -> dict: kb = session.get(KnowledgeBase, kb_id) if not kb: raise ValueError("知识库不存在") source = _source_dir(kb) source.mkdir(parents=True, exist_ok=True) if backend.is_disabled(): # Lightweight mode: preserve source files and registry rows, but never # download/load embedding models or build a local vector index. imported = [] for fp in file_paths: incoming = Path(fp) if not incoming.is_file() or incoming.suffix.lower() not in SUPPORTED_SUFFIXES | MEDIA_SUFFIXES: continue dest = source / incoming.name if incoming.resolve() != dest.resolve(): shutil.copy2(incoming, dest) digest = dedup.file_hash(dest) record = session.exec(select(KnowledgeFile).where( KnowledgeFile.kb_id == kb_id, KnowledgeFile.file_name == dest.name)).first() if record is None: record = KnowledgeFile(kb_id=kb_id, file_name=dest.name) record.content_hash = digest record.revision = max(1, int(record.revision or 1)) session.add(record) imported.append(dest.name) kb.file_count = len(_disk_files(source)) kb.source_dir = str(source) kb.chunk_count = 0 kb.index_status = "disabled" session.add(kb) session.commit() return {"files": kb.file_count, "chunks": 0, "imported_files": len(imported), "imported_file_names": imported, "skipped_duplicates": 0, "duplicate_files": [], "removed_duplicate_chunks": 0, "vector_disabled": True} cache = dedup.FingerprintCache(kb_id) if candidates is None: candidates = _indexed_sources(session, kb, source, cache) previous_status = kb.index_status kb.source_dir = str(source) kb.index_status = "indexing" session.add(kb) session.commit() result = {"files": 0, "chunks": 0, "imported_files": 0, "imported_file_names": [], "skipped_duplicates": 0, "duplicate_files": [], "removed_duplicate_chunks": 0} try: for fp in file_paths: incoming = Path(fp) if not incoming.is_file() or incoming.suffix.lower() not in SUPPORTED_SUFFIXES | MEDIA_SUFFIXES: continue file_name, dest = incoming.name, source / incoming.name if progress_cb: progress_cb(f"检查 {file_name} 是否重复", -1) raw_hash = dedup.file_hash(incoming) duplicate = _duplicate_of(candidates, raw_hash) units, target, body_hash = None, 0, "" if not duplicate: units, target = _parse_source(incoming, kb, progress_cb) body_hash = dedup.document_hash(units) _fill_body_hashes(candidates, kb, cache) duplicate = _duplicate_of(candidates, raw_hash, body_hash) if duplicate: result["skipped_duplicates"] += 1 result["duplicate_files"].append({"file_name": file_name, "duplicate_of": duplicate}) if progress_cb: progress_cb(f"{file_name}:与 {duplicate} 内容重复,已跳过", 0) continue raw_chunks = chunk.chunk_texts(units, target=target) chunks, removed = dedup.unique_chunks(raw_chunks) if not chunks: raise ValueError(f"{file_name} 未解析出可索引正文,已保留原文件与原索引;扫描件请先 OCR,旧版 doc 请转换为 docx") record = session.exec(select(KnowledgeFile).where( KnowledgeFile.kb_id == kb_id, KnowledgeFile.file_name == file_name)).first() revision = max(1, int(record.revision or 1)) if record else 1 if record and record.content_hash != raw_hash: revision += 1 for item in chunks: item["meta"].update(file_hash=raw_hash, file_revision=str(revision), document_hash=body_hash, dedup_version=1, file_chunk_count=len(chunks)) if progress_cb: progress_cb(f"{file_name}:去重后 {len(chunks)} 个片段,正在建立索引" + (f"(跳过 {removed} 个重复片段)" if removed else ""), -1) # 完成解析及嵌入后才替换索引/源文件,失败不会先破坏用户旧文件。 vectors = embed.embed_texts([item["text"] for item in chunks]) stage = source / f".{uuid.uuid4().hex}.upload" backup = source / f".{uuid.uuid4().hex}.previous" copying = incoming.resolve() != dest.resolve() snapshot = None index_written = source_replaced = source_backed_up = False try: if copying: shutil.copy2(incoming, stage) if dedup.file_hash(stage) != raw_hash: raise RuntimeError("上传文件在处理过程中发生变化,请重试") snapshot = store.snapshot_file(kb_id, file_name) store.replace_file_chunks(kb_id, chunks, vectors, file_name, category_path(session, (record.category_id or record.ai_category_id) if record else None)) index_written = True if copying: if dest.exists(): dest.replace(backup) source_backed_up = True stage.replace(dest) source_replaced = True if record is None: record = KnowledgeFile(kb_id=kb_id, file_name=file_name) record.content_hash, record.revision = raw_hash, revision session.add(record) session.commit() except Exception as exc: session.rollback() rollback_errors = [] if index_written and snapshot is not None: try: store.restore_file(kb_id, file_name, snapshot) except Exception as restore_exc: rollback_errors.append(f"索引恢复失败:{restore_exc}") if copying: try: if source_replaced: dest.unlink(missing_ok=True) if source_backed_up: backup.replace(dest) except OSError as restore_exc: rollback_errors.append(f"源文件恢复失败(备份 {backup.name}):{restore_exc}") if rollback_errors: raise RuntimeError(f"导入失败:{exc};{';'.join(rollback_errors)}") from exc raise finally: stage.unlink(missing_ok=True) backup.unlink(missing_ok=True) _store_usage(session, kb, dest, raw_hash, revision, chunks, operation) result["imported_files"] += 1 result["imported_file_names"].append(file_name) result["chunks"] += len(chunks) result["removed_duplicate_chunks"] += removed candidates[:] = [candidate for candidate in candidates if candidate["file_name"] != file_name] candidates.append({"file_name": file_name, "raw_hash": raw_hash, "body_hash": body_hash, "path": dest}) cache.set(raw_hash, body_hash) if progress_cb: progress_cb(f"{file_name}:已导入 {len(chunks)} 个片段" + (f",去重 {removed} 个片段" if removed else ""), len(chunks)) kb.file_count = len(_disk_files(source)) kb.chunk_count = store.count(kb_id) kb.index_status = "ready" if kb.chunk_count else "empty" session.add(kb) session.commit() cache.save() result["files"] = kb.file_count _schedule_lexical_prewarm(kb.id, int(getattr(kb, "organization_id", 1) or 1)) return result except Exception: session.rollback() kb = session.get(KnowledgeBase, kb_id) kb.file_count = len(_disk_files(source)) kb.chunk_count = store.count(kb_id) kb.index_status = previous_status if kb.chunk_count else "error" session.add(kb) session.commit() cache.save() raise def import_kb_files(session: Session, kb_id: int, file_paths: list[str], progress_cb=None) -> dict: """同库文件/正文精确去重,再安全增量导入;改名副本无需再次嵌入。""" with dedup.library_lock(kb_id): result = _import_kb_files_locked(session, kb_id, file_paths, progress_cb) # The upload transaction is committed before this call. Build the full # lexical index asynchronously so the first user search is not the one # paying the index-construction cost. kb = session.get(KnowledgeBase, int(kb_id)) if kb and kb.index_status == "ready" and kb.chunk_count: hybrid.schedule_prewarm(kb.organization_id, kb.id) # 新资料入库后后台自动做一次主题无关的全文阅读,写入可复用缓存。 # 任务独立于上传请求,模型不可用或阅读失败都不会影响索引结果。 imported = result.get("imported_file_names") or [] if imported: try: from ..training_reading import schedule_auto_read schedule_auto_read(session, kb.id, imported) except Exception: pass return result def rebuild_kb(session: Session, kb_id: int, progress_cb=None) -> dict: with dedup.library_lock(kb_id): result = _rebuild_kb_locked(session, kb_id, progress_cb) kb = session.get(KnowledgeBase, int(kb_id)) if kb and kb.index_status == "ready" and kb.chunk_count: hybrid.schedule_prewarm(kb.organization_id, kb.id) # A rebuild may discover new or changed files outside the regular upload # route. Queue the same topic independent reading pass so later training # map assessments can reuse the saved summaries immediately. imported = result.get("imported_file_names") or [] if imported: try: from ..training_reading import schedule_auto_read schedule_auto_read(session, kb.id, imported) except Exception: # Index rebuild succeeded; an unavailable model must not turn it # into a failed request. The next upload/rebuild can retry reading. pass return result def prewarm_ready_indexes(session: Session) -> int: """Queue lexical indexes and (when enabled) the initial generic reading pass. The initial AI pass is opt-in at process level because an existing large installation can contain many hours of material. Uploads and rebuilds always schedule their changed files independently below. Failed or interrupted passes are recovered on startup regardless of that opt-in. """ queued = 0 bootstrap = os.environ.get("YAWAN_AUTO_READ_EXISTING", "").strip().lower() in {"1", "true", "yes", "on"} from ..training_reading import read_status, schedule_auto_read, auto_read_paused, _write_auto_status now_epoch = time.time() for kb in session.exec(select(KnowledgeBase).where( KnowledgeBase.index_status == "ready", KnowledgeBase.chunk_count > 0)).all(): if hybrid.schedule_prewarm(kb.organization_id, kb.id): queued += 1 # Existing installations opt in to the initial full pass. A failed # pass or a worker interrupted by process restart is always recovered, # even when the opt-in is absent, so a stale status cannot strand a # library forever. ``updated_epoch`` is written atomically by the # reader and a generous timeout avoids duplicating a legitimately long # batch still running in another process. status = read_status(kb) if auto_read_paused() and status.get("status") in {"reading", "queued"}: _write_auto_status(kb.organization_id or 1, kb.id, {**status, "status": "paused", "error": "管理员已暂停自动预读"}) continue try: updated = float(status.get("updated_epoch") or 0) except (TypeError, ValueError): updated = 0 stale = (status.get("status") == "reading" and now_epoch - updated > 900) retry = status.get("status") in {"error", "queued"} or stale if bootstrap or retry: try: names = [item.file_name for item in session.exec( select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id) ).all() if item.file_name] if names: schedule_auto_read(session, kb.id, names) except Exception: # A model or cache problem must never prevent the API from # starting; the next rebuild/upload can retry the reading pass. pass return queued def _rebuild_kb_locked(session: Session, kb_id: int, progress_cb=None) -> dict: kb = session.get(KnowledgeBase, kb_id) if not kb: raise ValueError("知识库不存在") source = _source_dir(kb) previous = {"file_count": kb.file_count, "chunk_count": kb.chunk_count, "index_status": kb.index_status} previous_records = {record.file_name: (record.content_hash, record.revision) for record in session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kb_id)).all()} try: snapshot = store.snapshot_kb(kb_id) except Exception as exc: if int(kb.chunk_count or 0) > 0: raise RuntimeError(f"无法创建索引快照,已取消重建以保护原数据:{str(exc)[:300]}") from exc snapshot = {"ids": [], "documents": [], "embeddings": [], "metadatas": []} # 源目录丢失属于异常,不能把它解释成用户要删除全部索引。 if not source.exists() and int(kb.chunk_count or 0) > 0: raise ValueError("知识库源目录不存在,已取消重建并保留原索引") files = [str(path) for path in _disk_files(source)] try: # 保留旧索引直到对应文件解析/嵌入完成;重建也应用同库文件去重。 result = _import_kb_files_locked(session, kb_id, files, progress_cb, operation="rebuild", candidates=[]) canonical = set(result["imported_file_names"]) previous_names = {meta.get("file") for meta in snapshot.get("metadatas", []) if meta} for file_name in previous_names - canonical: if file_name: store.delete_file(kb_id, file_name) kb.file_count = len(_disk_files(source)) kb.chunk_count = store.count(kb_id) kb.index_status = "ready" if kb.chunk_count else "empty" session.add(kb) session.commit() return {**result, "files": kb.file_count, "rolled_back": False} except Exception as exc: session.rollback() restore_error = "" try: store.restore_kb(kb_id, snapshot) for record in session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kb_id)).all(): if record.file_name in previous_records: record.content_hash, record.revision = previous_records[record.file_name] session.add(record) else: session.delete(record) kb.file_count = previous["file_count"] kb.chunk_count = previous["chunk_count"] kb.index_status = previous["index_status"] except Exception as restore_exc: kb.index_status = "error" restore_error = f";回滚失败:{str(restore_exc)[:180]}" session.add(kb) session.commit() raise RuntimeError(f"知识库重建失败,已尝试恢复原索引:{str(exc)[:300]}{restore_error}") from exc def consumed_chunk_ids(session: Session, agent_id: int, kb_ids: list[int]) -> dict[int, set[str]]: """返回仅对当前 Agent 不可再次使用的知识片段。""" if not agent_id or not kb_ids: return {} usages = session.exec(select(KnowledgeUsage).where( KnowledgeUsage.agent_id == agent_id, or_(KnowledgeUsage.status.in_(["reserved", "consumed"]), KnowledgeUsage.banned == True), # noqa: E712 )).all() result: dict[int, set[str]] = {kid: set() for kid in kb_ids} for usage in usages: if usage.kb_id in result: result[usage.kb_id].add(usage.chunk_id) return result def reserve_chunks(session: Session, agent_id: int, campaign_id: int, segment_id: int, hits: list[dict]) -> list[dict]: """模型调用前原子预留;正文落盘成功后才转为正式 consumed。""" # 预取候选状态并使用 executemany,避免每个 chunk 两次往返 SQLite。 # 这条路径通常一次处理几十个片段,批量写入可显著减少写锁持有时间。 unique_hits, seen = [], set() for hit in hits: kb_id = int(hit.get("kb_id") or 0) chunk_id = str(hit.get("id") or "") if not kb_id or not chunk_id: continue key = (kb_id, chunk_id) if key in seen: continue seen.add(key) unique_hits.append(hit) if not unique_hits or not agent_id: return [] kb_ids = sorted({int(hit.get("kb_id") or 0) for hit in unique_hits}) existing = session.exec(select(KnowledgeUsage).where( KnowledgeUsage.agent_id == int(agent_id), KnowledgeUsage.kb_id.in_(kb_ids), KnowledgeUsage.chunk_id.in_([str(hit.get("id") or "") for hit in unique_hits]) )).all() state = {(int(row.kb_id), str(row.chunk_id)): row for row in existing} inserts, reopens, candidates = [], [], [] for hit in unique_hits: kb_id = int(hit.get("kb_id") or 0) chunk_id = str(hit.get("id") or "") values = { "agent_id": agent_id, "kb_id": kb_id, "chunk_id": chunk_id, "file_name": str(hit.get("meta", {}).get("file") or ""), "heading": str(hit.get("meta", {}).get("heading") or ""), "excerpt": str(hit.get("text") or "")[:1000], "campaign_id": campaign_id, "segment_id": segment_id, "used_at": now(), } row = state.get((kb_id, chunk_id)) if row is None: inserts.append(values); candidates.append(hit) elif str(row.status) == "available" and not bool(row.banned): reopens.append(values); candidates.append(hit) if inserts: session.execute(text(""" INSERT OR IGNORE INTO knowledgeusage (agent_id, kb_id, chunk_id, file_name, heading, excerpt, campaign_id, segment_id, status, used_at, released_at, banned) VALUES (:agent_id, :kb_id, :chunk_id, :file_name, :heading, :excerpt, :campaign_id, :segment_id, 'reserved', :used_at, '', 0) """), inserts) if reopens: session.execute(text(""" UPDATE knowledgeusage SET file_name = :file_name, heading = :heading, excerpt = :excerpt, campaign_id = :campaign_id, segment_id = :segment_id, status = 'reserved', used_at = :used_at, released_at = '' WHERE agent_id = :agent_id AND kb_id = :kb_id AND chunk_id = :chunk_id AND status = 'available' AND banned = 0 """), reopens) # 并发请求可能在预取后先认领了同一片段;以最终状态确认本事务实际 # 拿到的键,避免把 INSERT OR IGNORE 的冲突行误报为已预留。 claimed_keys = set() if candidates: rows = session.exec(select(KnowledgeUsage).where( KnowledgeUsage.agent_id == int(agent_id), KnowledgeUsage.kb_id.in_(kb_ids), KnowledgeUsage.chunk_id.in_([str(hit.get("id") or "") for hit in candidates]), KnowledgeUsage.status == "reserved", KnowledgeUsage.campaign_id == int(campaign_id), KnowledgeUsage.segment_id == int(segment_id), )).all() claimed_keys = {(int(row.kb_id), str(row.chunk_id)) for row in rows} session.commit() return [hit for hit in candidates if (int(hit.get("kb_id") or 0), str(hit.get("id") or "")) in claimed_keys] def consume_reserved_chunks(session: Session, agent_id: int, campaign_id: int, segment_id: int, hits: list[dict]) -> None: """只有正文和锚点成功写入后才视为当前 Agent 已消费。""" params = [{"agent_id": agent_id, "kb_id": int(hit.get("kb_id") or 0), "chunk_id": str(hit.get("id") or ""), "campaign_id": campaign_id, "segment_id": segment_id} for hit in hits if hit.get("kb_id") and hit.get("id")] if params: session.execute(text(""" UPDATE knowledgeusage SET status = 'consumed' WHERE agent_id = :agent_id AND kb_id = :kb_id AND chunk_id = :chunk_id AND campaign_id = :campaign_id AND segment_id = :segment_id AND status = 'reserved' """), params) session.commit() def release_reserved_chunks(session: Session, agent_id: int, campaign_id: int, segment_id: int, hits: list[dict]) -> None: """失败、重试或锚点不合格时归还未落盘素材。""" params = [{"agent_id": agent_id, "kb_id": int(hit.get("kb_id") or 0), "chunk_id": str(hit.get("id") or ""), "campaign_id": campaign_id, "segment_id": segment_id, "released_at": now()} for hit in hits if hit.get("kb_id") and hit.get("id")] if params: session.execute(text(""" UPDATE knowledgeusage SET status = 'available', released_at = :released_at WHERE agent_id = :agent_id AND kb_id = :kb_id AND chunk_id = :chunk_id AND campaign_id = :campaign_id AND segment_id = :segment_id AND status = 'reserved' """), params) session.commit() def search_kbs_remote(session: Session, kb_ids: list[int], query: str, k: int = 6, excluded_chunk_ids: dict[int, set[str]] | None = None, *, campaign_id: int = 0, trace_id: str = "", user_id: int = 0, organization_id: int = 1, category_ids: list[int] | None = None, file_whitelists: dict[int, set[str]] | None = None) -> list[dict]: """有界进程池检索;分类和调用方文件范围在召回前按知识库取交集。""" kids = list(dict.fromkeys(int(kid) for kid in kb_ids)) if not kids or not str(query or '').strip() or k <= 0: return [] if backend.is_disabled(): return _lightweight_keyword_search( session, kids, query, k=k, excluded_chunk_ids=excluded_chunk_ids, campaign_id=campaign_id, trace_id=trace_id, user_id=user_id, organization_id=organization_id, category_ids=category_ids, file_whitelists=file_whitelists, validate_access=True) whitelists = {int(kid): set(names) for kid, names in (file_whitelists or {}).items()} if category_ids: for kid in kids: allowed = _category_file_whitelist(session, kid, list(category_ids)) if allowed is not None: whitelists[kid] = allowed & whitelists[kid] if kid in whitelists else allowed # 显式空集合表示没有获选文件,绝不能当作“不限范围”。 eligible = [kid for kid in kids if kid not in whitelists or whitelists[kid]] if not eligible: return [] # Warm missing full-library FTS indexes from the API process. The search # worker remains focused on the bounded vector query and is not burdened by # a long background SQLite build that could be killed with the worker. for kid in eligible: try: rev = hybrid.revision(kid) if not hybrid.index_available(organization_id, kid, rev): hybrid.schedule_prewarm(organization_id, kid, rev) except Exception: # Prewarming is best effort; vector recall below remains available. pass hits = _search_kbs_worker(session, eligible, query, k, excluded_chunk_ids, campaign_id, trace_id, user_id, organization_id, file_whitelists=whitelists) # 最后仅遍历一次防御校验,不因选择多个库而重复或扩大结果。 results, seen = [], set() for hit in hits: kid = int(hit.get('kb_id') or 0) if kid not in eligible: continue meta = hit.get('meta') or {} if kid in whitelists and meta.get('file') not in whitelists[kid]: continue chunk_id = hit.get('id') identity = (kid, str(chunk_id)) if chunk_id else (kid, meta.get('file'), meta.get('seq'), hit.get('text')) if identity not in seen: seen.add(identity) results.append(hit) return results def _search_kbs_worker(session: Session, kb_ids: list[int], query: str, k: int = 6, excluded_chunk_ids: dict[int, set[str]] | None = None, campaign_id: int = 0, trace_id: str = "", user_id: int = 0, organization_id: int = 1, file_whitelists: dict[int, set[str]] | None = None) -> list[dict]: from .worker_pool import get_pool, RagWorkerError payload = { 'kb_ids': [int(kid) for kid in kb_ids], 'query': query, 'k': k, 'excluded': {str(kid): sorted(ids) for kid, ids in (excluded_chunk_ids or {}).items()}, 'campaign_id': int(campaign_id or 0), 'trace_id': trace_id or '', 'user_id': int(user_id or 0), 'organization_id': int(organization_id or 1), 'file_whitelists': {str(kid): sorted(names) for kid, names in (file_whitelists or {}).items()}, } try: response = get_pool().request(payload) except RagWorkerError as exc: metric = getattr(exc, "retrieval_metric", None) if metric: perf_metrics.ingest_rag(metric) from .. import system_log import logging message = f'知识库检索失败 · 知识库:{kb_ids} · 原因:{system_log.redact(exc, 500)}' try: # 独立短事务记录错误,不提交调用方尚未完成的业务变更。 with Session(session.get_bind()) as log_session: if log_session.get_bind().dialect.name == 'sqlite': log_session.connection().exec_driver_sql('PRAGMA busy_timeout=100') system_log.write(log_session, source='rag', message=message, trace_id=trace_id, status='error') except Exception: logging.getLogger('yawen.rag').error(message) # 不回退主进程执行:避免超时后继续无界计算并重复计量。 raise timings = {**response.get('worker_timings', {}), **response.get('transport_timings', {})} if response.get("retrieval_metric"): metric = dict(response["retrieval_metric"]) # Transport diagnostics are measured in the API process and are kept # alongside worker phase metrics for cold start/IPC analysis. # ``worker_ms`` includes the complete child search (embedding, vector # recall and post-processing). Using it as IPC time made the # diagnostics wildly overstate transport overhead. The worker pool # computes the residual pipe/JSON time explicitly as ``ipc_ms``; # retain a zero fallback for old workers that do not provide it. metric["embedding_ipc_ms"] = timings.get( "ipc_ms", metric.get("embedding_ipc_ms", 0.0) ) # Keep queue wait separate from child search and IPC so a saturated # resident worker is distinguishable from slow disk/vector recall. metric["queue_wait_ms"] = timings.get("queue_ms", 0.0) metric["transport_total_ms"] = timings.get("total_ms", 0.0) metric["worker_pid"] = timings.get("worker_pid") metric["worker_start_ms"] = timings.get("process_start_ms", 0.0) metric["worker_reused"] = timings.get("process_reused", False) # Normalize the worker's compact embedding envelope to the telemetry # schema used by the API diagnostics endpoint. metric["embedding_model"] = metric.get("embedding_model") or metric.get("model") metric["embedding_version"] = metric.get("embedding_version") or metric.get("model_version") metric["embedding_pid"] = metric.get("embedding_pid") or metric.get("pid") metric["embedding_process_start_ms"] = timings.get( "process_start_ms", metric.get("embedding_process_start_ms", 0.0) ) metric["embedding_cache_hit"] = metric.get("embedding_cache_hit", metric.get("cache_hit", False)) metric["embedding_model_load_ms"] = metric.get("embedding_model_load_ms", metric.get("model_load_ms", 0.0)) metric["embedding_tokenizer_ms"] = metric.get("embedding_tokenizer_ms", metric.get("tokenizer_ms", 0.0)) metric["embedding_encode_ms"] = metric.get("embedding_encode_ms", metric.get("encode_ms", 0.0)) metric["embedding_postprocess_ms"] = metric.get("embedding_postprocess_ms", metric.get("postprocess_ms", 0.0)) metric["embedding_count"] = metric.get("embedding_count", 0) metric["embedding_total_ms"] = sum(float(metric.get(name, 0.0) or 0.0) for name in ("embedding_model_load_ms", "embedding_tokenizer_ms", "embedding_encode_ms", "embedding_postprocess_ms")) perf_metrics.ingest_rag(metric) for item in response['results']: item['transport_timings'] = timings return response['results'] _LIGHTWEIGHT_TEXT_SUFFIXES = SUPPORTED_SUFFIXES - {".doc"} _LIGHTWEIGHT_ASCII_RE = re.compile(r"[a-z0-9_][a-z0-9_.+-]*", re.IGNORECASE) _LIGHTWEIGHT_CJK_RE = re.compile(r"[\u4e00-\u9fff]{2,}") _LIGHTWEIGHT_NUMBER_RE = re.compile(r"\d+(?:[-–.]\d+)+") def _lightweight_chunk_target(path: Path, kb: KnowledgeBase | None = None) -> int: configured = int(getattr(kb, "chunk_target", 0) or 0) return configured or (2200 if path.suffix.lower() == ".srt" else chunk.TARGET) def _lightweight_chunk_id(kb_id: int, file_name: str, content_hash: str, heading: str, seq: int, text: str) -> str: return hashlib.sha256( f"{kb_id}\0{file_name}\0{content_hash or ''}\0{heading}\0{seq}\0{text}".encode("utf-8") ).hexdigest() def iter_lightweight_chunks(session: Session, kb_ids: list[int], *, excluded_chunk_ids: dict[int, set[str]] | None = None, category_ids_by_kb: dict[int, list[int]] | None = None, file_whitelists: dict[int, set[str]] | None = None, apply_quarantine: bool = True) -> list[dict]: """Return all on-demand lightweight chunks with the same IDs used by search. Vector-disabled mode never persists embeddings or local vectors. It parses registered source files at query time; planner validation uses this helper to inspect the exact material pool that DSH Worker will see. """ kids = list(dict.fromkeys(int(kid) for kid in kb_ids)) exclusions = {int(kid): set(values) for kid, values in (excluded_chunk_ids or {}).items()} specified_ranges = {int(kid): set(names) for kid, names in (file_whitelists or {}).items()} out: list[dict] = [] for kid in kids: kb = session.exec(select(KnowledgeBase).where(KnowledgeBase.id == kid) .execution_options(populate_existing=True)).first() if kb is None or str(kb.index_status or "") == "indexing": continue whitelist = _category_file_whitelist( session, kid, list((category_ids_by_kb or {}).get(kid) or [])) if kid in specified_ranges: specified = specified_ranges[kid] whitelist = specified if whitelist is None else whitelist & specified quarantined = file_exclusions(session, kid) if apply_quarantine else set() if whitelist is not None: whitelist = whitelist - quarantined if whitelist is not None and not whitelist: continue source = _source_dir(kb) records = session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all() for record in sorted(records, key=lambda item: item.file_name): file_name = str(record.file_name or "") if whitelist is not None and file_name not in whitelist: continue if file_name in quarantined: continue path = source / file_name if not path.is_file() or path.suffix.lower() not in _LIGHTWEIGHT_TEXT_SUFFIXES: continue try: units = parse.parse_file(path) chunks = chunk.chunk_texts(units, target=_lightweight_chunk_target(path, kb)) except Exception as exc: logger.warning("轻量素材库存解析失败,已跳过 %s/%s:%s", kid, file_name, exc) continue excluded = exclusions.get(kid, set()) for seq, item in enumerate(chunks): body = str(item.get("text") or "").strip() if not body: continue meta = dict(item.get("meta") or {}) heading = str(meta.get("heading") or "") chunk_id = _lightweight_chunk_id( kid, file_name, str(record.content_hash or ""), heading, seq, body) if chunk_id in excluded: continue item_meta = dict(meta) item_meta.update({"kb_id": kid, "file": file_name, "heading": heading, "seq": seq, "retrieval_mode": "keyword-light"}) out.append({"id": chunk_id, "text": body, "meta": item_meta, "kb_id": kid, "distance": 0.0}) return out def simulate_lightweight_plan(session: Session, kb_ids: list[int], queries: list[str], *, excluded_chunk_ids: dict[int, set[str]] | None = None, category_ids_by_kb: dict[int, list[int]] | None = None, file_whitelists: dict[int, set[str]] | None = None, initial_top_k: int = 6, widened_top_k: int = 24, char_budget: int = 12000) -> dict: """Deterministically replay per-node keyword recall and chunk reservation. The plan is approvable only if every planned node can reserve at least one source chunk after earlier nodes have consumed their selected materials. This catches exhausted/mismatched lightweight KBs before Worker execution. """ pool = iter_lightweight_chunks( session, kb_ids, excluded_chunk_ids=excluded_chunk_ids, category_ids_by_kb=category_ids_by_kb, file_whitelists=file_whitelists) available = {str(item["id"]): item for item in pool} node_results: list[dict] = [] for index, query_text in enumerate(queries, start=1): query_text = str(query_text or "") terms = _lightweight_terms(query_text) scored: list[dict] = [] if terms: for item in available.values(): score, matched = _lightweight_score(item["text"], terms) if score > 0 and matched > 0: candidate = dict(item) candidate["_score"] = score candidate["_matched"] = matched scored.append(candidate) scored.sort(key=lambda item: (-float(item["_score"]), -int(item["_matched"]), int(item["kb_id"]), str(item["meta"].get("file") or ""), int(item["meta"].get("seq") or 0))) initial_hits = scored[:max(1, min(24, int(initial_top_k or 6)))] widened = max(int(initial_top_k or 6), int(widened_top_k or 24)) # Mirror DSH Worker: widen only when the first recall page is sparse. hits = scored[:max(1, min(64, widened))] if len(initial_hits) < int(initial_top_k or 6) else initial_hits selected: list[dict] = [] used_chars = 0 for hit in hits: remaining = int(char_budget or 12000) - used_chars if remaining < 240 and selected: break excerpt = source_excerpt(hit.get("text", ""), query_text, limit=min(2400, max(240, remaining))) cost = len(excerpt) if selected and used_chars + cost > int(char_budget or 12000): continue selected.append(hit) used_chars += cost if used_chars >= int(char_budget or 12000): break reserved_ids = [str(hit["id"]) for hit in selected] for cid in reserved_ids: available.pop(cid, None) node_results.append({ "order": index, "query": query_text[:500], "candidate_count": len(scored), "reserved_count": len(reserved_ids), "reserved_characters": used_chars, "chunk_ids": reserved_ids, "files": sorted({str(hit["meta"].get("file") or "") for hit in selected}), }) shortfalls = [item for item in node_results if item["reserved_count"] <= 0] return { "ok": not shortfalls, "total_chunks": len(pool), "remaining_chunks": len(available), "nodes": node_results, "shortfalls": shortfalls, "retrieval_mode": "keyword-light", "vector_backend": "disabled", } def _lightweight_terms(query: str) -> list[str]: """Extract conservative Chinese/English/numeric terms without a tokenizer. Chinese contiguous phrases are kept in full and expanded to bigrams. This is intentionally simple and deterministic: it provides usable source-grounded recall in lightweight mode while avoiding embedding models, Chroma and torch. """ text = unicodedata.normalize("NFKC", str(query or "")).lower() terms: list[str] = [] def add(value: str) -> None: value = value.strip().lower() if len(value) >= 2 and value not in terms: terms.append(value) for match in _LIGHTWEIGHT_NUMBER_RE.finditer(text): add(match.group(0)) for match in _LIGHTWEIGHT_CJK_RE.finditer(text): token = match.group(0) add(token) if len(token) > 2: for idx in range(len(token) - 1): add(token[idx:idx + 2]) for match in _LIGHTWEIGHT_ASCII_RE.finditer(text): add(match.group(0)) return terms[:80] def _lightweight_score(text: str, terms: list[str]) -> tuple[float, int]: body = unicodedata.normalize("NFKC", text or "").lower() score = 0.0 matched = 0 for term in terms: count = body.count(term) if count <= 0: continue matched += 1 if _LIGHTWEIGHT_CJK_RE.fullmatch(term): weight = 5.0 if len(term) >= 4 else (3.0 if len(term) == 3 else 1.0) elif _LIGHTWEIGHT_NUMBER_RE.fullmatch(term): weight = 5.0 elif len(term) >= 5: weight = 3.0 else: weight = 2.0 score += min(count, 6) * weight if matched and terms: # Prefer chunks matching several independent query concepts, while term # frequency remains the dominant signal. score *= 1.0 + 0.35 * (matched / min(len(terms), 12)) return score, matched def _lightweight_keyword_search(session: Session, kb_ids: list[int], query: str, k: int = 6, excluded_chunk_ids: dict[int, set[str]] | None = None, *, campaign_id: int = 0, trace_id: str = "", user_id: int = 0, organization_id: int = 1, category_ids: list[int] | None = None, file_whitelists: dict[int, set[str]] | None = None, validate_access: bool = True) -> list[dict]: """Keyword recall directly from registered source files when vectors are off. The workbench still owns source files, KnowledgeFile registry, category filters, quarantine, per-Agent consumed chunks and citation reservation. Parsing/chunking is performed on demand and never persists local vectors. """ started = time.perf_counter() kids = list(dict.fromkeys(int(kid) for kid in kb_ids)) query = str(query or "").strip() k = max(1, min(100, int(k or 6))) if not kids or not query: return [] if len(query) > 10000: raise ValueError("检索问题不能超过 10000 字") terms = _lightweight_terms(query) if not terms: return [] campaign = session.get(Campaign, campaign_id) if campaign_id else None resolved_user = int(user_id or (campaign.owner_user_id if campaign else 0) or 0) resolved_org = int((campaign.organization_id if campaign and not user_id else organization_id) or 1) exclusions = {int(kid): set(values) for kid, values in (excluded_chunk_ids or {}).items()} specified_ranges = {int(kid): set(names) for kid, names in (file_whitelists or {}).items()} actor = None if validate_access and resolved_user: from ..models import AppUser actor = session.exec(select(AppUser).where(AppUser.id == resolved_user) .execution_options(populate_existing=True)).first() if actor is None or not actor.enabled: raise ValueError("当前用户不存在或已停用,无法检索知识库") if int(actor.organization_id or 1) != resolved_org: raise ValueError("当前用户与知识库检索组织不一致") candidates: list[dict] = [] for kid in kids: kb = session.exec(select(KnowledgeBase).where(KnowledgeBase.id == kid) .execution_options(populate_existing=True)).first() if kb is None or int(kb.organization_id) not in {0, resolved_org}: raise ValueError("知识库不存在或当前用户没有访问权限") if kb.index_status == "indexing": raise ValueError("知识库正在导入,请完成后重新检索") if validate_access and actor: from ..access import can_access, canvas_allows_resource from ..models import KnowledgeBaseGrant grant = session.exec(select(KnowledgeBaseGrant).where( KnowledgeBaseGrant.kb_id == kid, KnowledgeBaseGrant.user_id == actor.id, KnowledgeBaseGrant.enabled == True)).first() # noqa: E712 if not (can_access(kb, actor, "use", grant.permission if grant else None) or canvas_allows_resource(session, kb, actor, "use")): raise ValueError("知识库不存在或当前用户没有访问权限") whitelist = _category_file_whitelist(session, kid, list(category_ids or [])) if kid in specified_ranges: specified = specified_ranges[kid] whitelist = specified if whitelist is None else whitelist & specified # An explicitly empty set means no eligible file; it must not mean all. if whitelist is not None and not whitelist: continue quarantined = file_exclusions(session, kid) if whitelist is not None: whitelist = whitelist - quarantined if not whitelist: continue source = _source_dir(kb) records = session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all() for record in sorted(records, key=lambda item: item.file_name): file_name = str(record.file_name or "") if whitelist is not None and file_name not in whitelist: continue if file_name in quarantined: continue path = source / file_name if not path.is_file() or path.suffix.lower() not in _LIGHTWEIGHT_TEXT_SUFFIXES: continue try: units = parse.parse_file(path) chunks = chunk.chunk_texts(units, target=_lightweight_chunk_target(path, kb)) except Exception as exc: logger.warning("轻量知识库解析失败,已跳过 %s/%s:%s", kid, file_name, exc) continue excluded = exclusions.get(kid, set()) for seq, item in enumerate(chunks): text = str(item.get("text") or "").strip() if not text: continue meta = dict(item.get("meta") or {}) heading = str(meta.get("heading") or "") chunk_id = _lightweight_chunk_id( kid, file_name, str(record.content_hash or ""), heading, seq, text) if chunk_id in excluded: continue score, matched = _lightweight_score(text, terms) if score <= 0 or matched <= 0: continue meta.update({"kb_id": kid, "file": file_name, "heading": heading, "seq": seq, "retrieval_mode": "keyword-light"}) candidates.append({ "id": chunk_id, "text": text, "meta": meta, "kb_id": kid, "distance": 0.0, "_score": round(score, 3), "_matched": matched, }) candidates.sort(key=lambda item: (-float(item["_score"]), -int(item["_matched"]), int(item["kb_id"]), str(item["meta"].get("file") or ""), int(item["meta"].get("seq") or 0))) results = candidates[:k] elapsed_ms = round((time.perf_counter() - started) * 1000, 2) timings = {"validation_ms": 0.0, "embedding_ms": 0.0, "recall_ms": elapsed_ms, "postprocess_ms": 0.0, "total_ms": elapsed_ms, "retrieval_mode": "keyword-light", "vector_backend": "disabled", "candidate_count": len(candidates)} for item in results: item["retrieval_timings"] = timings item.pop("_score", None) item.pop("_matched", None) logger.info("轻量关键词检索完成:%s 个库,query=%r,%s 个候选,%s 条结果,%.3fs", len(kids), query[:80], len(candidates), len(results), elapsed_ms / 1000) return results def _category_file_whitelist(session: Session, kb_id: int, category_ids: list[int]) -> set[str] | None: """分类 → 允许的文件名集合;空 list 返回 None(不限制)。""" if not category_ids: return None ids = list(category_ids) # 含子孙分类 cats = session.exec(select(KnowledgeCategory).where(KnowledgeCategory.kb_id == kb_id)).all() by_parent: dict = {} for c in cats: by_parent.setdefault(c.parent_id, []).append(c.id) expand = list(ids) queue = list(ids) while queue: cur = queue.pop() for child in by_parent.get(cur, []): if child not in expand: expand.append(child) queue.append(child) files = session.exec(select(KnowledgeFile).where( KnowledgeFile.kb_id == kb_id)).all() whitelist = set() for f in files: cat = f.category_id if f.category_id is not None else f.ai_category_id if cat in expand: whitelist.add(f.file_name) return whitelist def search_kbs(session: Session, kb_ids: list[int], query: str, k: int = 6, excluded_chunk_ids: dict[int, set[str]] | None = None, *, campaign_id: int = 0, trace_id: str = "", user_id: int = 0, organization_id: int = 1, category_ids: list[int] | None = None, file_whitelists: dict[int, set[str]] | None = None) -> list[dict]: """独立中文关键词与向量召回、融合精排;缓存不绕过权限或消费过滤。""" global _LAST_RETRIEVAL_METRIC _LAST_RETRIEVAL_METRIC = None import logging import time from . import hybrid from ..access import can_access, canvas_allows_resource from ..models import AppUser, KnowledgeBaseGrant started = time.perf_counter() validation_started = started if not kb_ids or not str(query or "").strip(): return [] if backend.is_disabled(): return _lightweight_keyword_search( session, kb_ids, query, k=k, excluded_chunk_ids=excluded_chunk_ids, campaign_id=campaign_id, trace_id=trace_id, user_id=user_id, organization_id=organization_id, category_ids=category_ids, file_whitelists=file_whitelists, validate_access=True) k = max(1, min(100, int(k))) query = str(query).strip() if len(query) > 10000: raise ValueError("检索问题不能超过 10000 字") ids = list(dict.fromkeys(int(kid) for kid in kb_ids)) campaign = session.get(Campaign, campaign_id) if campaign_id else None resolved_user = int(user_id or (campaign.owner_user_id if campaign else 0) or 0) resolved_org = int((campaign.organization_id if campaign and not user_id else organization_id) or 1) # A real requester is checked on every call, including cache hits. Internal # jobs without an account still keep the explicit organization boundary. actor = session.exec(select(AppUser).where(AppUser.id == resolved_user) .execution_options(populate_existing=True)).first() if resolved_user else None if resolved_user and (actor is None or not actor.enabled): raise ValueError("当前用户不存在或已停用,无法检索知识库") if actor and int(actor.organization_id or 1) != resolved_org: raise ValueError("当前用户与知识库检索组织不一致") kb_rows, allowed, scopes = {}, {}, [] # Authorization is part of candidate-pool identity. Keep a compact # signature alongside scopes so a changed grant cannot reuse a prior pool. permission_signature = [] # Validate before embedding and before looking in any query cache. for kid in ids: kb = session.exec(select(KnowledgeBase).where(KnowledgeBase.id == kid) .execution_options(populate_existing=True)).first() if kb is None or int(kb.organization_id) not in {0, resolved_org}: raise ValueError("知识库不存在或当前用户没有访问权限") if kb.index_status == "indexing": raise ValueError("知识库正在建立索引,请完成后重新检索") if actor: grant = session.exec(select(KnowledgeBaseGrant).where( KnowledgeBaseGrant.kb_id == kid, KnowledgeBaseGrant.user_id == actor.id, KnowledgeBaseGrant.enabled == True)).first() # noqa: E712 if not (can_access(kb, actor, 'use', grant.permission if grant else None) or canvas_allows_resource(session, kb, actor, 'use')): raise ValueError("知识库不存在或当前用户没有访问权限") permission_signature.append((kid, str(getattr(grant, 'permission', '') or 'canvas'))) else: permission_signature.append((kid, 'internal')) kb_rows[kid] = kb quarantined = file_exclusions(session, kid) whitelist = _category_file_whitelist(session, kid, list(category_ids or [])) if file_whitelists is not None and kid in file_whitelists: specified = set(file_whitelists[kid]) whitelist = specified if whitelist is None else whitelist & specified if quarantined: whitelist = (set(store.count_by_file(kid)) if whitelist is None else whitelist) - quarantined allowed[kid] = whitelist scopes.append((kid, hybrid.revision(kid), None if whitelist is None else sorted(whitelist))) validation_ms = (time.perf_counter() - validation_started) * 1000 exclusions = {int(kid): set(values) for kid, values in (excluded_chunk_ids or {}).items()} # Bound work even when a long-running Agent has consumed many thousands of # chunks. The candidate pool itself is cached before consumption filtering. excluded_count = max((len(exclusions.get(kid, set())) for kid in ids), default=0) # Keep the first-stage pool small enough for fast disk/vector reads. A # bounded allowance covers recently consumed chunks without letting a # heavily used Agent turn every query into a 2k-row scan. needed = max(32, k * 4) + min(excluded_count, 256) # Buckets allow the same cached pool to survive a few newly consumed chunks. limit = min(512, 1 << (needed - 1).bit_length()) model = str(getattr(embed, "EMBEDDING_MODEL", "local-embedding")) cache_model = model + ":" + str(getattr(embed, "EMBEDDING_POLICY", "default")) # Include lexical-index readiness in the cache namespace. A first query # may intentionally use vector-only recall while a large FTS index is being # built in the background; once that index becomes ready the key changes so # later requests immediately receive the hybrid (vector + keyword) result # instead of reusing the temporary vector-only pool for its full TTL. def _fts_scope_ready(kid, rev): files = allowed[kid] # A full-library index is also sufficient for a category scope: the # lexical channel filters its metadata after matching. return hybrid.index_available(resolved_org, kid, rev, files) or ( files is not None and hybrid.index_available(resolved_org, kid, rev, None)) fts_ready = all(_fts_scope_ready(kid, rev) for kid, rev, _ in scopes) cache_model += ":fts-ready" if fts_ready else ":fts-pending" retrieval_mode = "hybrid" if fts_ready else "vector" key = hybrid.candidate_key( resolved_org, resolved_user, scopes, query, limit, cache_model, mode=retrieval_mode, top_k=k, permissions=permission_signature, index_version=":".join(str(rev) for _, rev, _ in scopes), ) pool = hybrid.cache_get(key) cache_hit = pool is not None embedding_ms = 0.0 recall_ms = 0.0 if pool is None: embedding_started = time.perf_counter() qv = embed.embed_query(query) embedding_ms = (time.perf_counter() - embedding_started) * 1000 # Embed workers expose phase-level diagnostics (model load, tokenizer, # inference and serialization). Keep these alongside retrieval timing # without changing the vector API or test doubles. embedding_metric = getattr(embed, "last_embedding_metric", lambda: {})() _record_query_embedding_usage( idempotency_key=f"embedding:query:{trace_id or uuid.uuid4().hex}:{hashlib.sha256(query.encode('utf-8')).hexdigest()}", user_id=resolved_user, organization_id=resolved_org, campaign_id=campaign_id, trace_id=trace_id, model=model, quantity=estimate_tokens(query), metadata={"kb_ids": ids, "operation": "query"}) def recall_scope(scope): """召回单个知识库;独立 I/O 并行,避免多库检索按库串行等待。""" kid, rev, _ = scope whitelist = allowed[kid] try: scope_started = time.perf_counter() # Apply the same metadata scope to BOTH independent recalls. vhits = store.search(kid, qv, k=limit, **({'file_names': whitelist} if whitelist is not None else {})) vector_metric = getattr(store, "last_search_metric", lambda: {})() # lexical_search itself is bounded: it reuses the ready # full-library index for category scopes, or returns vector-only # while an absent index is being prewarmed in the background. lhits = hybrid.lexical_search(resolved_org, kid, rev, query, limit, whitelist) lexical_metric = getattr(hybrid, "last_lexical_metric", lambda: {})() channels = [] for hits in (vhits, lhits): rows = [] for channel_rank, item in enumerate(hits, 1): if whitelist is not None and (item.get('meta') or {}).get('file') not in whitelist: raise RuntimeError('检索返回了所选分类以外的文件') item = dict(item) item['kb_id'], item['kb_name'] = kid, kb_rows[kid].name item['channel_rank'] = channel_rank rows.append(item) channels.append(rows) if hybrid.revision(kid) != rev: raise RuntimeError('知识库索引正在更新,请稍后重新检索') return channels, { "kb_id": kid, "kb_name": kb_rows[kid].name, "collection_get_ms": vector_metric.get("collection_get_ms", 0.0), "metadata_filter_ms": round(float(vector_metric.get("metadata_filter_ms", 0.0) or 0.0) + float(lexical_metric.get("metadata_filter_ms", 0.0) or 0.0), 2), "vector_ms": vector_metric.get("vector_ms", 0.0), "fts_ms": lexical_metric.get("fts_ms", 0.0), "fts_connection_reused": bool(lexical_metric.get("connection_reused", False)), "scope_ms": round((time.perf_counter() - scope_started) * 1000, 2), "warm_state": lexical_metric.get("warm_state") or hybrid.warm_state(resolved_org, kid, rev, whitelist), } except Exception as exc: logging.getLogger('yawen.rag.search').exception('知识库 %s 检索失败', kid) raise RuntimeError(f'知识库 {kid} 检索失败:{exc}') from exc # Vector and FTS calls are network/disk bound. Running the independent # knowledge-base scopes concurrently cuts multi-library latency to the # slowest scope while keeping the same bounded candidate limit. recall_started = time.perf_counter() if len(scopes) > 1: # The process-wide bounded pool keeps recall concurrency stable and # lets each worker reuse its read-only FTS connection across # requests. ``executor.map`` still preserves scope ordering. recalled = list(_recall_executor.map(recall_scope, scopes)) elif scopes: recalled = [recall_scope(scopes[0])] else: recalled = [] scope_metrics = [item[1] for item in recalled] recalled_channels = [item[0] for item in recalled] vector = [item for channels in recalled_channels for item in channels[0]] lexical = [item for channels in recalled_channels for item in channels[1]] recall_ms = (time.perf_counter() - recall_started) * 1000 # Channel scores are ordered across libraries before fusion; incomparable # per-library FTS BM25 scores use reciprocal positions instead of raw sums. vector.sort(key=lambda row: (float(row.get('distance') or 0.0), int(row['kb_id']), str(row['id']))) lexical.sort(key=lambda row: (row['channel_rank'], int(row['kb_id']), str(row['id']))) pool = {'vector': vector, 'lexical': lexical} hybrid.cache_put(key, pool) else: # Preserve per-library visibility on cache hits. Stage durations are # zero because no vector/FTS I/O ran, while warm state still tells the # operator whether the cached pool was backed by a ready index. scope_metrics = [{ "kb_id": kid, "kb_name": kb_rows[kid].name, "collection_get_ms": 0.0, "metadata_filter_ms": 0.0, "vector_ms": 0.0, "fts_ms": 0.0, "scope_ms": 0.0, "fts_connection_reused": False, "warm_state": hybrid.warm_state(resolved_org, kid, rev, allowed[kid]), } for kid, rev, _ in scopes] postprocess_started = time.perf_counter() # Re-check consumption BEFORE bounded candidate reranking on every request. # Cached pools are never final, previously authorized answers. rerank_limit = min(128, max(32, k * 2)) filtered = {} for channel in ('vector', 'lexical'): filtered[channel] = [row for row in pool[channel] if row['id'] not in exclusions.get(int(row['kb_id']), set())][:rerank_limit] candidates = hybrid.fuse(filtered['vector'], filtered['lexical'], query) # Optional local Cross-Encoder rerank. The module is deliberately lazy and # returns the fused order unchanged when no model is configured or loading # fails, so the default offline deployment keeps the existing latency and # behaviour guarantees. from . import rerank as _rerank candidates = _rerank.rerank(candidates, query, limit=min(64, max(16, k * 4))) rerank_metric = _rerank.last_metric() fusion_metric = hybrid.last_fusion_metric() if not cache_hit else {} for kid, rev, _ in scopes: if hybrid.revision(kid) != rev: raise RuntimeError('知识库索引正在更新,请稍后重新检索') results = hybrid.finalize(candidates, k, exclusions, allowed) postprocess_ms = (time.perf_counter() - postprocess_started) * 1000 timings = { "cache_hit": cache_hit, "validation_ms": round(validation_ms, 2), "embedding_ms": round(embedding_ms, 2), "recall_ms": round(recall_ms, 2), "postprocess_ms": round(postprocess_ms, 2), "total_ms": round((time.perf_counter() - started) * 1000, 2), "retrieval_scopes": scope_metrics, "warm_state": {str(row.get("kb_id")): row.get("warm_state") for row in scope_metrics}, "merge_ms": fusion_metric.get("merge_ms", 0.0), "dedup_ms": fusion_metric.get("dedup_ms", 0.0), "rrf_ms": fusion_metric.get("rrf_ms", 0.0), "rerank_enabled": bool(rerank_metric.get("enabled", False)), "rerank_model": rerank_metric.get("model", ""), "rerank_model_loaded": bool(rerank_metric.get("model_loaded", False)), "rerank_candidate_count": int(rerank_metric.get("candidate_count", 0) or 0), "rerank_count": int(rerank_metric.get("reranked_count", 0) or 0), "rerank_model_load_ms": rerank_metric.get("model_load_ms", 0.0), "rerank_inference_ms": rerank_metric.get("inference_ms", 0.0), "rerank_fallback": bool(rerank_metric.get("fallback", True)), } if pool is not None: metric = locals().get("embedding_metric") or {} if metric: query_cache = getattr(embed, "query_cache_snapshot", lambda: {})() timings.update({ "embedding_source": metric.get("embedding_source"), "embedding_count": metric.get("embedding_count", 1), "embedding_model": metric.get("model", metric.get("model_name")), "embedding_version": metric.get("model_version", metric.get("version")), "embedding_pid": metric.get("pid", metric.get("process_id")), "embedding_dim": metric.get("embedding_dimension", metric.get("embedding_dim", 0)), "embedding_cache_hit": metric.get("cache_hit", False), "embedding_model_load_ms": metric.get("model_load_ms", 0.0), "embedding_tokenizer_ms": metric.get("tokenizer_ms", 0.0), "embedding_encode_ms": metric.get("encode_ms", metric.get("encode_inference_ms", 0.0)), "embedding_postprocess_ms": metric.get("postprocess_ms", metric.get("postprocess_serialization_ms", 0.0)), "embedding_process_start_ms": metric.get("process_start_ms", 0.0), "embedding_ipc_ms": metric.get("ipc_ms", 0.0), "embedding_total_ms": metric.get("total_ms", embedding_ms), "embedding_cache_size": query_cache.get("size", 0), "embedding_cache_hits": query_cache.get("hits", 0), "embedding_cache_misses": query_cache.get("misses", 0), "embedding_cache_hit_rate": query_cache.get("hit_rate", 0.0), }) _record_retrieval_metrics(timings, len(results)) for item in results: item["retrieval_timings"] = timings logging.getLogger('yawen.rag.search').info( '知识库检索完成:%s 个库,%s 个候选,%s 条结果,缓存%s,%.3f 秒', len(ids), len(candidates), len(results), '命中' if cache_hit else '未命中', time.perf_counter() - started) return results def import_folder(session: Session, kb_id: int, folder_zip: str, rel_base: str = "", progress_cb=None) -> dict: """导入已解压的文件夹(含多层子文件夹)。 file_paths 里每个条目形如 "相对路径\t绝对路径",相对路径用于来源标注。""" entries = [] for line in str(folder_zip).split("\n"): line = line.strip() if not line or "\t" not in line: continue rel, abs_path = line.rsplit("\t", 1) entries.append((rel, abs_path)) if not entries: raise ValueError("文件夹里没有可导入的文件") def cb(name: str, n: int): if progress_cb: progress_cb(name, n) return import_kb_files(session, kb_id, [abs_path for _, abs_path in entries], progress_cb=cb)