Files
my_bookmate/backend/app/rag/engine.py
T

287 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import jieba
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import async_session
from app.models.book import Book, Chapter, Segment
from app.rag.vector import (
embed_texts, rerank_documents, load_vectors, vector_search,
rrf_fuse, ensure_embeddings, get_status, RERANK_ENABLED,
)
CITATION_RE = re.compile(r"\[\[ref:(\d+)\]\]")
# 保守停用词:只过滤纯功能词,避免误删有检索价值的实词
STOPWORDS = set(
"的 了 呢 吗 吧 啊 呀 嘛 哦 啦 哈 么 这 那 是 在 和 与 或 之 对 从 有 就 都 也 很 还 又 而 却 但 但是 然而 因此 所以 因为 如果 我们 你们 他们 它们 她们 什么 怎么 为什么 如何 这个 那个 一些 一个 本书 这本书 内容 主题 大概 比如 例如 应该 可能 可以 时候 里面 当中 其中 一些 部分 相关 关于 请问 告诉 解释 介绍 总结 分析".split()
)
def _tokenize(query: str) -> list[str]:
"""分词:jieba 词 + 整句(精确子串)+ 双字 bigram,过滤停用词与纯符号。"""
tokens: set[str] = set()
for w in jieba.cut(query):
w = w.strip().lower()
if not w or w in STOPWORDS or re.fullmatch(r"[\s\W_]+", w):
continue
if len(w) >= 2:
tokens.add(w)
q = query.strip().lower()
if len(q) >= 4 and q not in STOPWORDS:
tokens.add(q)
# 中文连续串生成 bigram,增强长句召回(SQL LIKE 无法分词匹配)
for part in re.findall(r"[一-鿿]+", q):
if len(part) >= 4:
tokens.update(part[i:i + 2] for i in range(len(part) - 1))
# 过滤 bigram 中出现的停用字组合(如「的」开头的)
tokens = {t for t in tokens if not any(c in STOPWORDS for c in t) or len(t) >= 4}
return list(tokens)
def match_chapters(chapters: list[Chapter], query: str, tokens: list[str] | None = None) -> list[Chapter]:
"""章节标题匹配:查询词命中标题,或标题词命中查询。按目录顺序返回。"""
if not chapters:
return []
if tokens is None:
tokens = _tokenize(query)
q = query.lower()
hits = []
for ch in chapters:
title = (ch.title or "").strip().lower()
if not title or len(title) < 2:
continue
if any(w and w in title for w in tokens):
hits.append(ch)
continue
title_tokens = [
w.strip() for w in jieba.cut(title)
if len(w.strip()) >= 2 and w.strip() not in STOPWORDS
]
if any(tw in q for tw in title_tokens):
hits.append(ch)
hits.sort(key=lambda c: c.order_idx)
return hits
async def _keyword_candidates(db: AsyncSession, book_id: str, tokens: list[str], limit: int) -> list[Segment]:
"""关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。"""
if not tokens:
return []
ordered = sorted(tokens, key=len, reverse=True)[:8]
conds = [Segment.text.contains(w) for w in ordered]
q = (
select(Segment)
.where(Segment.book_id == book_id, or_(*conds))
.order_by(Segment.order_idx)
.limit(limit * 4)
)
result = await db.execute(q)
segs = list(result.scalars().all())
scored = []
for s in segs:
text = s.text.lower()
score = sum(3 if len(w) >= 4 else 1 for w in ordered if w in text)
scored.append((score, s))
scored.sort(key=lambda x: -x[0])
return [s for _, s in scored[:limit]]
async def _empty_fallback(db: AsyncSession, book_id: str, chapters: list[Chapter], top_k: int) -> list[Segment]:
"""空检索兜底:返回目录最靠前的章节的开头段(全书开头),让 AI 至少有所依据。"""
result = await db.execute(
select(Segment)
.where(Segment.book_id == book_id)
.order_by(Segment.order_idx)
.limit(top_k * 3)
)
segs = [s for s in result.scalars().all() if (s.text or "").strip()]
return segs[:top_k]
async def search_segments(
db: AsyncSession,
book_id: str,
query: str,
top_k: int = 15,
chapters: list[Chapter] | None = None,
) -> list[Segment]:
"""混合检索:jieba 关键词 + API 向量余弦 → RRF 融合 → rerank 精排 → top_k。
未配置 / 未生成向量 / API 失败时自动降级为纯关键词检索,绝不空手返回。
"""
tokens = _tokenize(query)
# 懒补向量(后台任务没跑完 / 重启后缺失时)
if get_status(book_id) != "done":
# 用独立会话执行:ensure_embeddings 失败时的 rollback 会过期会话内已加载的实例,
# 不能让调用方持有的 book/chapters 一起被过期(异步惰性访问会抛 MissingGreenlet)
async with async_session() as embed_session:
status = await ensure_embeddings(embed_session, book_id)
if status == "running":
# 后台正在生成,本次先用关键词,避免阻塞
status = "failed"
# 1. 关键词候选
kw_candidates = await _keyword_candidates(db, book_id, tokens, top_k * 3)
# 2. 向量候选
vec_candidates: list[Segment] = []
if tokens:
qvec = await embed_texts([query])
if qvec and qvec[0]:
mat, vec_segments = await load_vectors(db, book_id)
if mat is not None and len(mat) > 0:
idxs = vector_search(qvec[0], mat, top_k * 3)
vec_candidates = [vec_segments[i] for i in idxs]
# 3. RRF 融合
if kw_candidates and vec_candidates:
by_id = {s.id: s for s in kw_candidates + vec_candidates}
fused = rrf_fuse(
[s.id for s in kw_candidates],
[s.id for s in vec_candidates],
)
merged = [by_id[sid] for sid in fused if sid in by_id]
elif vec_candidates:
merged = vec_candidates
else:
merged = kw_candidates
# 4. rerank 精排(对融合后的 top50 段打分)
if merged and RERANK_ENABLED:
docs = [s.text[:800] for s in merged[:50]]
idxs = await rerank_documents(query, docs, top_n=top_k)
if idxs is not None:
merged = [merged[i] for i in idxs if i < len(merged)]
# 5. 兜底:完全没召回时给全书开头段,而不是空着
if not merged:
merged = await _empty_fallback(db, book_id, chapters or [], top_k)
return merged[:top_k]
def _chapter_full_text(chapter_segments: list[Segment], budget_tokens: int = 6000) -> str:
"""整章注入:按 token 预算截取命中章节的原文。"""
parts = []
used = 0
for seg in chapter_segments:
t = seg.text
cost = estimate_tokens(t)
if parts and used + cost > budget_tokens:
break
parts.append(t)
used += cost
return "\n\n".join(parts)
def build_chat_prompt(
book: Book,
chapters: list[Chapter],
segments: list[Segment],
history: list[dict],
query: str,
chapter_segments: list[Segment] | None = None,
) -> list[dict]:
system = f"""# 你是 BookMate,一个帮助用户阅读书籍的 AI 助手。
## 当前书籍
- 书名:《{book.title or '未知'}》
- 作者:{book.author or '未知'}
- 总字数:约 {book.char_count:,} 字
## 目录结构
"""
for ch in chapters[:40]:
indent = " " * (ch.level - 1)
system += f"{indent}- {ch.title or '(无标题)'}\n"
if len(chapters) > 40:
system += f" ...(共 {len(chapters)} 章)\n"
# 命中章节的整章原文:全局性问题(如「第3章讲了什么」)直接给全文
chapter_text = _chapter_full_text(chapter_segments or [])
if chapter_text:
system += "\n## 与问题直接相关的整章原文\n"
system += chapter_text + "\n"
if segments:
system += "\n## 检索到的相关原文段落\n"
for i, seg in enumerate(segments, 1):
text_preview = seg.text[:800].replace("\n", " ")
system += f"\n[[ref:{i}]] {text_preview}\n"
else:
system += (
"\n## 检索说明\n"
"本次没有检索到与问题直接匹配的原文段落。请基于上方目录结构、"
"书名与你的常识,指出书中哪些章节最可能涉及该主题,"
"并明确说明「这是基于目录的推断」,不要编造具体原文内容。\n"
)
system += """
## 回答要求
1. 优先基于「检索到的相关原文段落」和「整章原文」回答,不要臆造书里没有的内容
2. 引用原文时必须带 [[ref:N]] 标记(N 对应上方段落编号);同一段落只需引用一次,不要反复出现相同的 [[ref:N]];整个回答的引用控制在 5 条左右,只引用你真正依据的段落
3. 如果检索内容不足以回答,坦诚说明,并给出基于目录的建议
4. 用中文回答
5. 当用户要求「思维导图 / 脑图 / mind map」时,输出 mermaid mindmap 语法,放在 ```mermaid 代码块里。格式:第一行写 `mindmap`,根节点写 `root((主题))`,子节点通过缩进表示层级,例如:
```mermaid
mindmap
root((主题))
一级分支
二级分支
```
不要把其他解释文字塞进代码块,代码块外可以有简短说明。"""
messages = [{"role": "system", "content": system}]
if history:
messages.extend(history[-10:])
messages.append({"role": "user", "content": query})
return messages
def _trim_messages(messages: list[dict], budget_tokens: int) -> list[dict]:
"""内容感知裁剪:保留 system(含检索原文)与最新提问,从最旧历史开始丢弃。
替代原先粗糙的 `messages[:1] + messages[-(len//2):]`,避免裁掉检索上下文。
"""
if estimate_tokens("".join(m["content"] for m in messages)) <= budget_tokens:
return messages
system = messages[0]
tail = messages[-1]
used = estimate_tokens(system["content"] + tail["content"])
kept = []
for m in reversed(messages[1:-1]):
cost = estimate_tokens(m["content"])
if used + cost <= budget_tokens:
kept.append(m)
used += cost
else:
break
kept.reverse()
return [system] + kept + [tail]
def parse_citations(content: str, segments: list[Segment]) -> dict:
citations = []
for m in CITATION_RE.finditer(content):
idx = int(m.group(1)) - 1
if 0 <= idx < len(segments):
seg = segments[idx]
citations.append({
"segment_id": seg.id,
"cfi": seg.cfi,
"excerpt": seg.text[:200],
})
clean_text = CITATION_RE.sub("", content)
return {"text": clean_text, "citations": citations}
def estimate_tokens(text: str) -> int:
"""粗略估算 token 数:中文每字约 1.5 token,英文每词约 1.3 token"""
chinese_chars = len(re.findall(r"[一-鿿]", text))
english_words = len(re.findall(r"[a-zA-Z]+", text))
return int(chinese_chars * 1.5 + english_words * 1.3 + (len(text) - chinese_chars - len(re.findall(r"[a-zA-Z]", text))) * 0.5)