Files
yawan-workbench/server/app/rag/hybrid.py
T

547 lines
25 KiB
Python
Raw Normal View History

"""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