436 lines
22 KiB
Python
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))
|