547 lines
25 KiB
Python
547 lines
25 KiB
Python
"""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
|