1558 lines
80 KiB
Python
1558 lines
80 KiB
Python
"""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)
|