feat(server): add workbench API, DSH planner worker and RAG pipeline

This commit is contained in:
yi
2026-09-16 19:06:20 +08:00
parent 72fc5b47c7
commit d4b5b3f43a
122 changed files with 31906 additions and 0 deletions
View File
+78
View File
@@ -0,0 +1,78 @@
"""Vector backend configuration.
Default is ``disabled`` for lightweight local startup. Install optional local
RAG dependencies and set RAG_VECTOR_BACKEND=chroma to restore local vectors.
"""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
from ..config import DATA_DIR
def settings():
path = DATA_DIR / 'rag-backend.json'
try:
config = json.loads(path.read_text(encoding='utf-8'))
if not isinstance(config, dict):
raise ValueError('知识索引配置必须是对象')
except FileNotFoundError:
config = {}
value = os.environ.get('RAG_VECTOR_BACKEND', config.get('backend', 'disabled'))
backend_name = str(value or 'disabled').strip().lower()
if backend_name not in {'disabled', 'chroma', 'qdrant'}:
raise ValueError('知识索引后端配置无效')
return {**config, 'backend': backend_name}
def configured_backend() -> str:
return settings()['backend']
def local_chroma_available() -> bool:
return importlib.util.find_spec('chromadb') is not None
def effective_backend() -> str:
"""Return the backend that can actually run in this installation."""
value = configured_backend()
if value == 'chroma' and not local_chroma_available():
return 'disabled'
return value
def is_disabled() -> bool:
return effective_backend() == 'disabled'
def is_qdrant():
return effective_backend() == 'qdrant'
def qdrant_settings():
config = settings()
url = str(os.environ.get('QDRANT_URL', config.get('url', ''))).rstrip('/')
if not url.startswith(('http://', 'https://')):
raise ValueError('请配置 Qdrant 服务地址')
key = os.environ.get('QDRANT_API_KEY', '')
if not key and config.get('api_key_file'):
key = Path(config['api_key_file']).read_text(encoding='utf-8').strip()
if not key:
raise ValueError('请配置 Qdrant API 密钥')
prefix = str(config.get('collection_prefix', 'yawan_'))
if not prefix or not all(c.isalnum() or c in '_-' for c in prefix):
raise ValueError('Qdrant 知识库命名空间无效')
return url, key, prefix
def namespace():
value = effective_backend()
if value == 'qdrant':
config = settings()
return json.dumps(['qdrant', str(os.environ.get('QDRANT_URL', config.get('url', ''))).rstrip('/'),
str(config.get('collection_prefix', 'yawan_'))], separators=(',', ':'))
if value == 'disabled':
return 'disabled'
return 'chroma' if local_chroma_available() else 'chroma-missing'
+62
View File
@@ -0,0 +1,62 @@
"""RAG 管道:标题感知的连续窗口分块。"""
import re
TARGET = 500
MIN_CHUNK = 120
OVERLAP_RATIO = 0.15
def chunk_texts(units: list[dict], target: int = TARGET) -> list[dict]:
"""连续合并相同元数据的相邻单元,避免字幕类文件被切成大量细碎向量。"""
target = max(MIN_CHUNK, int(target or TARGET))
chunks = []
buf: list[str] = []
buf_len = 0
active_meta: dict | None = None
def flush() -> None:
nonlocal buf, buf_len
if buf:
text = "".join(buf)
if len(text) >= 30:
chunks.append({"text": text, "meta": dict(active_meta or {})})
buf, buf_len = [], 0
for u in units:
text = str(u.get("text") or "")
meta = dict(u.get("meta") or {})
if not text:
continue
if active_meta is not None and meta != active_meta:
flush()
if active_meta is None or meta != active_meta:
active_meta = meta
# 按句子/标点切候选段
parts = _split_sentences(text)
for p in parts:
if buf_len + len(p) > target * 1.15 and buf:
chunks.append({"text": "".join(buf), "meta": dict(active_meta)})
# 重叠:保留最后 15% 字符
overlap = "".join(buf)
cut = int(len(overlap) * OVERLAP_RATIO)
keep = overlap[-cut:] if cut > 30 else ""
buf = [keep] if keep else []
buf_len = len(keep)
buf.append(p)
buf_len += len(p)
flush()
return chunks
def _split_sentences(text: str) -> list[str]:
# 在句号/问号/感叹号/换行后切分,保持小段不小于 30 字
parts = re.split(r"(?<=[。!?!?;;])\s*|\n+", text)
merged, cur = [], ""
for p in parts:
if not p:
continue
cur += p
if len(cur) >= 30:
merged.append(cur)
cur = ""
if cur:
merged.append(cur)
return merged or [text]
+41
View File
@@ -0,0 +1,41 @@
"""Bounded source excerpts with relevant middle/tail evidence and honest omissions."""
import re
def source_excerpt(text: str, query: str = "", limit: int = 2400) -> str:
text = str(text or "")
if len(text) <= limit:
return text
marker = "\n[…此处省略,未展示内容不能作为已知事实…]\n"
# Keep boundary conditions and conclusion as well as query-related evidence.
edge = min(350, limit // 5)
spans = [(0, edge), (len(text) - edge, len(text))]
terms = re.findall(r"[A-Za-z0-9_]+|[\u3400-\u9fff]{2,}", str(query).lower())
terms += [term[i:i + 2] for term in list(terms) if len(term) > 2
for i in range(len(term) - 1)]
lower = text.lower()
candidates = []
for term in dict.fromkeys(terms):
position = lower.find(term)
if position >= edge and position < len(text) - edge:
candidates.append((position, len(term)))
candidates.sort(key=lambda pair: (-pair[1], pair[0]))
remaining = max(0, limit - 2 * edge - 3 * len(marker))
for position, _ in candidates:
if remaining < 120:
break
if any(start <= position < end for start, end in spans):
continue
size = min(remaining, 900)
start = max(0, position - size // 3)
spans.append((start, min(len(text), start + size)))
remaining -= size + len(marker)
if len(spans) == 2 and remaining:
spans.append((edge, min(len(text) - edge, edge + remaining)))
merged = []
for start, end in sorted(spans):
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(end, merged[-1][1]))
else:
merged.append((start, end))
return marker.join(text[start:end] for start, end in merged)
+110
View File
@@ -0,0 +1,110 @@
"""知识库导入去重:只比较精确正文,不以语义相似度删除资料。"""
from contextlib import contextmanager
import hashlib
import json
import os
from pathlib import Path
import threading
import uuid
from ..config import DATA_DIR
DEDUP_DIR = DATA_DIR / "rag_dedup"
_locks: dict[int, threading.RLock] = {}
_locks_guard = threading.Lock()
def file_hash(path: Path) -> str:
"""流式原始文件指纹;大文件不会为计算哈希额外复制到内存。"""
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def normalized_text(value: str) -> str:
# 保留字词、数字、标点及它们的顺序,仅统一排版空白。
return " ".join(str(value or "").split())
def document_hash(units: list[dict]) -> str:
digest = hashlib.sha256()
for unit in units:
text = normalized_text(unit.get("text", ""))
if not text:
continue
# 标题是正文语义的一部分;页码/字幕编号不是。
heading = normalized_text((unit.get("meta") or {}).get("heading", ""))
for value in (heading, text):
encoded = value.encode("utf-8")
digest.update(len(encoded).to_bytes(8, "big"))
digest.update(encoded)
return digest.hexdigest()
def unique_chunks(chunks: list[dict]) -> tuple[list[dict], int]:
"""同一文件精确重复段只嵌入一次,保留首条来源与原始顺序。"""
unique, seen = [], {}
for ordinal, item in enumerate(chunks):
key = (normalized_text((item.get("meta") or {}).get("heading", "")),
normalized_text(item.get("text", "")))
if key in seen:
seen[key]["meta"]["duplicate_count"] += 1
continue
retained = {**item, "meta": dict(item.get("meta") or {})}
retained["meta"].update(seq=ordinal, source_seq=ordinal, duplicate_count=0)
seen[key] = retained
unique.append(retained)
return unique, len(chunks) - len(unique)
@contextmanager
def library_lock(kb_id: int):
"""线程与进程都串行写同一库,避免同时上传相同文件穿过去重。"""
with _locks_guard:
lock = _locks.setdefault(int(kb_id), threading.RLock())
with lock:
DEDUP_DIR.mkdir(parents=True, exist_ok=True)
with (DEDUP_DIR / f"kb_{int(kb_id)}.lock").open("a") as handle:
try:
import fcntl
except ImportError: # Windows 保留线程保护;生产为 macOS/Linux。
fcntl = None
if fcntl:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
if fcntl:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
class FingerprintCache:
"""仅缓存解析指纹;当前原文件 SHA256 和现有索引仍独立校验。"""
def __init__(self, kb_id: int):
self.path = DEDUP_DIR / f"kb_{int(kb_id)}.json"
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
self.values = data.get("values", {}) if data.get("version") == 1 else {}
except (OSError, ValueError, TypeError, AttributeError):
self.values = {}
def get(self, raw_hash: str) -> str:
value = self.values.get(raw_hash)
return value if isinstance(value, str) and len(value) == 64 else ""
def set(self, raw_hash: str, body_hash: str):
self.values[raw_hash] = body_hash
def save(self):
temp = self.path.with_suffix(f".{uuid.uuid4().hex}.tmp")
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
temp.write_text(json.dumps({"version": 1, "values": self.values}), encoding="utf-8")
os.replace(temp, self.path)
except OSError:
# 只是加速缓存,不能让已成功的入库变为失败。
pass
finally:
temp.unlink(missing_ok=True)
+288
View File
@@ -0,0 +1,288 @@
"""RAG 管道:Embedding(本地模型,ModelScope 下载,离线可用)"""
import sys
import threading
import math
import logging
import time
import os
from functools import lru_cache
from pathlib import Path
from ..config import MODELS_DIR, EMBEDDING_MODEL
_lock = threading.Lock()
_encoder = None
_encoder_name = None
_encoder_load_ms = 0.0
_metric_lock = threading.Lock()
_last_embedding_metric: dict = {}
_embedding_dimension = ""
logger = logging.getLogger("yawen.rag.embed")
# This is deliberately a separate value from the model name. Operators can
# bump it when replacing files in the model directory without changing the
# configured model identifier (which also invalidates query-vector caches).
EMBEDDING_MODEL_VERSION = os.environ.get("EMBEDDING_MODEL_VERSION", "1")
def _set_metric(row: dict) -> None:
"""Keep the most recent bounded embedding sample for the RAG envelope.
The RAG worker is single threaded today, but protecting this assignment
makes the helper safe when a worker is configured with concurrent requests.
No source text is retained in telemetry.
"""
global _last_embedding_metric
clean = {
"process_start_ms": 0.0,
"ipc_ms": 0.0,
"model_load_ms": 0.0,
"tokenizer_ms": 0.0,
"encode_ms": 0.0,
"postprocess_ms": 0.0,
"embedding_source": "sentence-transformers",
"embedding_count": 0,
"model": str(EMBEDDING_MODEL),
"model_version": str(EMBEDDING_MODEL_VERSION),
"pid": os.getpid(),
"cache_hit": False,
}
clean.update({key: value for key, value in row.items() if value is not None})
for key in ("process_start_ms", "ipc_ms", "model_load_ms", "tokenizer_ms",
"encode_ms", "postprocess_ms"):
try:
clean[key] = round(float(clean[key]), 2)
except (TypeError, ValueError):
clean[key] = 0.0
clean["embedding_count"] = int(clean.get("embedding_count") or 0)
clean["pid"] = int(clean.get("pid") or os.getpid())
with _metric_lock:
_last_embedding_metric = clean
def last_embedding_metric() -> dict:
"""Return phase timings for the latest encode without exposing input text."""
with _metric_lock:
return dict(_last_embedding_metric)
def query_cache_snapshot() -> dict:
"""Expose the independent query-vector LRU counters for diagnostics."""
info = _cached_query.cache_info()
total = info.hits + info.misses
return {
"size": info.currsize, "max_size": info.maxsize,
"hits": info.hits, "misses": info.misses,
"hit_rate": round(info.hits / total, 4) if total else 0.0,
"model": str(EMBEDDING_MODEL), "model_version": str(EMBEDDING_MODEL_VERSION),
"dimension": int(_embedding_dimension or 0),
"pid": os.getpid(),
}
def clear_query_cache() -> None:
"""Clear only query vectors; retrieval candidate pools remain untouched."""
_cached_query.cache_clear()
def set_transport_timings(*, process_start_ms: float = 0.0, ipc_ms: float = 0.0) -> None:
"""Attach process/IPC timings measured by the parent worker transport.
``embed.py`` cannot observe subprocess creation or pipe transfer itself;
the worker pool can call this after a request and the next envelope then
contains the complete set of phases. Keeping this optional preserves the
existing embedding API for in-process callers and tests.
"""
metric = last_embedding_metric()
metric.update(process_start_ms=process_start_ms, ipc_ms=ipc_ms)
_set_metric(metric)
def ensure_model_downloaded(model_name: str) -> Path:
"""模型目录存在即用;否则从 ModelScope 下载(HF 被墙)"""
safe = model_name.replace("/", "--")
local = MODELS_DIR / safe
if (local / "config.json").exists():
return local
try:
from modelscope import snapshot_download
print(f"[embed] 从 ModelScope 下载 {model_name} ...", file=sys.stderr)
snapshot_download(model_name, local_dir=str(local))
return local
except Exception as e:
raise RuntimeError(f"模型下载失败(ModelScope): {e}")
def get_encoder():
"""单例加载 SentenceTransformer(本地路径)"""
global _encoder, _encoder_name, _encoder_load_ms
if _encoder is not None and _encoder_name == EMBEDDING_MODEL:
return _encoder
with _lock:
if _encoder is not None and _encoder_name == EMBEDDING_MODEL:
return _encoder
started = time.perf_counter()
from sentence_transformers import SentenceTransformer
local = ensure_model_downloaded(EMBEDDING_MODEL)
print(f"[embed] 加载 {local} ...", file=sys.stderr)
_encoder = SentenceTransformer(str(local))
_encoder_name = EMBEDDING_MODEL
_encoder_load_ms = (time.perf_counter() - started) * 1000
return _encoder
def prewarm() -> bool:
"""Load the embedding model and run one tiny encode as a best-effort warmup.
Model loading is intentionally kept out of the API startup path. The
caller should run this function from a daemon thread so a slow model
download/load never delays health checks or request handling. Returning a
boolean keeps this useful for startup diagnostics without making warmup
failures fatal to the service.
"""
started = time.perf_counter()
try:
encoder = get_encoder()
# A real encode initializes tokenizer/runtime kernels as well as the
# SentenceTransformer object. The result is discarded; query vectors
# continue to use the normal cached path.
encoder.encode(["系统启动预热"], normalize_embeddings=True,
show_progress_bar=False, batch_size=1)
logger.info("Embedding 模型预热完成:%s,耗时 %.3f 秒",
EMBEDDING_MODEL, time.perf_counter() - started)
return True
except Exception:
logger.exception("Embedding 模型预热失败:%s,服务将按需加载", EMBEDDING_MODEL)
return False
EMBEDDING_POLICY = "full-token-windows-v1"
def text_windows(text: str, encoder=None) -> list[str]:
"""Cover every source character without sending over-limit windows to BGE."""
enc = encoder or get_encoder()
tokenizer = enc.tokenizer
maximum = int(enc.max_seq_length)
def fits(value):
return len(tokenizer.encode(value, add_special_tokens=True, truncation=False)) <= maximum
if fits(text):
return [text]
# Character boundaries preserve exact original text; a binary search uses the
# actual model tokenizer rather than assuming that one character is one token.
windows, offset = [], 0
while offset < len(text):
remaining = text[offset:]
if fits(remaining):
windows.append(remaining)
break
low, high = 1, min(len(remaining), maximum * 4)
while low < high:
middle = (low + high + 1) // 2
if fits(remaining[:middle]):
low = middle
else:
high = middle - 1
if not fits(remaining[:low]):
raise ValueError("单个字符超过向量模型输入上限")
windows.append(remaining[:low])
offset += low
return windows
def embed_texts(texts: list[str]) -> list[list[float]]:
if not texts:
return []
# ``get_encoder`` includes model download and construction only on the
# first call in each resident worker. Subsequent calls report zero load
# time, which makes accidental worker restarts visible in telemetry.
enc = get_encoder()
global _encoder_load_ms
model_load_ms = _encoder_load_ms
# Consume the one-shot load measurement. A resident encoder must report
# zero on later requests; a non-zero value then reliably indicates a worker
# restart or an explicit model replacement.
_encoder_load_ms = 0.0
# At most 64 originals and 64 encoded windows in one model batch. Long
# paragraphs are pooled in the existing vector space, keeping chunk IDs and
# consumption records stable while representing their tails as well.
output = []
tokenizer_ms = encode_ms = postprocess_ms = 0.0
flat_count = 0
for start in range(0, len(texts), 64):
tokenizer_started = time.perf_counter()
groups = [text_windows(str(text), enc) for text in texts[start:start + 64]]
flat = [window for group in groups for window in group]
tokenizer_ms += (time.perf_counter() - tokenizer_started) * 1000
flat_count += len(flat)
encode_started = time.perf_counter()
vectors = enc.encode(flat, normalize_embeddings=True, show_progress_bar=False, batch_size=64)
encode_ms += (time.perf_counter() - encode_started) * 1000
postprocess_started = time.perf_counter()
cursor = 0
for group in groups:
selected = vectors[cursor:cursor + len(group)]
cursor += len(group)
if len(group) == 1:
output.append([float(v) for v in selected[0]])
continue
weights = [max(1, len(enc.tokenizer.encode(window, add_special_tokens=False, truncation=False)))
for window in group]
combined = [sum(float(vector[i]) * weight for vector, weight in zip(selected, weights))
for i in range(len(selected[0]))]
norm = math.sqrt(sum(value * value for value in combined)) or 1.0
output.append([value / norm for value in combined])
postprocess_ms += (time.perf_counter() - postprocess_started) * 1000
_set_metric({
"model_load_ms": model_load_ms,
"tokenizer_ms": tokenizer_ms,
"encode_ms": encode_ms,
"postprocess_ms": postprocess_ms,
"embedding_count": len(texts),
# ``flat_count`` is useful when long documents are split into windows;
# retain it as a diagnostic without changing the public return value.
"window_count": flat_count,
"embedding_dimension": len(output[0]) if output else 0,
"cache_hit": False,
})
return output
@lru_cache(maxsize=256)
def _cached_query(model: str, policy: str, version: str, dimension: str, text: str) -> tuple[float, ...]:
"""Cache only query vectors, keyed by model identity and policy version."""
return tuple(embed_texts([text])[0])
def embed_query(text: str) -> list[float]:
# lru_cache does not expose whether this invocation hit without comparing
# its counters. The comparison is process local and avoids retaining the
# query itself in telemetry.
before = _cached_query.cache_info()
global _embedding_dimension
# Keep the cache identity stable across the first call: the actual vector
# dimension is discovered only after encoding. Model/version already
# identify the encoder contract; ``auto`` prevents the first "unknown"
# dimension from creating a second key for the same query.
result = list(_cached_query(EMBEDDING_MODEL, EMBEDDING_POLICY,
EMBEDDING_MODEL_VERSION, "auto", str(text)))
if not _embedding_dimension and result:
_embedding_dimension = str(len(result))
after = _cached_query.cache_info()
cache_hit = after.hits > before.hits
metric = last_embedding_metric()
metric.update(
embedding_count=1,
cache_hit=cache_hit,
embedding_source="query-vector-lru",
model=EMBEDDING_MODEL,
model_version=EMBEDDING_MODEL_VERSION,
pid=os.getpid(),
embedding_dimension=len(result) if result else 0,
)
if cache_hit:
# A cache hit performs no tokenizer, model inference, or serialization;
# do not leak timings from the previous (possibly indexing) call.
metric.update(model_load_ms=0.0, tokenizer_ms=0.0,
encode_ms=0.0, postprocess_ms=0.0, window_count=0)
_set_metric(metric)
return result
+546
View File
@@ -0,0 +1,546 @@
"""Local Chinese lexical recall, reciprocal-rank fusion and scoped query caches.
The keyword channel has its own persisted FTS5 index, so it can find documents
outside vector top-k. Ranking is lightweight lexical ranking, not a paid/model
reranker. Derived indexes contain only the files in the requested scope.
"""
from __future__ import annotations
from collections import OrderedDict
from contextlib import contextmanager
from copy import deepcopy
try:
import fcntl
except ImportError: # Windows
fcntl = None
import hashlib
import json
import logging
import math
from pathlib import Path
import re
import sqlite3
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from ..config import DATA_DIR
from . import store
logger = logging.getLogger("yawen.rag.hybrid")
INDEX_DIR = DATA_DIR / "rag_hybrid"
VERSION = "zh-bigram-fts5-rrf-v2"
CACHE_TTL = 300
CACHE_MAX = 192
CACHE_MAX_CHARS = 8_000_000
_cache: OrderedDict = OrderedDict()
_cache_lock = threading.RLock()
_build_locks: dict[str, threading.Lock] = {}
# Index construction is deliberately kept out of request threads. A single
# process can have several libraries become ready at once (for example after a
# bulk import), so use a small bounded pool and coalesce duplicate requests by
# revision. The persisted SQLite index remains protected by _build_lock.
_prewarm_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="rag-prewarm")
_prewarm_state_lock = threading.RLock()
_prewarm_pending: set[tuple[int, str]] = set()
_lexical_metric_local = threading.local()
_fusion_metric_local = threading.local()
_fts_pool_lock = threading.RLock()
_fts_connection_pool: OrderedDict[str, tuple[tuple, sqlite3.Connection, threading.RLock]] = OrderedDict()
_FTS_CONNECTION_MAX = 32
_STOP = {"如何", "怎么", "什么", "为什么", "为何", "可以", "需要", "我们", "你们", "他们", "这个", "那个", "哪些", "以及", "一个", "进行", "方法"}
_RUNS = re.compile(r"[\u3400-\u9fff]+|[a-z0-9]+(?:[._-][a-z0-9]+)*", re.I)
def tokens(value: str) -> list[str]:
"""Overlapping Chinese 2/3-grams retain names/numbers without a dictionary."""
result = []
for run in _RUNS.findall(str(value or "").lower()):
if '\u3400' <= run[0] <= '\u9fff':
if len(run) == 1:
result.append(run)
for size in (2, 3):
result.extend(run[i:i + size] for i in range(len(run) - size + 1)
if run[i:i + size] not in _STOP)
else:
result.append(run)
return result
def _digest(value) -> str:
return hashlib.sha256(json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()).hexdigest()
def revision(kb_id: int) -> str:
"""Store revisions are persisted so invalidation also reaches query workers."""
return store.kb_revision(int(kb_id))
def warm_state(org: int, kb_id: int, rev: str | None = None,
files: set[str] | None = None) -> dict:
"""Return a lightweight, read-only FTS warm state for diagnostics."""
try:
library_id, current = int(kb_id), str(rev or revision(kb_id))
ready = index_available(int(org or 0), library_id, current, files)
with _prewarm_state_lock:
pending = (library_id, current) in _prewarm_pending
return {"state": "ready" if ready else ("warming" if pending else "cold"),
"ready": bool(ready), "pending": bool(pending),
"revision": current}
except Exception as exc:
return {"state": "error", "ready": False, "pending": False,
"error": type(exc).__name__}
def last_lexical_metric() -> dict:
return dict(getattr(_lexical_metric_local, "metric", {}) or {})
def last_fusion_metric() -> dict:
return dict(getattr(_fusion_metric_local, "metric", {}) or {})
def _scope_key(org: int, kb_id: int, rev: str, files: set[str] | None) -> str:
return _digest([VERSION, int(org), int(kb_id), rev, None if files is None else sorted(files)])
@contextmanager
def _build_lock(path: Path):
with _cache_lock:
lock = _build_locks.setdefault(str(path), threading.Lock())
with lock:
if fcntl is None:
yield
return
with path.with_suffix('.lock').open('a') as handle:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _valid_index(path: Path, signature: str) -> bool:
if not path.exists():
return False
try:
with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as db:
row = db.execute("SELECT value FROM settings WHERE key='signature'").fetchone()
return bool(row and row[0] == signature)
except sqlite3.Error:
return False
def _fts_connection(path: Path) -> tuple[sqlite3.Connection, threading.RLock, bool]:
"""Return a shared read-only FTS connection for ``path``.
FTS indexes are immutable between atomic replacements. Reusing a
connection avoids repeatedly opening the index on the external volume;
the inode/size/mtime signature forces a reconnect whenever a new revision
replaces the file, so a worker never serves an old index indefinitely.
"""
stat = path.stat()
signature = (str(path), int(getattr(stat, "st_ino", 0)), int(stat.st_size),
int(getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))))
key = str(path)
with _fts_pool_lock:
holder = _fts_connection_pool.get(key)
if holder and holder[0] == signature:
_fts_connection_pool.move_to_end(key)
return holder[1], holder[2], True
# A revision is published with an atomic rename. Serialize rotation
# with any query using the previous handle before closing it.
gate = holder[2] if holder else threading.RLock()
with gate:
uri = f"file:{path}?mode=ro"
# The bounded recall pool may hand the same KB to different
# workers across requests. A per-handle gate serializes access;
# disable sqlite's thread identity check so the handle can be
# safely reused by those workers.
connection = sqlite3.connect(uri, uri=True, check_same_thread=False)
# These indexes are read-only and query-heavy. A larger per-
# connection page cache and mmap keep repeated Chinese MATCH
# queries off the external volume.
for pragma in (
"PRAGMA query_only=ON",
"PRAGMA temp_store=MEMORY",
"PRAGMA cache_size=-32768",
"PRAGMA mmap_size=268435456",
):
try:
connection.execute(pragma)
except sqlite3.Error:
# Older SQLite builds may reject mmap_size; the query
# remains correct and still benefits from other settings.
if pragma != "PRAGMA mmap_size=268435456":
connection.close()
raise
if holder:
try:
holder[1].close()
except sqlite3.Error:
pass
_fts_connection_pool[key] = (signature, connection, gate)
_fts_connection_pool.move_to_end(key)
while len(_fts_connection_pool) > _FTS_CONNECTION_MAX:
old_key, (_, old_connection, old_gate) = _fts_connection_pool.popitem(last=False)
if old_key == key:
# Defensive guard; the just-created handle should never
# be evicted, but restore it if the limit is misconfigured.
_fts_connection_pool[key] = (signature, connection, gate)
break
with old_gate:
try:
old_connection.close()
except sqlite3.Error:
pass
return connection, gate, False
def _drop_fts_connection() -> None:
# The failed query may have come from any recall worker. Drop all handles
# so the next request reopens the affected index with a clean connection.
with _fts_pool_lock:
for _, connection, connection_gate in list(_fts_connection_pool.values()):
with connection_gate:
try:
connection.close()
except sqlite3.Error:
pass
_fts_connection_pool.clear()
def _index(org: int, kb_id: int, rev: str, files: set[str] | None) -> Path:
signature = _scope_key(org, kb_id, rev, files)
INDEX_DIR.mkdir(parents=True, exist_ok=True)
# One derived file per scope; revisions atomically replace it rather than
# retaining a full text copy of every historical library revision.
scope_name = _scope_key(org, kb_id, "", files)
path = INDEX_DIR / f"kb_{kb_id}_{scope_name}.sqlite3"
if _valid_index(path, signature):
return path
with _build_lock(path):
if _valid_index(path, signature):
return path
stage = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
started = time.perf_counter()
count = 0
try:
with sqlite3.connect(str(stage)) as db:
db.execute('PRAGMA journal_mode=OFF')
db.execute('PRAGMA synchronous=OFF')
db.execute('CREATE TABLE settings(key TEXT PRIMARY KEY, value TEXT)')
db.execute('CREATE TABLE sources(rowid INTEGER PRIMARY KEY, chunk_id TEXT NOT NULL, source_text TEXT NOT NULL, metadata TEXT NOT NULL)')
# Contentless FTS stores postings, not another expanded n-gram copy
# of every source document. The original text lives once in sources.
db.execute('CREATE VIRTUAL TABLE docs USING fts5(body, heading, content="", tokenize="unicode61")')
batch = []
for item in store.iter_chunks(kb_id, file_names=files):
meta = item.get('meta') or {}
# Defense in depth, including future store implementations.
if files is not None and meta.get('file') not in files:
raise RuntimeError('关键词索引读取到了所选分类以外的文件')
source = str(item.get('text') or '')
if not source.strip() or not item.get('id'):
continue
count += 1
batch.append((count, ' '.join(tokens(source)), ' '.join(tokens(meta.get('heading', ''))),
str(item['id']), source, json.dumps(meta, ensure_ascii=False)))
if len(batch) >= 128:
db.executemany('INSERT INTO docs(rowid,body,heading) VALUES(?,?,?)', (row[:3] for row in batch))
db.executemany('INSERT INTO sources VALUES(?,?,?,?)', ((row[0], *row[3:]) for row in batch))
batch.clear()
if batch:
db.executemany('INSERT INTO docs(rowid,body,heading) VALUES(?,?,?)', (row[:3] for row in batch))
db.executemany('INSERT INTO sources VALUES(?,?,?,?)', ((row[0], *row[3:]) for row in batch))
if revision(kb_id) != rev:
raise RuntimeError('知识库索引正在更新,请稍后重新检索')
db.execute('INSERT INTO settings VALUES(?,?)', ('signature', signature))
db.execute("INSERT INTO docs(docs) VALUES('optimize')")
stage.replace(path)
logger.info('中文关键词索引就绪:知识库 %s,%s 段,%.3f 秒', kb_id, count, time.perf_counter() - started)
finally:
stage.unlink(missing_ok=True)
return path
def _index_path(org: int, kb_id: int, rev: str, files: set[str] | None) -> Path:
"""Return the persisted index path without opening or building it."""
scope_name = _scope_key(org, kb_id, "", files)
INDEX_DIR.mkdir(parents=True, exist_ok=True)
return INDEX_DIR / f"kb_{int(kb_id)}_{scope_name}.sqlite3"
def index_available(org: int, kb_id: int, rev: str, files: set[str] | None = None) -> bool:
"""Return whether a derived FTS index already exists for this scope.
Building a first index can require scanning tens of thousands of chunks. It
must never happen synchronously on a content generation request: callers can
use the vector channel immediately and let ``schedule_prewarm`` finish the
lexical index in the background.
"""
try:
signature = _scope_key(int(org or 0), int(kb_id), str(rev), files)
path = _index_path(int(org or 0), int(kb_id), str(rev), files)
return _valid_index(path, signature)
except Exception:
return False
def schedule_prewarm(org: int, kb_id: int, rev: str | None = None) -> bool:
"""Build the full-library lexical index in the background.
Upload/rebuild callers invoke this only after their transaction has
committed. It is intentionally best-effort: a failed prewarm must never
make an otherwise successful upload fail; the normal first query can still
build the index under the same cross-process lock. Requests for the same
library revision are coalesced so repeated progress callbacks do not spawn
unbounded workers.
"""
try:
library_id = int(kb_id)
current_revision = str(rev or revision(library_id))
key = (library_id, current_revision)
except Exception:
return False
with _prewarm_state_lock:
if key in _prewarm_pending:
return False
_prewarm_pending.add(key)
def _worker():
started = time.perf_counter()
try:
# A newer upload may have committed while this task was queued;
# skip stale work and let the newer revision be scheduled instead.
if revision(library_id) != current_revision:
return
_index(int(org or 0), library_id, current_revision, None)
logger.info("知识库全文索引已后台预热:知识库 %s,%.3f 秒",
library_id, time.perf_counter() - started)
except Exception:
# Best effort only. Keep the exception in server logs for
# diagnosis, while preserving the successful import/rebuild API.
logger.exception("知识库全文索引后台预热失败:知识库 %s", library_id)
finally:
with _prewarm_state_lock:
_prewarm_pending.discard(key)
try:
_prewarm_executor.submit(_worker)
return True
except RuntimeError:
# Interpreter shutdown or executor teardown; requests can fall back to
# lazy construction on the next search.
with _prewarm_state_lock:
_prewarm_pending.discard(key)
return False
def lexical_search(org: int, kb_id: int, rev: str, query: str, limit: int,
files: set[str] | None = None) -> list[dict]:
started = time.perf_counter()
metric = {"kb_id": int(kb_id), "fts_ms": 0.0, "metadata_filter_ms": 0.0,
"warm_state": warm_state(org, kb_id, rev, files)}
_lexical_metric_local.metric = metric
if files is not None and not files:
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return []
terms = list(dict.fromkeys(tokens(query)))[:192]
if not terms:
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return []
# A category/file scope can be very large (tens of thousands of chunks).
# Building a dedicated FTS database synchronously in the generation
# request used to block the RAG worker until its 45s deadline. Full
# library indexes are prewarmed after import, so use one as a safe fallback
# and filter its metadata after the match. The vector channel still runs
# concurrently when a full index is being prepared.
post_filter_files = None
scope_path = _index_path(org, kb_id, rev, files)
scope_signature = _scope_key(org, kb_id, rev, files)
if files is not None and not _valid_index(scope_path, scope_signature):
full_path = _index_path(org, kb_id, rev, None)
full_signature = _scope_key(org, kb_id, rev, None)
if not _valid_index(full_path, full_signature):
# Do not construct a potentially multi-hundred-MB index on the
# request thread. Import/rebuild schedules this work in the
# background; returning no lexical hits is preferable to timing
# out the whole generation request.
schedule_prewarm(org, kb_id, rev)
logger.info("知识库 %s 的全文关键词索引尚未就绪,已后台预热;本次仅使用向量召回", kb_id)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return []
path = full_path
post_filter_files = files
else:
# The full-library index is normally ready from the post-import
# prewarm. If a revision changed and the replacement is still being
# built, keep this request bounded as well instead of synchronously
# scanning a large collection in the RAG worker.
if files is None:
full_path = _index_path(org, kb_id, rev, None)
full_signature = _scope_key(org, kb_id, rev, None)
if not _valid_index(full_path, full_signature):
schedule_prewarm(org, kb_id, rev)
logger.info("知识库 %s 的全文关键词索引尚未就绪,已后台预热;本次仅使用向量召回", kb_id)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return []
path = full_path
else:
path = scope_path
# Every token is quoted; user input cannot introduce FTS operators.
match = ' OR '.join('"' + term.replace('"', '""') + '"' for term in terms)
try:
fts_started = time.perf_counter()
db, db_gate, reused = _fts_connection(path)
metric["connection_reused"] = bool(reused)
# Pull a bounded oversample when filtering a full-library index so
# that relevant files are not lost behind unrelated matches.
fetch_limit = min(2000, max(limit, limit * 8)) if post_filter_files is not None else limit
with db_gate:
rows = db.execute('SELECT sources.chunk_id,sources.source_text,sources.metadata,bm25(docs,1.0,2.2) AS rank FROM docs JOIN sources ON sources.rowid=docs.rowid WHERE docs MATCH ? ORDER BY rank,sources.chunk_id LIMIT ?',
(match, fetch_limit)).fetchall()
metric["fts_ms"] = round((time.perf_counter() - fts_started) * 1000, 2)
except sqlite3.Error as exc:
_drop_fts_connection()
raise RuntimeError(f'知识库 {kb_id} 关键词检索失败:{exc}') from exc
result = []
for row in rows:
filter_started = time.perf_counter()
metadata = json.loads(row[2])
if post_filter_files is not None and metadata.get('file') not in post_filter_files:
metric["metadata_filter_ms"] += (time.perf_counter() - filter_started) * 1000
continue
result.append({'id': row[0], 'text': row[1], 'meta': metadata,
'lexical_score': max(0.0, -float(row[3]))})
if len(result) >= limit:
metric["metadata_filter_ms"] += (time.perf_counter() - filter_started) * 1000
break
metric["metadata_filter_ms"] += (time.perf_counter() - filter_started) * 1000
metric["metadata_filter_ms"] = round(metric["metadata_filter_ms"], 2)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return result
def cache_get(key):
with _cache_lock:
cached = _cache.get(key)
if cached is None:
return None
if time.monotonic() - cached[0] > CACHE_TTL:
_cache.pop(key, None)
return None
_cache.move_to_end(key)
return deepcopy(cached[1])
def cache_put(key, value):
with _cache_lock:
size = len(json.dumps(value, ensure_ascii=False))
if size > CACHE_MAX_CHARS:
return
_cache[key] = (time.monotonic(), deepcopy(value), size)
_cache.move_to_end(key)
while len(_cache) > CACHE_MAX or sum(row[2] for row in _cache.values()) > CACHE_MAX_CHARS:
_cache.popitem(last=False)
def clear_cache():
with _cache_lock:
_cache.clear()
def prewarm_index(org: int, kb_id: int, files: set[str] | None = None) -> None:
"""Build the FTS scope outside the user's request path.
Import/rebuild jobs call this after the vector index is committed. The
existing file lock makes concurrent warmups idempotent and safe across
API and worker processes.
"""
_index(int(org or 1), int(kb_id), revision(int(kb_id)), files)
def candidate_key(org: int, user: int, scopes: list[tuple], query: str, limit: int,
model: str, *, mode: str = "hybrid", top_k: int | None = None,
permissions=None, index_version: str | None = None) -> str:
"""Build an isolated retrieval candidate-pool cache key.
Query vectors are cached in :mod:`embed` and deliberately do not use this
key. Candidate pools depend on every scope and authorization dimension;
callers should pass a stable permission signature when one is available.
Defaults preserve compatibility with older six-argument callers.
"""
# ``scopes`` already carries KB id, index revision and category/file scope.
# Sorting permission values makes equivalent grant sets map to one key while
# still preventing cross-user or cross-permission pool reuse.
permission_scope = permissions
if isinstance(permission_scope, (set, frozenset, list, tuple)):
permission_scope = sorted(str(value) for value in permission_scope)
return _digest([VERSION, int(org or 0), int(user or 0), scopes,
str(query).strip(), int(limit), int(top_k or limit),
str(model), str(mode or "hybrid"), permission_scope,
str(index_version or "")])
def fuse(vector: list[dict], lexical: list[dict], query: str) -> list[dict]:
"""RRF balances independent recalls; token coverage refines bounded candidates."""
started = time.perf_counter()
metric = {"merge_ms": 0.0, "dedup_ms": 0.0, "rrf_ms": 0.0}
_fusion_metric_local.metric = metric
merge_started = time.perf_counter()
merged = {}
for channel, rows in (('vector', vector), ('lexical', lexical)):
for rank, row in enumerate(rows, 1):
key = (int(row['kb_id']), str(row['id']))
current = merged.setdefault(key, {**deepcopy(row), 'retrieval_channels': [], '_rrf': 0.0})
current['retrieval_channels'].append(channel)
current['_rrf'] += 1.0 / (60 + rank)
if channel == 'lexical':
current['lexical_score'] = row.get('lexical_score', 0)
metric["merge_ms"] = round((time.perf_counter() - merge_started) * 1000, 2)
metric["dedup_ms"] = metric["merge_ms"]
rrf_started = time.perf_counter()
qterms = set(tokens(query))
# Candidate-local inverse document frequency makes rare, exact names useful.
df = {}
term_sets = {}
for key, row in merged.items():
terms = set(tokens(row.get('text', '') + ' ' + str((row.get('meta') or {}).get('heading', ''))))
term_sets[key] = terms
for term in qterms & terms:
df[term] = df.get(term, 0) + 1
weights = {term: math.log(1 + (len(merged) + 1) / (df.get(term, 0) + 1)) for term in qterms}
total_weight = sum(weights.values()) or 1
for key, row in merged.items():
coverage = sum(weights[term] for term in qterms & term_sets[key]) / total_weight
row['score'] = row.pop('_rrf') + 0.045 * coverage
row['keyword_coverage'] = round(coverage, 4)
result = sorted(merged.values(), key=lambda row: (-row['score'], int(row['kb_id']), str(row['id'])))
metric["rrf_ms"] = round((time.perf_counter() - rrf_started) * 1000, 2)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return result
def finalize(candidates: list[dict], k: int, excluded: dict[int, set[str]],
allowed: dict[int, set[str] | None]) -> list[dict]:
"""Always run after cache lookup: consumption and access scopes are live."""
output, seen = [], set()
for row in candidates:
kid, meta = int(row['kb_id']), row.get('meta') or {}
if kid not in allowed or row['id'] in excluded.get(kid, set()):
continue
files = allowed[kid]
if files is not None and meta.get('file') not in files:
continue
# Same heading/body in one KB is a repeat; numbers and distinct headings survive.
fingerprint = (kid, re.sub(r'\s+', ' ', str(meta.get('heading') or '')).strip(),
re.sub(r'\s+', ' ', row.get('text', '')).strip())
if fingerprint in seen:
continue
seen.add(fingerprint)
output.append(row)
if len(output) >= k:
break
return output
+100
View File
@@ -0,0 +1,100 @@
"""音视频转写:faster-whisper 本地模型 + ffmpeg 抽音轨。
输出与 .srt 同格式的「去时间戳、按语义间隙合并」文本,直接进入既有 RAG 分块管线。
"""
from __future__ import annotations
import os
import re
import subprocess
import threading
from pathlib import Path
from .parse import clean_text
MEDIA_SUFFIXES = {".mp4", ".mov", ".m4a", ".mp3", ".wav", ".flv", ".mkv", ".avi", ".webm", ".aac", ".ogg", ".flac"}
# 模型目录:优先环境变量,其次 modelscope 缓存里的 gpustack/faster-whisper-small
_MODEL_DIR_CANDIDATES = [
os.environ.get("WHISPER_MODEL_DIR", ""),
str(Path.home() / ".cache/modelscope/models/gpustack--faster-whisper-small/snapshots/master"),
]
_model = None
_model_lock = threading.Lock()
def _get_model():
global _model
if _model is not None:
return _model
with _model_lock:
if _model is not None:
return _model
from faster_whisper import WhisperModel
for cand in _MODEL_DIR_CANDIDATES:
if cand and Path(cand).exists():
_model = WhisperModel(cand, device="cpu", compute_type="int8", local_files_only=True)
return _model
_model = WhisperModel("small", device="cpu", compute_type="int8")
return _model
def is_media(path: Path) -> bool:
return path.suffix.lower() in MEDIA_SUFFIXES
def _probe_duration(path: Path) -> float:
try:
r = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "csv=p=0", str(path)],
capture_output=True, text=True, timeout=30)
return float(r.stdout.strip() or 0)
except Exception:
return 0.0
def transcribe_media(path: Path, progress_cb=None) -> list[dict]:
"""音视频 → 语义段落列表 [{text, meta:{heading}}]。
超长音先压缩码率降体积;VAD 过滤静音;按标点+间隙合并成语义段。"""
src = Path(path)
work = src
# 大文件先转 16k mono wav(whisper 输入要求,同时把视频降为纯音轨)
if src.suffix.lower() not in (".wav",) or _probe_duration(src) > 0:
work = src.with_suffix(".wav.wav")
cmd = ["ffmpeg", "-y", "-i", str(src), "-vn", "-ar", "16000", "-ac", "1",
"-b:a", "64k", str(work)]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
if r.returncode != 0 or not work.exists() or work.stat().st_size == 0:
raise ValueError(f"ffmpeg 抽音轨失败: {r.stderr[-200:]}")
try:
model = _get_model()
segments, _info = model.transcribe(str(work), language="zh", vad_filter=True,
vad_parameters={"min_silence_duration_ms": 700})
lines: list[str] = []
for seg in segments:
t = (seg.text or "").strip()
if t:
lines.append(t)
if progress_cb and len(lines) % 20 == 0:
progress_cb(len(lines))
if not lines:
return []
# 合并成语义段:约 15 句或 1200 字一段(与既有 2200 字分块目标匹配)
merged, buf, size = [], [], 0
for t in lines:
buf.append(t)
size += len(t)
if size >= 1200 or t.endswith(("。", "!", "?", "!", "?")) and size >= 600:
merged.append(" ".join(buf))
buf, size = [], 0
if buf:
merged.append(" ".join(buf))
# 转写结果也经过同一套结构清洗:去掉字幕式序号/时间戳、不可见字符和句首独立口头填充词,
# 保留正文中的日期、金额、型号等数字。
return [{"text": clean_text(m), "meta": {"heading": src.stem}}
for m in merged if clean_text(m)]
finally:
if work != src:
try:
work.unlink(missing_ok=True)
except OSError:
pass
+182
View File
@@ -0,0 +1,182 @@
"""RAG 管道:文件解析"""
import json
import re
import unicodedata
from pathlib import Path
# 口语稿和字幕里常见的填充词。只在句子边界作为独立词出现时删除,
# 避免把“然后我们……”这类有实际语义的连接词误删。
_FILLER_WORDS = r"嗯|呃|额|啊|哦|唉|那个|这个嘛|就是嘛"
_FILLER_TOKEN_RE = re.compile(
rf"(?:(?<=^)|(?<=[\s,。!?;:,.!?;:、]))(?:{_FILLER_WORDS})+(?=(?:[\s,。!?;:,.!?;:、]|$))"
)
_TIMESTAMP_RE = re.compile(
r"^(?:\d{1,4}|\[?\d{1,2}:\d{2}(?::\d{2})?(?:[,.]\d{1,3})?\]?"
r"|\d{1,2}:\d{2}(?::\d{2})?(?:[,.]\d{1,3})?\s*[-–—>]\s*.+)$"
)
def clean_text(value: str, *, remove_fillers: bool = True) -> str:
"""清理导入素材中的格式噪声,同时保留有业务意义的数字。
只删除整行序号/时间戳、不可见控制字符、重复空白和句末重复标点;
数字出现在正文中(如价格、日期、型号)时会原样保留。知识库默认去除
句首独立口头填充词;数字人蒸馏可传 ``remove_fillers=False`` 保留口吻。
"""
if not isinstance(value, str):
value = str(value or "")
text = unicodedata.normalize("NFKC", value)
text = text.replace("\uFEFF", "").replace("\u200B", "").replace("\u200C", "").replace("\u200D", "")
# 保留换行和制表,移除其它 C0/C1 控制字符。
text = "".join(ch for ch in text if ch in "\n\r\t" or not unicodedata.category(ch).startswith("C"))
lines: list[str] = []
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = re.sub(r"[ \t]+", " ", raw_line).strip()
if not line:
continue
# 字幕编号、单独页码、时间戳行是结构噪声;正文中的数字不受影响。
if _TIMESTAMP_RE.fullmatch(line):
continue
if remove_fillers:
line = _FILLER_TOKEN_RE.sub("", line)
line = re.sub(r"^[、,,;;::\s]+", "", line)
# 仅压缩明显的重复标点,不改写省略号和正文标点风格。
line = re.sub(r"([。!?!?;;])\1+", r"\1", line)
if line:
lines.append(line)
return "\n".join(lines).strip()
def parse_file(path: Path) -> list[dict]:
"""返回 [{text, meta:{...}}] 按文件内章节/段落拆分的最初单元"""
ext = path.suffix.lower()
if ext == ".docx":
return _parse_docx(path)
if ext == ".srt":
return _parse_srt(path)
if ext == ".pdf":
return _parse_pdf(path)
if ext == ".json" or ext == ".jsonl":
return _parse_json(path)
if ext == ".md":
return _parse_text(path, is_md=True)
if ext in (".txt", ""):
return _parse_text(path)
return []
def _parse_pdf(path: Path) -> list[dict]:
"""PDF 文本层解析;扫描版无文本时返回空,避免把页码等结构噪声写入索引。"""
try:
from pypdf import PdfReader
reader = PdfReader(str(path))
except Exception:
return []
out: list[dict] = []
for page_no, page in enumerate(reader.pages, 1):
text = clean_text(page.extract_text() or "")
if text:
out.append({"text": text, "meta": {"page": page_no}})
return out
def _parse_docx(path: Path) -> list[dict]:
try:
from docx import Document
except ImportError:
return []
doc = Document(str(path))
out, buf, cur_h = [], [], ""
for p in doc.paragraphs:
t = clean_text(p.text)
if not t:
continue
if p.style and p.style.name and "Heading" in p.style.name:
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
buf = []
cur_h = t
else:
buf.append(t)
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
return out
def _parse_srt(path: Path) -> list[dict]:
"""SRT:去时间戳,按时间戳间隙合并成语义段(间隙>2秒 或 字幕号跳变)"""
raw = path.read_text(encoding="utf-8", errors="ignore")
blocks = re.split(r"\n\s*\n", raw)
cur, cur_end, out = [], None, []
for b in blocks:
lines = [l.strip() for l in b.splitlines() if l.strip()]
if not lines:
continue
# 行1=序号,行2=时间 00:00:01,000 --> 00:00:03,500
if len(lines) >= 2 and "-->" in lines[1]:
m = re.match(r"(\d+):(\d+):(\d+),(\d+)", lines[1])
start = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) if m else 0
gap = (start - cur_end) if cur_end is not None else 0
text = clean_text(" ".join(lines[2:]))
if cur and gap > 2.0:
out.append({"text": clean_text(" ".join(cur)), "meta": {}})
cur = []
cur.append(text)
cur_end = start
if cur:
out.append({"text": clean_text(" ".join(cur)), "meta": {}})
return out
def _parse_json(path: Path) -> list[dict]:
raw = path.read_text(encoding="utf-8", errors="ignore")
out = []
# JSONL:逐行 JSON(观品 chunks.jsonl 格式)
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
except Exception:
continue
if isinstance(item, dict):
text = item.get("content") or item.get("text") or item.get("page_content")
if text:
text = clean_text(str(text))
if " " in text:
text = text.replace(" ", "") # 观品格式字间空格
out.append({"text": text, "meta": {"heading": item.get("title", "")}})
if out:
return out
data = json.loads(raw)
if isinstance(data, list):
for i, item in enumerate(data):
text = item.get("content") or item.get("text") or (item.get("page_content") if isinstance(item, dict) else str(item))
if text:
out.append({"text": clean_text(str(text)), "meta": {"index": i}})
elif isinstance(data, dict):
# 支持 {title: text} 或 {chunks: [...]}
for k, v in data.items():
if isinstance(v, str) and v.strip():
out.append({"text": clean_text(v), "meta": {"heading": clean_text(k, remove_fillers=False)}})
elif isinstance(v, list):
for item in v:
t = item.get("content") or item.get("text") if isinstance(item, dict) else str(item)
if t:
out.append({"text": clean_text(str(t)), "meta": {"heading": clean_text(k, remove_fillers=False)}})
return out
def _parse_text(path: Path, is_md: bool = False) -> list[dict]:
out, buf, cur_h = [], [], ""
# 按行读取,避免公网分片上传后的超大文本一次性进入内存。
with path.open("r", encoding="utf-8", errors="ignore") as source:
for line in source:
s = clean_text(line)
if not s:
continue
if is_md and (s.startswith("# ") or s.startswith("## ")):
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
buf = []
cur_h = s.lstrip("#").strip()
else:
buf.append(s)
if buf:
out.append({"text": clean_text("\n".join(buf)), "meta": {"heading": cur_h}})
return out
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
"""Qdrant REST adapter preserving the application's original chunk IDs and metadata.
The small collection interface matches the store's existing snapshot/rollback code.
REST calls have deadlines; backend errors never fall back to a stale local index.
"""
from __future__ import annotations
import hashlib
import math
import threading
import uuid
import httpx
class QdrantError(RuntimeError):
def __init__(self, message, *, status_code=None):
super().__init__(message)
self.status_code = status_code
class QdrantClient:
def __init__(self, url, api_key, prefix='yawan_', *, http_client=None):
if not prefix or not all(c.isalnum() or c in '_-' for c in prefix):
raise ValueError('Qdrant 知识库命名空间无效')
self.prefix = prefix
self._creation_lock = threading.Lock()
self.http = http_client or httpx.Client(base_url=url.rstrip('/'),
headers={'api-key': api_key}, timeout=httpx.Timeout(25, connect=5),
limits=httpx.Limits(max_connections=8, max_keepalive_connections=4), trust_env=False)
def request(self, method, path, body=None, *, missing_ok=False):
try:
response = self.http.request(method, path, json=body)
except httpx.TimeoutException as exc:
raise QdrantError('Qdrant 知识索引请求超时,请检查向量服务') from exc
except httpx.HTTPError as exc:
raise QdrantError('Qdrant 知识索引连接失败,请检查向量服务') from exc
if missing_ok and response.status_code == 404:
return None
if not response.is_success:
# No body: it can contain stored user text or reflected credentials.
raise QdrantError(f'Qdrant 知识索引操作失败(HTTP {response.status_code})', status_code=response.status_code)
try:
payload = response.json()
return payload.get('result', payload)
except (ValueError, AttributeError) as exc:
raise QdrantError('Qdrant 知识索引返回了无效数据') from exc
def get_collection(self, name):
collection = QdrantCollection(self, name)
result = self.request('GET', collection.path, missing_ok=True)
if result is None:
raise QdrantError(f'知识索引集合不存在:{name}')
collection.configure(result)
return collection
def get_or_create_collection(self, name):
collection = QdrantCollection(self, name)
with self._creation_lock:
description = self.request('GET', collection.path, missing_ok=True)
if description is None:
try:
self.request('PUT', collection.path, {
'vectors': {'size': 512, 'distance': 'Cosine'},
'on_disk_payload': True,
'optimizers_config': {'default_segment_number': 2},
'hnsw_config': {'m': 16, 'ef_construct': 128}})
except QdrantError as exc:
# Another process can win after our GET; only accept a
# confirmed, compatible collection, never hide server errors.
if exc.status_code not in {400, 409}:
raise
description = self.request('GET', collection.path, missing_ok=True)
if description is None:
raise exc
else:
description = {'config': {'params': {'vectors': {'size': 512, 'distance': 'Cosine'}}}}
collection.configure(description)
self.request('PUT', collection.path + '/index?wait=true',
{'field_name': 'meta.file', 'field_schema': 'keyword'})
else:
collection.configure(description)
return collection
def delete_collection(self, name):
return self.request('DELETE', QdrantCollection(self, name).path, missing_ok=True)
def close(self):
self.http.close()
class QdrantCollection:
def __init__(self, client, name):
if not name or not all(c.isalnum() or c in '_-' for c in name):
raise ValueError('知识索引集合名称无效')
self.client, self.name = client, name
self.path = '/collections/' + client.prefix + name
self.distance = 'Cosine'
def configure(self, description):
try:
vectors = description['config']['params']['vectors']
except (KeyError, TypeError):
vectors = None
if not isinstance(vectors, dict) or vectors.get('size') != 512 or vectors.get('distance') not in {'Cosine', 'Euclid'}:
raise QdrantError('知识索引集合必须使用 512 维 Cosine 或 Euclid 向量')
self.distance = vectors['distance']
def point_id(self, original_id):
return str(uuid.uuid5(uuid.NAMESPACE_URL, self.client.prefix + self.name + ':' + str(original_id)))
@staticmethod
def filter(where):
if not where:
return None
if not isinstance(where, dict) or set(where) != {'file'}:
raise ValueError('不支持的知识索引过滤条件')
value = where['file']
if isinstance(value, dict):
if set(value) != {'$in'} or not isinstance(value['$in'], (list, tuple, set)) or any(not isinstance(v, str) for v in value['$in']):
raise ValueError('知识索引文件过滤条件无效')
match = {'any': list(value['$in'])}
elif isinstance(value, str):
match = {'value': value}
else:
raise ValueError('知识索引文件过滤条件无效')
return {'must': [{'key': 'meta.file', 'match': match}]}
def count(self, where=None):
body = {'exact': True}
filtering = self.filter(where)
if filtering is not None:
body['filter'] = filtering
result = self.client.request('POST', self.path + '/points/count', body)
return int(result['count'])
def _pages(self, where, include, size):
if isinstance(size, bool) or not isinstance(size, int) or not 1 <= size <= 4000:
raise ValueError('索引分页大小无效')
offset = None
seen_offsets = set()
while True:
body = {'limit': size, 'with_vector': 'embeddings' in include,
'with_payload': {'include': ['chunk_id', 'meta'] + (['text'] if 'documents' in include else [])}}
filtering = self.filter(where)
if filtering is not None:
body['filter'] = filtering
if offset is not None:
body['offset'] = offset
result = self.client.request('POST', self.path + '/points/scroll', body)
yield result.get('points', [])
new_offset = result.get('next_page_offset')
if new_offset is None:
break
if new_offset in seen_offsets:
raise QdrantError('Qdrant 分页游标未前进')
seen_offsets.add(new_offset)
offset = new_offset
@staticmethod
def _format(points, include):
result = {'ids': [], 'documents': [], 'metadatas': [], 'embeddings': []}
for point in points:
payload = point.get('payload') or {}
if not isinstance(payload.get('chunk_id'), str) or not payload['chunk_id']:
raise QdrantError('知识索引原始片段编号缺失')
result['ids'].append(payload['chunk_id'])
if 'documents' in include:
if not isinstance(payload.get('text'), str):
raise QdrantError('知识索引原文缺失')
result['documents'].append(payload['text'])
if 'metadatas' in include:
if not isinstance(payload.get('meta'), dict):
raise QdrantError('知识索引来源元数据缺失')
result['metadatas'].append(payload['meta'])
if 'embeddings' in include:
vector = point.get('vector')
if not isinstance(vector, list) or len(vector) != 512 or any(not isinstance(v, (int, float)) or not math.isfinite(v) for v in vector):
raise QdrantError('知识索引向量维度无效')
result['embeddings'].append(vector)
return result
def iter_records(self, where=None, include=None, page_size=256):
include = ['documents', 'metadatas'] if include is None else include
seen = set()
for page in self._pages(where, include, page_size):
self._check_scope(page, where)
result = self._format(page, include)
for identifier in result['ids']:
if identifier in seen:
raise QdrantError('知识索引分页返回重复片段,请重新读取')
seen.add(identifier)
yield result
@staticmethod
def _check_scope(points, where):
if not where:
return
allowed = where['file']
for point in points:
name = (point.get('payload', {}).get('meta') or {}).get('file')
if not (name in allowed['$in'] if isinstance(allowed, dict) else name == allowed):
raise QdrantError('知识索引返回了文件范围外的片段')
def get(self, *, ids=None, where=None, include=None, limit=None, offset=0):
include = ['documents', 'metadatas'] if include is None else include
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0 or (limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or limit < 0)):
raise ValueError('索引分页范围无效')
if limit == 0 or ids == []:
return self._format([], include)
if ids is not None:
self.filter(where)
found = {}
for start in range(0, len(ids), 128):
batch = ids[start:start + 128]
points = self.client.request('POST', self.path + '/points',
{'ids': [self.point_id(identifier) for identifier in batch],
'with_payload': True, 'with_vector': 'embeddings' in include})
for point in points:
original_id = (point.get('payload') or {}).get('chunk_id')
if original_id not in batch:
raise QdrantError('知识索引返回了请求范围外的片段')
if where:
name = (point.get('payload', {}).get('meta') or {}).get('file')
allowed = where['file']
if not (name in allowed['$in'] if isinstance(allowed, dict) else name == allowed):
continue
found[original_id] = point
selected = [found[identifier] for identifier in ids if identifier in found]
return self._format(selected[offset:None if limit is None else offset + limit], include)
retained = []
visited = 0
for page in self._pages(where, include, min(256, max(1, limit or 256))):
self._check_scope(page, where)
for point in page:
if visited >= offset:
retained.append(point)
visited += 1
if limit is not None and len(retained) >= limit:
return self._format(retained, include)
return self._format(retained, include)
def add(self, *, ids, documents, embeddings, metadatas):
if not (len(ids) == len(documents) == len(embeddings) == len(metadatas)):
raise ValueError('索引编号、正文、向量和元数据数量不一致')
# Validate the entire input before the first batch is written.
if any(not isinstance(identifier, str) or not identifier for identifier in ids) or len(set(ids)) != len(ids):
raise ValueError('索引片段编号为空或重复')
for text, vector, meta in zip(documents, embeddings, metadatas):
if not isinstance(text, str) or not text.strip() or not isinstance(meta, dict):
raise ValueError('索引正文或元数据无效')
if not isinstance(vector, (list, tuple)) or len(vector) != 512 or any(not isinstance(v, (int, float)) or not math.isfinite(v) for v in vector):
raise ValueError('索引向量必须是 512 维有限数值')
for start in range(0, len(ids), 128):
points = [{'id': self.point_id(ids[i]), 'vector': embeddings[i],
'payload': {'chunk_id': ids[i], 'text': documents[i], 'meta': metadatas[i]}}
for i in range(start, min(len(ids), start + 128))]
self.client.request('PUT', self.path + '/points?wait=true', {'points': points})
def delete(self, *, where=None, ids=None):
if ids is not None and where is not None:
raise ValueError('删除知识索引不可同时指定两种范围')
if ids is not None:
if not ids:
return None
body = {'points': [self.point_id(identifier) for identifier in ids]}
elif where:
body = {'filter': self.filter(where)}
else:
raise ValueError('删除知识索引必须指定文件或片段编号')
return self.client.request('POST', self.path + '/points/delete?wait=true', body)
def query(self, *, query_embeddings, n_results=6, where=None):
if isinstance(n_results, bool) or not isinstance(n_results, int) or n_results < 1:
raise ValueError('知识索引检索数量无效')
result = {'ids': [], 'documents': [], 'metadatas': [], 'distances': []}
for vector in query_embeddings:
body = {'query': vector, 'limit': n_results, 'with_payload': True,
'with_vector': False, 'params': {'hnsw_ef': max(64, min(256, n_results * 2))}}
filtering = self.filter(where)
if filtering is not None:
body['filter'] = filtering
points = self.client.request('POST', self.path + '/points/query', body).get('points', [])
self._check_scope(points, where)
formatted = self._format(points, ['documents', 'metadatas'])
for key in ('ids', 'documents', 'metadatas'):
result[key].append(formatted[key])
# Chroma's existing default is squared L2. Our embeddings are normalized.
result['distances'].append([max(0., 2. * (1. - float(point['score']))) if self.distance == 'Cosine'
else float(point['score']) ** 2 for point in points])
return result
_client = None
_client_signature = None
_lock = threading.Lock()
def get_client():
from .backend import qdrant_settings
global _client, _client_signature
url, key, prefix = qdrant_settings()
signature = (url, hashlib.sha256(key.encode()).hexdigest(), prefix)
with _lock:
if _client is None or _client_signature != signature:
if _client is not None:
_client.close()
_client = QdrantClient(url, key, prefix)
_client_signature = signature
return _client
+70
View File
@@ -0,0 +1,70 @@
"""Reversible exclusions for explicitly confirmed orphan test indexes.
The manifest is local operational configuration, not an index mutation. Existing
registered files always take precedence and automatically release an exclusion.
"""
from __future__ import annotations
import json
from sqlmodel import select
from ..config import DATA_DIR
from ..models import KnowledgeFile
QUARANTINE_PATH = DATA_DIR / 'rag-quarantine.json'
class QuarantineManifestError(ValueError):
"""Invalid quarantine configuration must fail explicitly, never fail open."""
def _manifest():
try:
raw = QUARANTINE_PATH.read_text(encoding='utf-8')
except FileNotFoundError:
return {}
except (OSError, UnicodeError) as exc:
raise QuarantineManifestError('知识库测试索引隔离配置无法读取,请检查 rag-quarantine.json') from exc
try:
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('duplicate key')
result[key] = value
return result
data = json.loads(raw, object_pairs_hook=unique_object)
if not isinstance(data, dict) or set(data) != {'version', 'libraries'}:
raise ValueError('invalid fields')
if type(data['version']) is not int or data['version'] != 1 or not isinstance(data['libraries'], dict):
raise ValueError('invalid version or libraries')
result = {}
for kid, entries in data['libraries'].items():
if not kid.isascii() or not kid.isdecimal() or int(kid) <= 0 or str(int(kid)) != kid or not isinstance(entries, list):
raise ValueError('invalid library')
names = set()
for entry in entries:
if not isinstance(entry, dict) or set(entry) != {'file_name', 'reason'}:
raise ValueError('invalid entry fields')
name, reason = entry['file_name'], entry['reason']
if not isinstance(name, str) or not name.strip() or '\x00' in name or name in names:
raise ValueError('invalid or duplicate file name')
if not isinstance(reason, str) or not reason.strip():
raise ValueError('missing reason')
names.add(name)
result[int(kid)] = names
return result
except (ValueError, TypeError, AttributeError, OverflowError) as exc:
raise QuarantineManifestError('知识库测试索引隔离配置无效,请检查 rag-quarantine.json 的版本、知识库编号及文件条目') from exc
def file_exclusions(session, kid):
"""Exclude only explicitly listed names still absent from KnowledgeFile."""
if isinstance(kid, bool) or not isinstance(kid, int) or kid <= 0:
raise ValueError('知识库编号无效')
names = _manifest().get(kid, set())
if not names:
return set()
registered = session.exec(select(KnowledgeFile.file_name).where(KnowledgeFile.kb_id == kid)).all()
return names - set(registered)
+115
View File
@@ -0,0 +1,115 @@
"""常驻检索进程。标准输出只用于 JSON 协议,初始化和检索诊断写标准错误。"""
import sys
import json
import time
from contextlib import redirect_stdout
from pathlib import Path
# 从任意 cwd 启动都能找到 app 包(uvicorn 工作目录不保证是 server/)
_SERVER_ROOT = Path(__file__).resolve().parents[2]
if str(_SERVER_ROOT) not in sys.path:
sys.path.insert(0, str(_SERVER_ROOT))
def initialize():
"""每个 worker 仅初始化一次,避免每次查询重复执行迁移和历史表扫描。"""
try:
import fcntl
except ImportError:
fcntl = None
from app import db as db_mod
from app.config import DATA_DIR
# 多个首次查询同时启动进程时,不并发执行相同的数据库迁移。
with (DATA_DIR / 'rag-worker-init.lock').open('a') as lock:
if fcntl:
fcntl.flock(lock, fcntl.LOCK_EX)
db_mod.init_db()
def handle(payload: dict) -> list:
from sqlmodel import Session
from app import db as db_mod
from app.rag import pipeline as rag_pipeline
with Session(db_mod.engine) as session:
results = rag_pipeline.search_kbs(
session,
payload["kb_ids"],
payload["query"],
payload.get("k", 6),
excluded_chunk_ids={int(k): set(v) for k, v in (payload.get("excluded") or {}).items()},
campaign_id=payload.get("campaign_id", 0),
trace_id=payload.get("trace_id", ""),
user_id=payload.get("user_id", 0),
organization_id=payload.get("organization_id", 1),
file_whitelists={int(k): set(v) for k, v in (payload.get("file_whitelists") or {}).items()},
)
# 透传库名、召回来源、重排分数和各阶段耗时,不裁掉检索诊断信息。
return results
def handle_warmup() -> dict:
"""Warm the resident worker without issuing a billable retrieval."""
from app.rag import embed, store, rerank
started = time.monotonic()
embed.prewarm()
# Optional local Cross-Encoder is loaded in the resident worker so the
# first real retrieval does not pay model initialization latency.
rerank.prewarm()
# Construct the persistent vector client once. Collections remain opened
# lazily per KB, while the client and embedding runtime stay resident.
store.get_client()
return {"warm_state": "warm", "warmup_ms": round((time.monotonic() - started) * 1000, 2)}
def main():
protocol_out = sys.stdout
try:
with redirect_stdout(sys.stderr):
initialize()
except Exception as exc:
from app.system_log import redact
protocol_out.write(json.dumps({'ok': False, 'error': '检索进程初始化失败:' + redact(exc, 400)}, ensure_ascii=False) + '\n')
protocol_out.flush()
return
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
started = time.monotonic()
with redirect_stdout(sys.stderr):
payload = json.loads(line)
if payload.get("op") == "warmup":
warm = handle_warmup()
protocol_out.write(json.dumps({"ok": True, **warm}, ensure_ascii=False) + "\n")
protocol_out.flush()
continue
out = handle(payload)
from app.rag import pipeline as _pipeline
metric = _pipeline.last_retrieval_metric()
envelope = {"ok": True, "results": out,
"worker_timings": {"search_ms": round((time.monotonic() - started) * 1000, 2)},
"retrieval_metric": metric}
serialization_started = time.perf_counter()
json.dumps(envelope, ensure_ascii=False)
metric["serialization_ms"] = round((time.perf_counter() - serialization_started) * 1000, 2)
envelope["retrieval_metric"] = metric
protocol_out.write(json.dumps(envelope, ensure_ascii=False) + "\n")
except Exception as exc:
from app.system_log import redact
metric = {}
try:
from app.rag import pipeline as _pipeline
metric = _pipeline.last_retrieval_metric()
except Exception:
pass
if not metric:
metric = {"error": "retrieval_failed"}
protocol_out.write(json.dumps({"ok": False, "error": redact(exc, 400),
"retrieval_metric": metric}, ensure_ascii=False) + "\n")
protocol_out.flush()
if __name__ == "__main__":
main()
+166
View File
@@ -0,0 +1,166 @@
"""Optional local Cross-Encoder candidate reranking.
The first-stage vector/FTS recall remains the default. A Cross-Encoder is
only enabled when ``RAG_RERANKER_MODEL`` points at a local model directory (or
``RAG_RERANKER_ALLOW_DOWNLOAD=1`` is explicitly set). This keeps production
search deterministic and offline when the optional model is absent, while
allowing operators to add a Chinese model such as ``BAAI/bge-reranker-base``
without changing the retrieval contract.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import threading
import time
logger = logging.getLogger("yawen.rag.rerank")
_lock = threading.Lock()
_metric_lock = threading.Lock()
_model = None
_model_name = ""
_model_error = ""
_last_metric: dict = {
"enabled": False,
"model": "",
"model_loaded": False,
"model_load_ms": 0.0,
"inference_ms": 0.0,
"candidate_count": 0,
"reranked_count": 0,
"fallback": True,
}
def _configured_model() -> str:
return str(os.environ.get("RAG_RERANKER_MODEL", "")).strip()
def _allow_download() -> bool:
return str(os.environ.get("RAG_RERANKER_ALLOW_DOWNLOAD", "0")).lower() in {
"1", "true", "yes", "on"
}
def _set_metric(**values) -> None:
global _last_metric
with _metric_lock:
row = dict(_last_metric)
row.update(values)
for key in ("model_load_ms", "inference_ms", "total_ms"):
if key in row:
try:
row[key] = round(float(row[key]), 2)
except (TypeError, ValueError):
row[key] = 0.0
_last_metric = row
def last_metric() -> dict:
with _metric_lock:
return dict(_last_metric)
def _local_model_path(name: str) -> str | None:
"""Resolve a configured model without triggering a network download."""
candidate = Path(name).expanduser()
if candidate.is_dir() and (candidate / "config.json").exists():
return str(candidate)
try:
from ..config import MODELS_DIR
cached = MODELS_DIR / name.replace("/", "--")
if cached.is_dir() and (cached / "config.json").exists():
return str(cached)
except Exception:
pass
return None
def _get_model():
"""Lazily load a local sentence-transformers CrossEncoder."""
global _model, _model_name, _model_error
name = _configured_model()
if not name:
return None
with _lock:
if _model is not None and _model_name == name:
return _model
if _model_error and _model_name == name:
return None
started = time.perf_counter()
local = _local_model_path(name)
if local is None and not _allow_download():
_model_name = name
_model_error = "模型目录不存在;为避免请求线程下载,已回退基础排序"
_set_metric(enabled=False, model=name, model_loaded=False,
fallback=True, error=_model_error)
logger.warning("RAG 重排模型不可用:%s(%s)", name, _model_error)
return None
try:
from sentence_transformers import CrossEncoder
_model = CrossEncoder(local or name)
_model_name = name
_model_error = ""
_set_metric(enabled=True, model=name, model_loaded=True,
model_load_ms=(time.perf_counter() - started) * 1000,
fallback=False)
return _model
except Exception as exc: # optional feature must never break RAG
_model = None
_model_name = name
_model_error = f"{type(exc).__name__}: {exc}"
_set_metric(enabled=False, model=name, model_loaded=False,
model_load_ms=(time.perf_counter() - started) * 1000,
fallback=True, error=_model_error)
logger.exception("RAG Cross-Encoder 加载失败,回退基础排序:%s", name)
return None
def rerank(candidates: list[dict], query: str, *, limit: int | None = None) -> list[dict]:
"""Rerank candidates in place-compatible copies and return stable results.
Only the first ``limit`` candidates are scored; the tail keeps its fused
order. If the model is not configured/available, candidates are returned
unchanged and no exception escapes the retrieval path.
"""
configured = _configured_model()
if not candidates or not configured:
_set_metric(enabled=False, model=configured, candidate_count=len(candidates),
reranked_count=0, fallback=True, inference_ms=0.0)
return candidates
count = min(len(candidates), max(1, min(int(limit or os.environ.get("RAG_RERANKER_TOP_K", 32)), 64)))
model = _get_model()
if model is None:
_set_metric(candidate_count=len(candidates), reranked_count=0, fallback=True)
return candidates
pairs = [(str(query), str(row.get("text") or "")) for row in candidates[:count]]
started = time.perf_counter()
try:
scores = model.predict(pairs, batch_size=min(32, count), show_progress_bar=False)
scored = []
for index, (row, score) in enumerate(zip(candidates[:count], scores)):
item = dict(row)
item["rerank_score"] = float(score)
scored.append((item, float(score), index))
scored.sort(key=lambda value: (-value[1], value[2]))
result = [item for item, _, _ in scored] + [dict(row) for row in candidates[count:]]
_set_metric(candidate_count=len(candidates), reranked_count=count,
inference_ms=(time.perf_counter() - started) * 1000,
fallback=False)
return result
except Exception as exc:
logger.exception("RAG Cross-Encoder 推理失败,回退基础排序")
_set_metric(candidate_count=len(candidates), reranked_count=0,
inference_ms=(time.perf_counter() - started) * 1000,
fallback=True, error=f"{type(exc).__name__}: {exc}")
return candidates
def prewarm() -> bool:
"""Load the optional reranker in the resident RAG worker at startup."""
if not _configured_model():
return False
return _get_model() is not None
+788
View File
@@ -0,0 +1,788 @@
"""RAG 向量索引(collection=知识库),显式选择 Chroma 或 Qdrant。"""
import hashlib
import logging
import re
import shutil
import sqlite3
import json
import math
import time
import uuid
import threading
from ..config import CHROMA_DIR, DATA_DIR
from . import backend
logger = logging.getLogger("yawen.rag.store")
class VectorIndexDisabled(RuntimeError):
"""Raised when an operation requires a vector backend but it is disabled."""
_client = None
_client_init_lock = threading.Lock()
FALLBACK_DIR = DATA_DIR / "vector_fallback"
FALLBACK_DIR.mkdir(parents=True, exist_ok=True)
_count_cache: dict[int, tuple[str, dict[str, int]]] = {}
_search_metric_local = threading.local()
COUNT_CACHE_DIR = DATA_DIR / "vector_counts"
COUNT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
def _count_cache_path(kb_id: int):
return COUNT_CACHE_DIR / f"kb_{int(kb_id)}.json"
def kb_revision(kb_id: int) -> str:
"""Cross-process version of one library, namespaced to this data directory."""
marker = COUNT_CACHE_DIR / f"kb_{int(kb_id)}.revision"
parts = [backend.namespace(), str(CHROMA_DIR.resolve()), str(FALLBACK_DIR.resolve()), str(marker.resolve())]
try:
parts.append(marker.read_text(encoding="ascii"))
except FileNotFoundError:
parts.append("initial")
if not backend.is_qdrant():
fallback = _fallback_path(kb_id)
try:
stat = fallback.stat()
parts.append(f"{stat.st_ino}:{stat.st_size}:{stat.st_mtime_ns}:{stat.st_ctime_ns}")
except FileNotFoundError:
pass
return hashlib.sha256("\0".join(parts).encode()).hexdigest()
def _bump_revision(kb_id: int) -> None:
COUNT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
marker = COUNT_CACHE_DIR / f"kb_{int(kb_id)}.revision"
temporary = marker.with_suffix(f".{uuid.uuid4().hex}.tmp")
try:
temporary.write_text(uuid.uuid4().hex, encoding="ascii")
temporary.replace(marker)
finally:
temporary.unlink(missing_ok=True)
def _invalidate_count_cache(kb_id: int) -> None:
_bump_revision(kb_id)
_count_cache.pop(int(kb_id), None)
try:
_count_cache_path(kb_id).unlink(missing_ok=True)
except OSError:
pass
def _fallback_path(kb_id: int):
return FALLBACK_DIR / f"kb_{int(kb_id)}.json"
def _read_fallback(kb_id: int) -> dict | None:
if backend.is_qdrant():
return None
path = _fallback_path(kb_id)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if isinstance(payload, dict) and isinstance(payload.get("items"), list):
return payload
except (OSError, ValueError, TypeError):
return None
return None
def _write_fallback(kb_id: int, items: list[dict]) -> None:
path = _fallback_path(kb_id)
temp = path.with_suffix(".tmp")
payload = {"version": 1, "kb_id": int(kb_id), "items": items}
temp.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temp.replace(path)
_bump_revision(kb_id)
class _StreamingJSON:
"""只保留当前字段的 JSON 流;向量字段逐字符跳过,不构造 embedding 数组。"""
def __init__(self, handle):
self.handle, self.buffer, self.pos, self.eof = handle, "", 0, False
def peek(self):
if self.pos >= len(self.buffer) and not self.eof:
self.buffer, self.pos = self.handle.read(65536), 0
self.eof = not self.buffer
return self.buffer[self.pos:self.pos + 1]
def take(self):
value = self.peek()
self.pos += bool(value)
return value
def whitespace(self):
while self.peek() and self.peek().isspace():
self.take()
def expect(self, value):
self.whitespace()
if self.take() != value:
raise ValueError("索引 JSON 结构损坏")
def value(self, retain=True):
self.whitespace()
first = self.peek()
if not first:
raise ValueError("索引 JSON 意外结束")
output, depth, quoted, escaped = [], 0, False, False
while self.peek():
char = self.peek()
if not quoted and depth == 0 and output and (char in ",]}" or char.isspace()):
break
# 不保留的值也需要一个已开始标记,避免把后续逗号吞掉。
if not quoted and depth == 0 and not retain and output and (char in ",]}" or char.isspace()):
break
char = self.take()
if retain:
output.append(char)
elif not output:
output.append(True)
if quoted:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
quoted = False
elif char == '"':
quoted = True
elif char in "[{":
depth += 1
elif char in "]}":
depth -= 1
if depth == 0 and not quoted and first in '[{"':
break
if quoted or depth:
raise ValueError("索引 JSON 字段不完整")
return json.loads("".join(output)) if retain else None
def _stream_fallback(kb_id: int):
path = _fallback_path(kb_id)
before = path.stat()
with path.open(encoding="utf-8") as handle:
stream, found = _StreamingJSON(handle), False
stream.expect("{")
while True:
stream.whitespace()
if stream.peek() == "}":
stream.take()
break
key = stream.value()
stream.expect(":")
if key == "items":
if found:
raise ValueError("索引包含重复 items 字段")
found = True
stream.expect("[")
while True:
stream.whitespace()
if stream.peek() == "]":
stream.take()
break
stream.expect("{")
item = {}
while True:
stream.whitespace()
if stream.peek() == "}":
stream.take()
break
field = stream.value()
stream.expect(":")
if field in {"id", "text", "meta"}:
item[field] = stream.value()
else:
stream.value(retain=False)
stream.whitespace()
if stream.peek() == "}":
continue
stream.expect(",")
yield item
stream.whitespace()
if stream.peek() == "]":
continue
stream.expect(",")
elif key == "kb_id":
if stream.value() != kb_id:
raise ValueError("索引知识库编号不匹配")
else:
stream.value(retain=False)
stream.whitespace()
if stream.peek() == "}":
continue
stream.expect(",")
stream.whitespace()
if stream.peek() or not found:
raise ValueError("索引 JSON 缺少正文或包含多余数据")
after = path.stat()
if (before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns):
raise ValueError("读取过程中知识库索引发生变化,请在索引完成后重新评估")
def iter_chunks(kb_id: int, page_size: int = 256, file_names: set[str] | None = None):
"""顺序遍历完整索引正文;不做相似度筛选,不加载向量,不改变消费记录。"""
if backend.is_disabled():
return
if isinstance(kb_id, bool) or not isinstance(kb_id, int) or kb_id <= 0:
raise ValueError("知识库编号无效")
if not isinstance(page_size, int) or not 1 <= page_size <= 4000:
raise ValueError("索引分页大小无效")
if file_names is not None and not file_names:
return
if backend.is_qdrant():
revision = kb_revision(kb_id)
collection = get_client().get_collection(collection_name(kb_id))
total = collection.count()
seen = set()
groups = [None] if file_names is None else [sorted(file_names)[i:i + 128] for i in range(0, len(file_names), 128)]
for names in groups:
where = None if names is None else {"file": {"$in": names}}
for page in collection.iter_records(where=where, page_size=page_size):
for index, identifier in enumerate(page['ids']):
meta = page['metadatas'][index]
if identifier in seen:
raise RuntimeError("知识索引分页返回重复片段,请重新评估")
seen.add(identifier)
if meta.get('kb_id') not in (None, kb_id):
raise RuntimeError("知识索引返回了其他知识库的片段")
if file_names is not None and meta.get('file') not in file_names:
raise RuntimeError("知识索引返回了分类范围外的文件")
yield {"id": identifier, "text": page['documents'][index], "meta": meta}
if collection.count() != total or kb_revision(kb_id) != revision or (file_names is None and len(seen) != total):
raise RuntimeError("读取过程中知识索引数量变化,请重新评估")
return
if _fallback_path(kb_id).exists():
try:
for item in _stream_fallback(kb_id):
if file_names is None or (item.get('meta') or {}).get('file') in file_names:
yield item
except (OSError, ValueError, TypeError) as exc:
raise RuntimeError(f"知识库 {kb_id} 本地索引读取失败:{exc}") from exc
return
try:
collection = get_client().get_collection(collection_name(kb_id))
total, offset = collection.count(), 0
if file_names is not None:
# Filter in Chroma before paging; only selected-category documents are read.
names = sorted(file_names)
for start in range(0, len(names), 128):
offset = 0
while True:
page = collection.get(include=["documents", "metadatas"], limit=page_size, offset=offset,
where={"file": {"$in": names[start:start + 128]}})
ids = _as_list(page.get("ids"))
documents, metadata = _as_list(page.get("documents")), _as_list(page.get("metadatas"))
if len(documents) != len(ids) or len(metadata) != len(ids):
raise ValueError("分类索引正文或来源缺失")
for index, identifier in enumerate(ids):
meta = metadata[index] or {}
if meta.get('file') not in file_names:
raise ValueError("分类索引返回了范围外的文件")
yield {"id": identifier, "text": documents[index], "meta": meta}
offset += len(ids)
if len(ids) < page_size:
break
if collection.count() != total:
raise ValueError("读取过程中索引数量变化,请重新评估")
return
while offset < total:
page = collection.get(include=["documents", "metadatas"], limit=page_size, offset=offset)
ids = _as_list(page.get("ids"))
documents, metadata = _as_list(page.get("documents")), _as_list(page.get("metadatas"))
if not ids or len(documents) != len(ids) or len(metadata) != len(ids):
raise ValueError("索引分页正文或来源缺失")
for index, identifier in enumerate(ids):
yield {"id": identifier, "text": documents[index], "meta": metadata[index] or {}}
offset += len(ids)
if offset != total or collection.count() != total:
raise ValueError("读取过程中索引数量变化,请重新评估")
except Exception as exc:
raise RuntimeError(f"知识库 {kb_id} 索引读取失败:{exc}") from exc
def _as_list(value, default: list | None = None) -> list:
"""把 Chroma/NumPy 返回的序列安全转换为普通列表,避免 array 的布尔判断异常。"""
if value is None:
return list(default or [])
if isinstance(value, list):
return value
try:
return list(value)
except TypeError:
return list(default or [])
def get_client():
if backend.is_disabled():
configured = backend.configured_backend()
if configured == "chroma":
raise VectorIndexDisabled("本地向量索引未启用:未安装 chromadb,请使用轻量模式或安装可选 RAG 依赖")
raise VectorIndexDisabled("本地向量索引已禁用:设置 RAG_VECTOR_BACKEND=chroma/qdrant 后才可访问向量库")
if backend.is_qdrant():
from .qdrant_store import get_client as remote_client
return remote_client()
global _client
if _client is None:
with _client_init_lock:
if _client is None:
try:
import chromadb
except ImportError as exc:
raise VectorIndexDisabled("缺少 chromadb,本地向量索引不可用") from exc
_client = chromadb.PersistentClient(path=str(CHROMA_DIR))
return _client
def collection_name(kb_id: int) -> str:
return f"kb_{kb_id}"
def ensure_collection(kb_id: int):
started = time.perf_counter()
collection = get_client().get_or_create_collection(collection_name(kb_id))
metric = getattr(_search_metric_local, "metric", None)
if metric is not None:
metric["collection_get_ms"] = round((time.perf_counter() - started) * 1000, 2)
return collection
def last_search_metric() -> dict:
"""Return timings for the most recent vector search on this thread.
Search requests fan out across threads, so a thread-local envelope avoids
cross-library races while keeping the public ``search`` signature intact.
"""
return dict(getattr(_search_metric_local, "metric", {}) or {})
def _chunk_id(file_name: str, item: dict, occurrence: int) -> str:
"""用来源和正文生成稳定 ID,重建整个库也不会改变同一片段的消费记录。"""
meta = item.get("meta", {})
heading = str(meta.get("heading") or "")
text = " ".join(str(item.get("text") or "").split())
raw = f"{file_name}\0{heading}\0{text}\0{occurrence}".encode("utf-8")
return "chunk-" + hashlib.sha256(raw).hexdigest()
def _file_rows(collection, file_name: str) -> list[dict]:
if hasattr(collection, "iter_records"):
total = collection.count(where={"file": file_name})
rows = []
for data in collection.iter_records(where={"file": file_name}, include=["documents", "metadatas", "embeddings"]):
rows.extend({"id": identifier, "text": data["documents"][i], "embedding": data["embeddings"][i], "meta": data["metadatas"][i]}
for i, identifier in enumerate(data["ids"]))
if len(rows) != total or collection.count(where={"file": file_name}) != total:
raise RuntimeError("文件索引快照不完整,已取消修改")
return rows
rows, offset = [], 0
while True:
data = collection.get(where={"file": file_name}, include=["documents", "embeddings", "metadatas"],
limit=4000, offset=offset)
ids = _as_list(data.get("ids"))
texts, vectors, metas = (_as_list(data.get(key)) for key in ("documents", "embeddings", "metadatas"))
if any(len(values) != len(ids) for values in (texts, vectors, metas)):
raise RuntimeError("文件索引快照不完整,已取消修改")
rows.extend({"id": ident, "text": texts[i], "embedding": _as_list(vectors[i]), "meta": metas[i] or {}}
for i, ident in enumerate(ids))
offset += len(ids)
if len(ids) < 4000:
return rows
def snapshot_file(kb_id: int, file_name: str) -> dict:
"""Snapshot the file in each existing index; never seed a partial fallback."""
if backend.is_disabled():
return {"kb_id": kb_id, "file_name": file_name, "fallback_exists": False,
"backend_namespace": backend.namespace(), "fallback_items": [], "chroma_items": []}
exists = not backend.is_qdrant() and _fallback_path(kb_id).exists()
fallback = _read_fallback(kb_id) if exists else None
if exists and fallback is None:
raise RuntimeError("本地向量索引损坏,已取消修改")
return {"kb_id": kb_id, "file_name": file_name, "fallback_exists": exists,
"backend_namespace": backend.namespace(),
"fallback_items": [row for row in (fallback or {}).get("items", [])
if (row.get("meta") or {}).get("file") == file_name],
"chroma_items": _file_rows(ensure_collection(kb_id), file_name)}
def _insert_rows(collection, rows: list[dict]):
for start in range(0, len(rows), 4000):
batch = rows[start:start + 4000]
collection.add(ids=[row["id"] for row in batch], documents=[row["text"] for row in batch],
embeddings=[row["embedding"] for row in batch], metadatas=[row["meta"] for row in batch])
def restore_file(kb_id: int, file_name: str, snapshot: dict) -> None:
if snapshot.get("kb_id") != kb_id or snapshot.get("file_name") != file_name:
raise ValueError("文件索引快照归属不匹配")
if snapshot.get("backend_namespace", backend.namespace()) != backend.namespace():
raise ValueError("文件索引快照所属后端已改变,已取消恢复")
collection = ensure_collection(kb_id)
try:
collection.delete(where={"file": file_name})
_insert_rows(collection, snapshot["chroma_items"])
if snapshot["fallback_exists"]:
fallback = _read_fallback(kb_id)
if fallback is None:
raise RuntimeError("回滚时本地索引不可读取")
others = [row for row in fallback["items"] if (row.get("meta") or {}).get("file") != file_name]
_write_fallback(kb_id, others + snapshot["fallback_items"])
finally:
_invalidate_count_cache(kb_id)
def _prepared_rows(kb_id, chunks, embeddings, file_name, category_path):
if len(chunks) != len(embeddings):
raise ValueError("分段数量与向量数量不一致,已取消写入")
rows, occurrences = [], {}
for i, item in enumerate(chunks):
if not isinstance(item.get("text"), str) or not item["text"].strip():
raise ValueError("索引片段正文为空,已取消写入")
vector = _as_list(embeddings[i])
if not vector or any(not math.isfinite(float(x)) for x in vector):
raise ValueError("索引向量无效,已取消写入")
if backend.is_qdrant() and len(vector) != 512:
raise ValueError("Qdrant 索引向量必须是 512 维,已取消写入")
fingerprint = f"{item.get('meta', {}).get('heading', '')}\0{' '.join(item['text'].split())}"
occurrences[fingerprint] = occurrences.get(fingerprint, 0) + 1
ident = _chunk_id(file_name, item, occurrences[fingerprint])
meta = dict(item.get("meta") or {})
meta.setdefault("seq", i)
from .embed import EMBEDDING_POLICY
meta.update(kb_id=kb_id, file=file_name, chunk_hash=ident.removeprefix("chunk-"), embedding_policy=EMBEDDING_POLICY)
if category_path:
meta["category_path"] = category_path
rows.append({"id": ident, "text": item["text"], "embedding": vector, "meta": meta})
return rows
def replace_file_chunks(kb_id: int, chunks: list[dict], embeddings: list[list[float]],
file_name: str, category_path: str = "") -> None:
if backend.is_disabled():
raise VectorIndexDisabled("本地向量索引已禁用,不能写入向量片段")
"""Prepare before mutation; failed replacements restore that file in both stores.
Chroma-only libraries stay Chroma-only. A fallback, when already present,
continues to contain every other file rather than shadowing the whole library
with only this upload. Callers serialize writers with the per-KB import lock.
"""
rows = _prepared_rows(kb_id, chunks, embeddings, file_name, category_path)
previous = snapshot_file(kb_id, file_name)
collection = ensure_collection(kb_id)
_invalidate_count_cache(kb_id)
try:
collection.delete(where={"file": file_name})
_insert_rows(collection, rows)
if previous["fallback_exists"]:
fallback = _read_fallback(kb_id)
if fallback is None:
raise RuntimeError("本地向量索引无法读取")
others = [row for row in fallback["items"] if (row.get("meta") or {}).get("file") != file_name]
_write_fallback(kb_id, others + rows)
_invalidate_count_cache(kb_id)
except Exception as exc:
try:
restore_file(kb_id, file_name, previous)
except Exception as recovery:
raise RuntimeError(f"文件索引更新失败,恢复也失败,请保留源文件并检查索引:{recovery}") from exc
raise RuntimeError(f"文件索引更新失败,已恢复原索引:{exc}") from exc
def add_chunks(kb_id: int, chunks: list[dict], embeddings: list[list[float]],
file_name: str, category_path: str = ""):
return replace_file_chunks(kb_id, chunks, embeddings, file_name, category_path)
def delete_file(kb_id: int, file_name: str):
if backend.is_disabled():
_invalidate_count_cache(kb_id)
return
return replace_file_chunks(kb_id, [], [], file_name)
def count_by_file(kb_id: int) -> dict:
"""按当前后端及版本缓存文件片段数;Qdrant 故障不读取旧 Chroma 缓存。"""
if backend.is_disabled():
return {}
remote = backend.is_qdrant()
revision = kb_revision(kb_id)
cache_path = _count_cache_path(kb_id)
try:
# An exact remote count also verifies service availability before cached
# statistics are returned. The endpoint is cheap and does not load text.
coll = ensure_collection(kb_id) if remote else None
total = coll.count() if remote else None
cached = _count_cache.get(int(kb_id))
if cached and cached[0] == revision and (total is None or sum(cached[1].values()) == total):
return dict(cached[1])
try:
payload = json.loads(cache_path.read_text(encoding="utf-8"))
counts = payload.get("counts") if isinstance(payload, dict) else None
if payload.get("version") == 2 and payload.get("kb_id") == int(kb_id) and payload.get("revision") == revision and isinstance(counts, dict):
if any(not isinstance(k, str) or isinstance(v, bool) or not isinstance(v, int) or v < 0 for k, v in counts.items()):
raise ValueError("知识索引统计缓存无效")
if total is None or sum(counts.values()) == total:
_count_cache[int(kb_id)] = (revision, counts)
return dict(counts)
except (OSError, ValueError, TypeError, AttributeError):
pass
out: dict[str, int] = {}
if not remote and _fallback_path(kb_id).exists():
records = (item.get("meta") or {} for item in _stream_fallback(kb_id))
for meta in records:
name = meta.get("file") or "未知"
out[name] = out.get(name, 0) + 1
else:
coll = coll if coll is not None else ensure_collection(kb_id)
if coll is None:
return {}
if hasattr(coll, "iter_records"):
pages = coll.iter_records(include=["metadatas"])
for data in pages:
for meta in data["metadatas"]:
name = meta.get("file") or "未知"
out[name] = out.get(name, 0) + 1
else:
offset, page = 0, 5000
while True:
got = coll.get(include=["metadatas"], limit=page, offset=offset)
metas = got.get("metadatas") or []
for meta in metas:
name = (meta or {}).get("file") or "未知"
out[name] = out.get(name, 0) + 1
offset += len(metas)
if len(metas) < page:
break
if kb_revision(kb_id) != revision or (remote and (sum(out.values()) != total or coll.count() != total)):
raise RuntimeError("统计过程中知识索引发生变化,请重新加载")
_count_cache[int(kb_id)] = (revision, out)
temporary = cache_path.with_suffix(f".{uuid.uuid4().hex}.tmp")
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(json.dumps({"version": 2, "kb_id": int(kb_id), "revision": revision, "counts": out},
ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temporary.replace(cache_path)
except OSError:
pass
finally:
temporary.unlink(missing_ok=True)
return dict(out)
except Exception:
if remote:
raise
return {}
def count(kb_id: int) -> int:
if backend.is_disabled():
return 0
fallback = _read_fallback(kb_id)
if fallback is not None:
return len(fallback.get("items", []))
try:
return ensure_collection(kb_id).count()
except Exception:
if backend.is_qdrant():
raise
return 0
def search(kb_id: int, query_embedding: list[float], k: int = 6, file_names: set[str] | None = None) -> list[dict]:
if backend.is_disabled():
return []
metric = {"kb_id": int(kb_id), "metadata_filter_ms": 0.0,
"vector_ms": 0.0, "collection_get_ms": 0.0}
_search_metric_local.metric = metric
started = time.perf_counter()
if file_names is not None and not file_names:
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return []
fallback = _read_fallback(kb_id)
if fallback is not None:
filter_started = time.perf_counter()
if file_names is not None:
filtered = [item for item in fallback.get("items", [])
if (item.get("meta") or {}).get("file") in file_names]
else:
filtered = list(fallback.get("items", []))
metric["metadata_filter_ms"] = round((time.perf_counter() - filter_started) * 1000, 2)
vector_started = time.perf_counter()
def cosine_distance(vector: list[float]) -> float:
dot = sum(float(a) * float(b) for a, b in zip(query_embedding, vector))
left = math.sqrt(sum(float(a) * float(a) for a in query_embedding)) or 1.0
right = math.sqrt(sum(float(b) * float(b) for b in vector)) or 1.0
return 1.0 - dot / (left * right)
ranked = sorted(filtered, key=lambda item: cosine_distance(item.get("embedding", [])))[:k]
metric["vector_ms"] = round((time.perf_counter() - vector_started) * 1000, 2)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return [{"id": item.get("id"), "text": item.get("text", ""),
"meta": item.get("meta", {}), "distance": cosine_distance(item.get("embedding", []))}
for item in ranked]
coll = ensure_collection(kb_id)
# Chroma and Qdrant both return an empty result for an empty collection.
# Do not call ``count()`` on the hot retrieval path: on an external disk
# it can scan collection metadata (over 1.7s cold for the 108k-chunk KB),
# while the bounded nearest-neighbor query itself is only a few ms.
# Keeping ``available`` unknown also removes a redundant remote count for
# Qdrant; the backend safely truncates to the number of stored points.
available = None
if file_names is not None:
candidates = []
names = sorted(file_names)
for start in range(0, len(names), 128):
vector_started = time.perf_counter()
result = coll.query(query_embeddings=[query_embedding], n_results=k,
where={"file": {"$in": names[start:start + 128]}})
metric["vector_ms"] += (time.perf_counter() - vector_started) * 1000
filter_started = time.perf_counter()
for i, identifier in enumerate((result.get('ids') or [[]])[0]):
meta = result['metadatas'][0][i] or {}
if meta.get('file') in file_names:
candidates.append({'id': identifier, 'text': result['documents'][0][i], 'meta': meta,
'distance': result['distances'][0][i]})
metric["metadata_filter_ms"] += (time.perf_counter() - filter_started) * 1000
metric["metadata_filter_ms"] = round(metric["metadata_filter_ms"], 2)
metric["vector_ms"] = round(metric["vector_ms"], 2)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return sorted(candidates, key=lambda item: item['distance'])[:k]
vector_started = time.perf_counter()
res = coll.query(query_embeddings=[query_embedding], n_results=k)
metric["vector_ms"] = round((time.perf_counter() - vector_started) * 1000, 2)
out = []
if res and res.get("ids") and res["ids"][0]:
for i, doc_id in enumerate(res["ids"][0]):
out.append({
"id": doc_id,
"text": res["documents"][0][i],
"meta": res["metadatas"][0][i] if res.get("metadatas") else {},
"distance": res["distances"][0][i] if res.get("distances") else None,
})
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return out
def _vector_segment_dirs(kb_id: int) -> list:
"""在删除 collection 前记录其 HNSW 段目录;只返回当前 collection 的 UUID 目录。"""
db_path = CHROMA_DIR / "chroma.sqlite3"
if not db_path.exists():
return []
with sqlite3.connect(str(db_path)) as conn:
rows = conn.execute(
"""SELECT s.id FROM collections c JOIN segments s ON s.collection = c.id
WHERE c.name = ? AND s.scope = 'VECTOR'""", (collection_name(kb_id),)
).fetchall()
root = CHROMA_DIR.resolve()
dirs = []
for (segment_id,) in rows:
if not re.fullmatch(r"[0-9a-f-]{36}", str(segment_id)):
continue
candidate = (root / str(segment_id)).resolve()
if candidate.parent == root:
dirs.append(candidate)
return dirs
def reset_kb(kb_id: int):
if backend.is_disabled():
_fallback_path(kb_id).unlink(missing_ok=True)
_invalidate_count_cache(kb_id)
return
if backend.is_qdrant():
_invalidate_count_cache(kb_id)
get_client().delete_collection(collection_name(kb_id))
_invalidate_count_cache(kb_id)
return
fallback_path = _fallback_path(kb_id)
if fallback_path.exists():
fallback_path.unlink()
_invalidate_count_cache(kb_id)
segment_dirs = _vector_segment_dirs(kb_id)
try:
get_client().delete_collection(collection_name(kb_id))
except Exception:
# collection 不存在时无需清理;其余失败交给调用方,避免误删仍被引用的段目录。
if segment_dirs:
raise
return
_invalidate_count_cache(kb_id)
for directory in segment_dirs:
if directory.exists():
shutil.rmtree(directory)
def snapshot_kb(kb_id: int) -> dict:
"""重建前导出当前 collection,供分块/嵌入异常时原样恢复。"""
if backend.is_disabled():
return {"ids": [], "documents": [], "embeddings": [], "metadatas": []}
fallback = _read_fallback(kb_id)
if fallback is not None:
return {"ids": [item.get("id") for item in fallback["items"]],
"documents": [item.get("text", "") for item in fallback["items"]],
"embeddings": [item.get("embedding", []) for item in fallback["items"]],
"metadatas": [item.get("meta", {}) for item in fallback["items"]]}
coll = ensure_collection(kb_id)
if coll.count() <= 0:
return {"ids": [], "documents": [], "embeddings": [], "metadatas": []}
if hasattr(coll, "iter_records"):
revision, total = kb_revision(kb_id), coll.count()
result = {"ids": [], "documents": [], "embeddings": [], "metadatas": []}
for page in coll.iter_records(include=["documents", "metadatas", "embeddings"]):
for key in result:
result[key].extend(page[key])
if len(result["ids"]) != total or coll.count() != total or kb_revision(kb_id) != revision:
raise RuntimeError("知识库索引快照不完整,已取消重建")
return result
# 大库分页导出(单次全量 get 会触发 sqlite 变量上限)
ids: list = []
documents: list = []
embeddings: list = []
metadatas: list = []
page = 4000
offset = 0
while True:
data = coll.get(include=["documents", "embeddings", "metadatas"], limit=page, offset=offset)
batch_ids = _as_list(data.get("ids"))
if not batch_ids:
break
ids.extend(batch_ids)
documents.extend(_as_list(data.get("documents")))
embeddings.extend(_as_list(data.get("embeddings")))
metadatas.extend(_as_list(data.get("metadatas")))
offset += len(batch_ids)
if len(batch_ids) < page:
break
return {
"ids": ids,
"documents": documents,
"embeddings": embeddings,
"metadatas": metadatas,
}
def restore_kb(kb_id: int, snapshot: dict) -> None:
"""仅用于重建失败回滚;恢复原 chunk id 和元数据,消费记录仍可对应。"""
ids = _as_list(snapshot.get("ids"))
if backend.is_disabled():
if ids:
raise VectorIndexDisabled("本地向量索引已禁用,无法恢复向量快照")
return
documents = _as_list(snapshot.get("documents"))
embeddings = _as_list(snapshot.get("embeddings"))
metadatas = _as_list(snapshot.get("metadatas"))
if any(len(values) != len(ids) for values in (documents, embeddings, metadatas)) or len(set(ids)) != len(ids):
raise ValueError("知识索引快照不完整,已取消恢复")
remote = backend.is_qdrant()
for identifier, text, vector, meta in zip(ids, documents, embeddings, metadatas):
if not isinstance(identifier, str) or not identifier or not isinstance(text, str) or not text.strip() or not isinstance(meta, dict):
raise ValueError("知识索引快照正文或来源无效,已取消恢复")
if not len(vector) or (remote and len(vector) != 512) or any(not math.isfinite(float(v)) for v in vector):
raise ValueError("知识索引快照向量无效,已取消恢复")
reset_kb(kb_id)
if not ids:
return
items = [{"id": ids[index], "text": documents[index],
"embedding": embeddings[index], "meta": metadatas[index]}
for index in range(len(ids))]
if not remote:
_write_fallback(kb_id, items)
ensure_collection(kb_id).add(
ids=ids,
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
)
_invalidate_count_cache(kb_id)
+237
View File
@@ -0,0 +1,237 @@
"""Small isolated RAG process pool with bounded admission and wall-clock deadlines."""
from __future__ import annotations
import atexit
import json
import math
import os
from pathlib import Path
import queue
import selectors
import subprocess
import sys
import threading
import time
class RagWorkerError(RuntimeError):
pass
class RagWorkerBusy(RagWorkerError):
pass
class RagWorkerTimeout(RagWorkerError):
pass
def _number(name: str, default: float, minimum: float, maximum: float) -> float:
try:
value = float(os.environ.get(name, default))
return min(maximum, max(minimum, value)) if math.isfinite(value) else default
except (TypeError, ValueError):
return default
class _Worker:
def __init__(self, command: list[str], stderr_path: Path | None):
self.command = command
self.stderr_path = stderr_path
self.process: subprocess.Popen | None = None
self.last_start_ms = 0.0
self.last_reused = False
def start(self):
if self.process is not None and self.process.poll() is None:
self.last_start_ms = 0.0
self.last_reused = True
return self.process
self.close()
started = time.perf_counter()
stderr = subprocess.DEVNULL
if self.stderr_path:
self.stderr_path.parent.mkdir(parents=True, exist_ok=True)
stderr = self.stderr_path.open('ab')
try:
self.process = subprocess.Popen(
self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=stderr, bufsize=0)
finally:
if hasattr(stderr, 'close'):
stderr.close()
os.set_blocking(self.process.stdin.fileno(), False)
os.set_blocking(self.process.stdout.fileno(), False)
self.last_start_ms = round((time.perf_counter() - started) * 1000, 2)
self.last_reused = False
return self.process
def close(self):
process, self.process = self.process, None
if process is None:
return
try:
if process.poll() is None:
process.kill()
process.wait(timeout=0.2)
except (OSError, subprocess.TimeoutExpired):
pass
finally:
for pipe in (process.stdin, process.stdout):
if pipe:
pipe.close()
def request(self, payload: dict, deadline: float) -> dict:
if time.monotonic() >= deadline:
raise RagWorkerTimeout('知识库检索超时,请缩小资料范围后重试。')
process = self.start()
encoded = memoryview((json.dumps(payload, ensure_ascii=False) + '\n').encode('utf-8'))
buffer = bytearray()
with selectors.DefaultSelector() as selector:
selector.register(process.stdin, selectors.EVENT_WRITE)
selector.register(process.stdout, selectors.EVENT_READ)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RagWorkerTimeout('知识库检索超时,请缩小资料范围后重试。')
events = selector.select(remaining)
if not events:
raise RagWorkerTimeout('知识库检索超时,请缩小资料范围后重试。')
for key, _ in events:
if key.fileobj is process.stdin:
try:
sent = os.write(key.fd, encoded[:65536])
except BlockingIOError:
continue
encoded = encoded[sent:]
if not encoded:
selector.unregister(process.stdin)
else:
try:
part = os.read(key.fd, 65536)
except BlockingIOError:
continue
if not part:
raise RagWorkerError('知识库检索进程意外退出,请重试。')
buffer.extend(part)
if len(buffer) > 32 * 1024 * 1024:
raise RagWorkerError('知识库检索返回内容过大,请减少检索片段数。')
if b'\n' in buffer:
line, trailing = buffer.split(b'\n', 1)
if trailing.strip():
raise RagWorkerError('知识库检索进程返回格式异常,请重试。')
try:
response = json.loads(line)
except (ValueError, UnicodeDecodeError) as exc:
raise RagWorkerError('知识库检索进程返回格式异常,请重试。') from exc
if not isinstance(response, dict):
raise RagWorkerError('知识库检索进程返回格式异常,请重试。')
return response
class RagWorkerPool:
"""At most three active workers, a small waiting room, no in-process fallback."""
def __init__(self, *, size: int = 2, total_timeout: float = 45,
queue_timeout: float = 3, max_pending: int | None = None,
command: list[str] | None = None, stderr_path: Path | None = None):
self.size = min(3, max(1, int(size)))
self.total_timeout = max(0.01, float(total_timeout))
self.queue_timeout = max(0.0, min(float(queue_timeout), self.total_timeout))
self._closed = False
self._workers = [_Worker(command or [sys.executable, str(Path(__file__).with_name('rag_worker.py'))],
stderr_path) for _ in range(self.size)]
self._available = queue.Queue(maxsize=self.size)
for worker in self._workers:
self._available.put_nowait(worker)
self._admission = threading.BoundedSemaphore(self.size + (max_pending if max_pending is not None else self.size * 2))
def request(self, payload: dict) -> dict:
started = time.monotonic()
deadline = started + self.total_timeout
if self._closed:
raise RagWorkerError('知识库检索服务正在关闭,请稍后重试。')
if not self._admission.acquire(blocking=False):
raise RagWorkerBusy('知识库检索任务较多,等待队列已满,请稍后重试。')
worker = None
try:
try:
worker = self._available.get(timeout=min(self.queue_timeout, max(0, deadline - time.monotonic())))
except queue.Empty as exc:
raise RagWorkerBusy('知识库检索排队超时,请稍后重试。') from exc
queue_ms = round((time.monotonic() - started) * 1000, 2)
try:
response = worker.request(payload, deadline)
if response.get('ok') is not True:
from ..system_log import redact
detail = redact(response.get('error') or '未知错误', 400)
error = RagWorkerError(f'知识库检索失败:{detail}')
setattr(error, 'retrieval_metric', response.get('retrieval_metric') or {"error": detail})
raise error
if payload.get('op') == 'warmup':
return response
if not isinstance(response.get('results'), list):
raise RagWorkerError('知识库检索返回格式异常,请重试。')
except Exception as exc:
# A timed-out response may never be consumed by a subsequent request.
worker.close()
if isinstance(exc, RagWorkerError):
raise
raise RagWorkerError('知识库检索进程通信失败,请重试。') from exc
response['transport_timings'] = {
'queue_ms': queue_ms, 'total_ms': round((time.monotonic() - started) * 1000, 2),
'worker_ms': round((time.monotonic() - started) * 1000 - queue_ms, 2),
'process_start_ms': worker.last_start_ms,
'process_reused': worker.last_reused,
'worker_pid': int(worker.process.pid) if worker.process else 0,
}
# Child search timing is reported in the response envelope. The
# residual after spawn and search is the parent/child pipe and JSON
# transport overhead, useful for spotting IPC regressions.
child_ms = float((response.get('worker_timings') or {}).get('search_ms') or 0.0)
response['transport_timings']['ipc_ms'] = round(max(
0.0,
response['transport_timings']['worker_ms']
- child_ms
- float(response['transport_timings']['process_start_ms'] or 0.0)), 2)
return response
finally:
if worker is not None:
self._available.put_nowait(worker)
self._admission.release()
def prewarm(self) -> dict:
"""Start one resident worker and warm its model/client in the background."""
return self.request({'op': 'warmup'})
def close(self):
self._closed = True
for worker in self._workers:
worker.close()
_pool: RagWorkerPool | None = None
_pool_lock = threading.Lock()
def get_pool() -> RagWorkerPool:
global _pool
with _pool_lock:
if _pool is None:
from ..config import DATA_DIR
_pool = RagWorkerPool(
# The local embedding model is loaded once per worker. Keep one
# resident worker by default on the single-host deployment to
# avoid duplicating several hundred MB and triggering macOS
# memory pressure; operators can raise RAG_WORKER_POOL_SIZE
# explicitly when concurrent retrieval is required.
size=int(_number('RAG_WORKER_POOL_SIZE', 1, 1, 3)),
total_timeout=_number('RAG_SEARCH_TIMEOUT_SECONDS', 45, 1, 180),
# Content tasks commonly fan out several segment requests at
# once. Give queued requests enough time to use the single
# memory-efficient worker instead of surfacing a spurious
# timeout while an earlier segment is still being searched.
queue_timeout=_number('RAG_QUEUE_TIMEOUT_SECONDS', 10, 0, 30),
max_pending=int(_number('RAG_MAX_PENDING', 8, 0, 32)),
stderr_path=DATA_DIR / 'logs' / 'rag-worker.log')
atexit.register(_pool.close)
return _pool