Files
WiseGrade/backend/wisegrade/rag.py
T

436 lines
22 KiB
Python

"""Indexing and hybrid retrieval with page-type aware candidate filtering.
Chinese keyword recall does not use PostgreSQL ``to_tsvector('simple', …)``. That
configuration has no Chinese segmenter, so a whole run of Chinese characters becomes a
single token; measured on the course reports, the query ``文献调研`` does not match the
page that contains ``第2章文献调研与技术现状``. Recall therefore uses character bigrams
plus whole ASCII/number tokens, which needs no extension and behaves the same on
PostgreSQL and SQLite.
Semantic and keyword scores are fused by rank (reciprocal rank fusion), never by adding
raw numbers: a cosine similarity and a bigram overlap are not on a common scale.
"""
from functools import lru_cache
from pathlib import Path
import re
from sqlalchemy import case, cast, delete, func, literal, or_, select
from .config import get_settings
from .models import KnowledgeChunk, KnowledgeDocument, KnowledgePage
from .pages import (BLOCK_BODY, BLOCK_TOC, classify_document, is_toc_entry,
normalize_heading, searchable_pages)
INDEX_VERSION = 3
#: Fusion constant for reciprocal rank fusion; the usual value from the IR literature.
RRF_K = 60
#: Candidate pools are gathered independently so both retrievers get a chance.
SEMANTIC_POOL = 60
KEYWORD_POOL = 60
#: A chunk must be at least this long to count as substantive content evidence.
MIN_CHUNK_CHARS = 60
#: At most this many chunks from one page in the first retrieval pass.
DEFAULT_PAGE_LIMIT = 2
#: Soft cap on chunks from one section once a reliable section path exists.
DEFAULT_SECTION_CAP = 3
#: Maximum chunks handed to the model for one criterion, across all targets.
SCORE_EVIDENCE_LIMIT = 14
_ASCII_TOKEN = re.compile(r"[A-Za-z][A-Za-z0-9_+\-.]*|\d+(?:\.\d+)*")
_CJK_RUN = re.compile(r"[㐀-䶿一-鿿]+")
def query_terms(query: str) -> tuple[list[str], list[str]]:
"""Split a query into (ascii tokens lowercased, CJK character bigrams).
Single-character CJK runs are dropped: they carry too little signal and would make
every chunk with a common character look relevant.
"""
value = str(query or "")
ascii_tokens = [token.lower() for token in _ASCII_TOKEN.findall(value)]
bigrams: list[str] = []
seen = set()
for run in _CJK_RUN.findall(value):
for index in range(len(run) - 1):
bigram = run[index:index + 2]
if bigram not in seen:
seen.add(bigram)
bigrams.append(bigram)
return sorted(set(ascii_tokens)), bigrams
def _keyword_query(db, filters, query: str, limit: int):
"""Keyword recall ranked by bigram/token overlap over the already-filtered rows.
PostgreSQL's ``to_tsvector('simple', …)`` cannot segment Chinese, so it is not used
for recall. The overlap is computed with ``strpos``/``substr``/``generate_series``
so the same expression works on every dialect SQLAlchemy targets here.
"""
ascii_tokens, bigrams = query_terms(query)
if not ascii_tokens and not bigrams:
return []
total = len(ascii_tokens) + len(bigrams)
score = None
for token in ascii_tokens:
hit = case((func.strpos(func.lower(KnowledgeChunk.text), literal(token)) > 0, 1.0), else_=0.0)
score = hit if score is None else score + hit
if bigrams:
series = select(func.generate_series(1, func.greatest(func.length(KnowledgeChunk.text) - 1, 0)).label("i")).subquery("series")
matches = (select(func.count()).select_from(series)
.where(func.substr(KnowledgeChunk.text, series.c.i, 2).in_(bigrams)).scalar_subquery())
score = matches if score is None else score + matches
score = (score / float(total)).label("keyword")
rows = db.execute(select(KnowledgeChunk.id, score).where(*filters)
.order_by(score.desc(), KnowledgeChunk.id.asc()).limit(limit)).all()
return [row[0] for row in rows if float(row[1] or 0) > 0]
def split_pages(pages: list[dict], *, size=650, overlap=100):
"""Split pages into searchable chunks carrying page type and section path."""
for page in pages:
page_type = page.get("page_type") or "body"
chapter = page.get("chapter", "")
section = page.get("section", "")
blocks = page.get("blocks")
if not blocks:
blocks = [{"kind": BLOCK_BODY, "text": str(page.get("text") or "").strip()}]
blocks.extend({"kind": BLOCK_BODY, "text": "表格: " + str(table.get("rows", table))}
for table in page.get("tables", []))
for block in blocks:
if block["kind"] == BLOCK_TOC:
# A contents page is navigation, so its text is not body content.
yield {"page": page["page_number"], "page_type": "toc", "block_type": BLOCK_TOC,
"chapter": chapter, "section": section, "text": block["text"]}
continue
text_block = block["text"].strip()
if not text_block:
continue
for table in ([block["tables"]] if block.get("tables") else []):
for table_item in table:
text_block += "\n表格: " + str(table_item.get("rows", table_item))
# Table rows of a page are appended once, on the page's first body chunk.
start = 0
while start < len(text_block):
yield {"page": page["page_number"], "page_type": page_type, "block_type": BLOCK_BODY,
"chapter": chapter, "section": section, "text": text_block[start:start + size]}
if start + size >= len(text_block):
break
start += size - overlap
def build_chunks(pages: list[dict]) -> list[dict]:
"""Chunks for one document, classifying pages first when they are not yet typed."""
prepared = pages if pages and "page_type" in (pages[0] or {}) else searchable_pages(pages)
return list(split_pages(prepared))
@lru_cache(maxsize=2)
def local_encoder(model_path: str):
path = Path(model_path)
if not path.is_dir() or not any(path.iterdir()):
raise RuntimeError(f"离线嵌入模型不存在: {path};请挂载本地模型包")
from sentence_transformers import SentenceTransformer
return SentenceTransformer(str(path), local_files_only=True, trust_remote_code=False)
def embed(texts: list[str], *, model_path: str | None = None):
if not texts:
return []
model = local_encoder(model_path or str(get_settings().embedding_model_path))
vectors = model.encode(texts, normalize_embeddings=True)
output = []
for vector in vectors:
values = vector.tolist()
if len(values) > 1024:
raise RuntimeError("嵌入维度超过pgvector列容量1024")
output.append(values + [0.0] * (1024 - len(values)))
return output
def ingest(db, *, course_version_id: int, project_id: int | None, submission_file_id: int | None,
title: str, category: str, source_hash: str, pages: list[dict], embedder=embed):
existing = db.scalar(select(KnowledgeDocument).where(KnowledgeDocument.course_version_id == course_version_id,
KnowledgeDocument.project_id == project_id, KnowledgeDocument.submission_file_id == submission_file_id,
KnowledgeDocument.title == title))
if existing and existing.source_hash == source_hash and existing.index_version == INDEX_VERSION and existing.status == "ready":
return existing
prepared = searchable_pages(pages)
chunks = list(split_pages(prepared))
vectors = embedder([chunk["text"] for chunk in chunks])
if len(vectors) != len(chunks):
raise RuntimeError("嵌入向量数量与知识片段数量不一致")
if existing:
db.execute(delete(KnowledgePage).where(KnowledgePage.document_id == existing.id))
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == existing.id))
doc = existing
doc.document_type, doc.source_hash = category, source_hash
else:
doc = KnowledgeDocument(course_version_id=course_version_id, project_id=project_id, submission_file_id=submission_file_id,
title=title, document_type=category, source_hash=source_hash)
db.add(doc)
doc.index_version, doc.status = INDEX_VERSION, "indexing"
db.flush()
for source, prepared_page in zip(pages, prepared):
classification = prepared_page["classification"]
db.add(KnowledgePage(
document_id=doc.id, page=source["page_number"], text=page_text(source),
image_path=relative_image_path(source.get("rendered_image_path")),
page_type=classification["page_type"], mixed=bool(classification["mixed"]),
section=classification["section"], chapter=classification["chapter"],
confidence=classification["confidence"],
classification={**classification["signals"], "classifier_version": classification["classifier_version"],
"blocks": [{"kind": block["kind"], "chars": len(block["text"]),
"substantive": block.get("substantive", False)}
for block in classification["blocks"]]}))
for chunk, vector in zip(chunks, vectors):
db.add(KnowledgeChunk(document_id=doc.id, course_version_id=course_version_id, project_id=project_id,
document_type=category, page=chunk["page"], section=chunk["section"],
chapter=chunk["chapter"], page_type=chunk["page_type"],
block_type=chunk["block_type"], text=chunk["text"], embedding=vector))
doc.status = "ready"
db.commit()
return doc
def _scope_filter(project_id: int | None, include_public: bool, public_only: bool):
if public_only:
return KnowledgeChunk.project_id.is_(None)
if include_public:
return or_(KnowledgeChunk.project_id == project_id, KnowledgeChunk.project_id.is_(None))
return KnowledgeChunk.project_id == project_id
def _base_filters(*, course_version_id: int, project_id: int | None, category, categories,
include_public: bool, public_only: bool, allowed_page_types, exclude_document_ids):
filters = [KnowledgeChunk.course_version_id == course_version_id,
_scope_filter(project_id, include_public, public_only)]
if category:
filters.append(KnowledgeChunk.document_type == category)
if categories:
filters.append(KnowledgeChunk.document_type.in_(list(categories)))
if allowed_page_types is not None:
filters.append(KnowledgeChunk.page_type.in_(list(allowed_page_types)))
if exclude_document_ids:
filters.append(KnowledgeChunk.document_id.not_in(list(exclude_document_ids)))
return filters
def _keyword_query(db, filters, query: str, limit: int):
"""Keyword recall ranked by bigram/token overlap over the already-filtered rows.
PostgreSQL's ``to_tsvector('simple', …)`` cannot segment Chinese, so it is not used
for recall. The overlap is computed with ``strpos``/``substr`` so the same expression
works on every dialect SQLAlchemy targets here.
"""
ascii_tokens, bigrams = query_terms(query)
if not ascii_tokens and not bigrams:
return []
total = len(ascii_tokens) + len(bigrams)
score = None
for token in ascii_tokens:
term = literal(token)
hit = case((func.strpos(func.lower(KnowledgeChunk.text), term) > 0, 1.0), else_=0.0)
score = hit if score is None else score + hit
if bigrams:
start = func.generate_series(1, func.greatest(func.length(KnowledgeChunk.text) - 1, 0))
series = select(start.label("i")).subquery("series")
matches = (select(func.count()).select_from(series)
.where(func.substr(KnowledgeChunk.text, series.c.i, 2).in_(bigrams))
.scalar_subquery())
score = matches if score is None else score + matches
score = (score / float(total)).label("keyword")
rows = db.execute(select(KnowledgeChunk.id, score).where(*filters)
.order_by(score.desc(), KnowledgeChunk.id.asc()).limit(limit)).all()
return [row[0] for row in rows if float(row[1] or 0) > 0]
def keyword_overlap(text_value: str, ascii_tokens: list[str], bigrams: list[str]) -> float:
"""Bigram/token overlap used by the non-PostgreSQL path and by tests."""
value = str(text_value or "")
lower = value.lower()
hits = sum(1 for token in ascii_tokens if token in lower)
total = len(ascii_tokens)
hits += sum(1 for bigram in bigrams if bigram in value)
total += len(bigrams)
return hits / total if total else 0.0
def _semantic_query(db, filters, vector, limit: int):
if db.bind.dialect.name == "postgresql":
from pgvector.sqlalchemy import Vector
distance = cast(KnowledgeChunk.embedding, Vector(1024)).cosine_distance(vector)
rows = db.execute(select(KnowledgeChunk.id).where(*filters)
.order_by(distance.asc(), KnowledgeChunk.id.asc()).limit(limit)).all()
return [row[0] for row in rows]
rows = db.execute(select(KnowledgeChunk.id, KnowledgeChunk.embedding).where(*filters)).all()
scored = [(chunk_id, sum(a * b for a, b in zip(embedding or [], vector)))
for chunk_id, embedding in rows]
scored.sort(key=lambda pair: (-pair[1], pair[0]))
return [chunk_id for chunk_id, _ in scored[:limit]]
def _fuse(db, ids: list[int], semantic_ids: list[int], keyword_ids: list[int]):
"""Reciprocal rank fusion over the two ranked lists, keeping discovery order stable."""
scores: dict[int, float] = {chunk_id: 0.0 for chunk_id in ids}
for ranked in (semantic_ids, keyword_ids):
for rank, chunk_id in enumerate(ranked):
if chunk_id in scores:
scores[chunk_id] += 1.0 / (RRF_K + rank + 1)
return scores
def _row_dict(chunk):
return {"chunk_id": chunk.id, "document_id": chunk.document_id, "project_id": chunk.project_id,
"document_type": chunk.document_type, "page": chunk.page, "section": chunk.section,
"chapter": chunk.chapter, "page_type": chunk.page_type, "block_type": chunk.block_type,
"text": chunk.text}
def normalize_hit(hit: dict) -> dict:
"""Fill the fields the rest of the pipeline reads, so one shape flows through.
An alternate retriever or a test double may return only the essentials. Filling the
defaults here keeps every consumer from re-deriving them; the authoritative page type
is still re-read from storage when a score is produced.
"""
return {"chunk_id": hit.get("chunk_id"), "document_id": hit.get("document_id"),
"project_id": hit.get("project_id"), "document_type": hit.get("document_type"),
"page": hit.get("page"), "text": hit.get("text") or "",
"section": hit.get("section") or "", "chapter": hit.get("chapter") or "",
"page_type": hit.get("page_type") or "unknown", "block_type": hit.get("block_type") or "legacy",
"rank": float(hit.get("rank") or 0.0)}
def retrieve(db, *, course_version_id: int, project_id: int, query: str, category: str | None = None,
categories=None, limit=10, embedder=embed, include_public=True, public_only=False,
allowed_page_types=None, exclude_document_ids=(), substantive_only=False,
semantic_pool: int | None = None, keyword_pool: int | None = None):
"""Filtered hybrid retrieval, then rank fusion over the two independent recalls.
Page filtering happens inside the query, so it always precedes the candidate limit:
a contents page can never displace a body page simply by scoring well.
"""
filters = _base_filters(course_version_id=course_version_id, project_id=project_id, category=category,
categories=categories, include_public=include_public, public_only=public_only,
allowed_page_types=allowed_page_types, exclude_document_ids=exclude_document_ids)
if substantive_only:
filters.append(KnowledgeChunk.block_type != BLOCK_TOC)
vector = embedder([query])[0]
semantic_ids = _semantic_query(db, filters, vector, semantic_pool or SEMANTIC_POOL)
keyword_ids = _keyword_query(db, filters, query, keyword_pool or KEYWORD_POOL)
ids = list(dict.fromkeys(semantic_ids + keyword_ids))
if not ids:
return []
scores = _fuse(db, ids, semantic_ids, keyword_ids)
chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.id.in_(ids))).all()
usable = []
for chunk in chunks:
if substantive_only and (chunk.block_type == BLOCK_TOC
or (chunk.page_type == "body" and is_toc_entry(chunk.text)
and len(chunk.text.strip()) < MIN_CHUNK_CHARS)):
continue
usable.append(chunk)
usable.sort(key=lambda chunk: (-scores.get(chunk.id, 0.0), chunk.id))
return [{**_row_dict(chunk), "rank": float(scores.get(chunk.id, 0.0))} for chunk in usable[:limit]]
def _page_diverse(chunks: list[dict], limit: int, *, page_limit: int, section_cap: int | None):
"""Spread the first pass over pages, then allow a bounded number per section."""
picked: list[dict] = []
per_page: dict[tuple, int] = {}
per_section: dict[str, int] = {}
remaining = []
for chunk in chunks:
page_key = (chunk.get("document_id"), chunk.get("page"))
section = str(chunk.get("section") or "")
if len(picked) < limit and per_page.get(page_key, 0) < page_limit:
if section_cap and section and per_section.get(section, 0) >= section_cap:
remaining.append(chunk)
continue
picked.append(chunk)
per_page[page_key] = per_page.get(page_key, 0) + 1
if section:
per_section[section] = per_section.get(section, 0) + 1
else:
remaining.append(chunk)
for chunk in remaining:
if len(picked) >= limit:
break
picked.append(chunk)
return picked[:limit]
def retrieve_targets(db, *, course_version_id: int, project_id: int, targets: list[dict], category=None,
categories=None, limit=SCORE_EVIDENCE_LIMIT, embedder=embed, include_public=False,
page_limit=DEFAULT_PAGE_LIMIT, section_cap=DEFAULT_SECTION_CAP,
per_target_limit: int | None = None, navigator=None, retrieve_fn=retrieve):
"""Search once per required-evidence target and merge with page-level diversity.
Every target is searched separately with its own query and its own allowed page
types, so a contents page permitted for a structural target can never flood a
content target's evidence. A target counts as unmet when its *own* search found
nothing admissible, which is what decides a teacher review.
"""
merged: list[dict] = []
seen: set[int] = set()
per_target: dict[str, list[dict]] = {}
for target in targets:
allowed = target.get("allowed_page_types")
raw_hits = retrieve_fn(db, course_version_id=course_version_id, project_id=project_id, category=category,
categories=categories, query=target.get("query") or target.get("label") or "",
limit=per_target_limit or max(limit, 8), embedder=embed,
include_public=include_public, allowed_page_types=allowed,
substantive_only=target.get("use") != "structure",
semantic_pool=SEMANTIC_POOL, keyword_pool=KEYWORD_POOL)
hits = [normalize_hit(hit) for hit in (raw_hits or [])]
if target.get("use") == "content" and not hits and navigator is not None:
# The contents list may only be used to navigate: its headings are matched
# against headings that really occur in body text, and those sections and
# their neighbouring pages are searched. The quotation still lands on body.
hits = [dict(hit, via="toc_navigation") for hit in (navigator(target) or [])]
per_target[target["target_id"]] = hits
for hit in hits:
if hit["chunk_id"] not in seen:
seen.add(hit["chunk_id"])
merged.append({**hit, "target_id": target["target_id"], "target_label": target["label"],
"target_use": target["use"]})
merged.sort(key=lambda chunk: -chunk["rank"])
selected = _page_diverse(merged, limit, page_limit=page_limit, section_cap=section_cap)
uncovered = [target for target in targets if not per_target.get(target["target_id"])]
return {"evidence": selected, "per_target": per_target, "uncovered_targets": uncovered}
def body_heading_index(db, *, course_version_id: int, project_id: int, category: str | None = None):
"""Sections present in real body text, used to navigate from a contents page."""
filters = [KnowledgeChunk.course_version_id == course_version_id, KnowledgeChunk.project_id == project_id,
KnowledgeChunk.page_type == "body"]
if category:
filters.append(KnowledgeChunk.document_type == category)
rows = db.execute(select(KnowledgeChunk.document_id, KnowledgeChunk.page, KnowledgeChunk.section,
KnowledgeChunk.chapter).where(*filters).distinct()).all()
index: dict[str, list[dict]] = {}
for document_id, page, section, chapter in rows:
for value in (section, chapter):
key = normalize_heading(value or "")
if key:
index.setdefault(key, []).append({"document_id": document_id, "page": page,
"section": section, "chapter": chapter})
return index
def page_text(source: dict) -> str:
blocks = [str(source.get("text") or "").strip()]
blocks.extend("表格: " + str(table.get("rows", table)) for table in source.get("tables", []))
return "\n".join(block for block in blocks if block)
def relative_image_path(value: str | None) -> str | None:
if not value:
return None
root = get_settings().processed_path.resolve()
path = Path(value).resolve()
if not path.is_relative_to(root):
raise ValueError("页面图像路径不在处理目录内")
return str(path.relative_to(root))