"""Deterministic lexical baseline over a bounded, reviewed knowledge corpus.""" import hashlib import json import logging import math import unicodedata from collections import Counter from functools import lru_cache from time import perf_counter from uuid import uuid4 import jieba from fastapi import HTTPException from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.models import Document, KnowledgeItem, RetrievalRun, Source from app.models.catalog import utc_now from app.schemas.retrieval import RetrievalConfig, RetrievalHit, RetrievalRequest, RetrievalResponse MAX_ITEMS = 5000 MAX_CHARACTERS = 2_000_000 SNIPPET_LENGTH = 240 STOPWORDS = frozenset("的 了 和 与 是 在 有 哪些 什么 请问 如何 吗 呢 我 你 想 了解".split()) CONFIG = RetrievalConfig( tokenizer="jieba-0.42.1-search-hmm-off-nalati-v1", scorer="bm25-weighted-text-v1", k1=1.2, b=0.75, title_weight=2, stopwords_version="zh-small-v1", ) @lru_cache(maxsize=1) def tokenizer(): jieba.setLogLevel(logging.WARNING) instance = jieba.Tokenizer() instance.initialize() instance.add_word("那拉提", freq=100000) return instance def normalized_with_offsets(text: str) -> tuple[str, list[int]]: pieces, offsets = [], [] for position, character in enumerate(text): piece = unicodedata.normalize("NFKC", character).casefold() pieces.append(piece) offsets.extend([position] * len(piece)) return "".join(pieces), offsets def tokenize(text: str) -> list[str]: normalized, _ = normalized_with_offsets(text) return [ term for term in tokenizer().cut_for_search(normalized, HMM=False) if term not in STOPWORDS and any(character.isalnum() for character in term) ] def bm25(counters: list[Counter], query_terms: list[str]) -> list[float]: """Positive log-IDF BM25; repeated query terms count once.""" count = len(counters) lengths = [sum(counter.values()) for counter in counters] average = sum(lengths) / count if count else 0 if not average: return [0.0] * count scores = [0.0] * count for term in dict.fromkeys(query_terms): frequency = sum(counter[term] > 0 for counter in counters) if not frequency: continue idf = math.log(1 + (count - frequency + 0.5) / (frequency + 0.5)) for index, counter in enumerate(counters): tf = counter[term] if tf: denominator = tf + CONFIG.k1 * (1 - CONFIG.b + CONFIG.b * lengths[index] / average) scores[index] += idf * tf * (CONFIG.k1 + 1) / denominator return scores def excerpt(content: str, matched_terms: list[str]) -> tuple[str, int, int]: normalized, offsets = normalized_with_offsets(content) positions = [normalized.find(term) for term in matched_terms if term in normalized] start = max(0, offsets[min(positions)] - 60) if positions else 0 end = min(len(content), start + SNIPPET_LENGTH) return content[start:end], start, end def load_corpus(db: Session, category: str | None) -> list[dict]: statement = ( select( KnowledgeItem, Source.name.label("source_name"), Source.version.label("source_version"), Document.version.label("document_version"), func.count().over().label("corpus_total"), func.sum(func.length(KnowledgeItem.title) + func.length(KnowledgeItem.content)) .over() .label("corpus_characters"), ) .outerjoin(Source, KnowledgeItem.source_id == Source.id) .outerjoin(Document, KnowledgeItem.document_id == Document.id) .where( KnowledgeItem.verification_status == "verified", KnowledgeItem.is_deleted.is_(False), KnowledgeItem.source_url.is_not(None), func.length(func.trim(KnowledgeItem.source_url)) > 0, KnowledgeItem.collected_at.is_not(None), KnowledgeItem.reviewed_at.is_not(None), KnowledgeItem.reviewer.is_not(None), KnowledgeItem.verification_notes.is_not(None), or_(KnowledgeItem.source_id.is_(None), Source.is_deleted.is_(False)), or_( KnowledgeItem.document_id.is_(None), (Document.is_deleted.is_(False)) & (Document.source_id == KnowledgeItem.source_id), ), or_(KnowledgeItem.source_id.is_(None), KnowledgeItem.source_url == Source.source_url), ) .order_by(KnowledgeItem.id) .limit(MAX_ITEMS + 1) ) if category is not None: statement = statement.where(KnowledgeItem.category == category) corpus = [] # 一条 SQL 的快照同时给出语料与容量;流式读取避免先载入超限正文。 with db.execute(statement.execution_options(stream_results=True, max_row_buffer=1)) as rows: for row in rows: if row.corpus_total > MAX_ITEMS or row.corpus_characters > MAX_CHARACTERS: raise HTTPException( 503, "已核验语料超过关键词基线容量,请缩小分类范围或建立检索索引" ) item = row.KnowledgeItem corpus.append( { "knowledge_id": item.id, "knowledge_version": item.version, "document_id": item.document_id, "document_version": row.document_version, "source_id": item.source_id, "source_version": row.source_version, "source_name": row.source_name, "source_url": item.source_url, "title": item.title, "category": item.category, "content": item.content, } ) return corpus def retrieve(db: Session, payload: RetrievalRequest) -> RetrievalResponse: started = perf_counter() terms = list(dict.fromkeys(tokenize(payload.query))) if not terms: raise HTTPException(422, "问题中没有可检索词,请补充具体关键词") corpus = load_corpus(db, payload.category) fingerprint = hashlib.sha256( json.dumps(corpus, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() counters = [ Counter(tokenize(item["title"]) * CONFIG.title_weight + tokenize(item["content"])) for item in corpus ] scores = bm25(counters, terms) ranked = sorted( (index for index, score in enumerate(scores) if score > 0), key=lambda index: (-scores[index], corpus[index]["knowledge_id"]), ) results = [] for rank, index in enumerate(ranked[: payload.top_k], start=1): item = corpus[index] matched = [term for term in terms if counters[index][term]] snippet, start, end = excerpt(item["content"], matched) results.append( RetrievalHit( **{key: value for key, value in item.items() if key != "content"}, rank=rank, score=round(scores[index], 8), matched_terms=matched, snippet=snippet, snippet_start=start, snippet_end=end, ) ) response = RetrievalResponse( run_id=uuid4(), query=payload.query, mode="keyword", status="ok" if results else "no_evidence", top_k=payload.top_k, category=payload.category, query_terms=terms, corpus_size=len(corpus), corpus_fingerprint=fingerprint, total_hits=len(ranked), results=results, elapsed_ms=round((perf_counter() - started) * 1000, 3), created_at=utc_now(), config=CONFIG, ) db.add( RetrievalRun( id=str(response.run_id), query=response.query, mode=response.mode, corpus_fingerprint=fingerprint, response=response.model_dump(mode="json"), created_at=response.created_at, ) ) db.commit() return response