Files
my_bookmate/backend/app/rag/engine.py
T
gavindiazandClaude Code 2cda214170 feat: 全局「第二大脑」AI 助手(跨书跨对话 + bm_* 工具 + DSML 兜底)
- Conversation 加 kind(book/assistant)、book_id 可空;SQLite 老表迁移保留数据
- rag/engine.py: search_all_books 跨书混合检索;citations 带 book_title 出处
- app/assistant/service.py: 知识库概览 + 第二大脑 persona + 原生工具
  (bm_list_conversations / bm_read_conversation / bm_search_books),并入 MCP agent 循环
- chat.py: create/list 支持 kind;send_message 委托助手管线
- llm/hub.py: 解析 DeepSeek DSML 文本工具调用(模型非确定性输出标准/DSML)
- mcp/agent.py: 工具轮中段追加强制文本收尾,防模型反复调工具不出答案
- 前端: 侧栏「AI 助手」入口 + 抽取共享 ChatPanel 复用书/助手两套对话 UI

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 03:21:22 +08:00

379 lines
14 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_ids: list[str], tokens: list[str], limit: int) -> list[Segment]:
"""关键词候选:SQL LIKE 召回(含整句子串),按命中词数 + 长词加权打分。
book_ids 传单个书名 id 的列表(单书检索)或多个 id(跨书检索)。
"""
if not tokens or not book_ids:
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.in_(book_ids), 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]
async def _empty_fallback_all(db: AsyncSession, books: list[Book], top_k: int) -> list[Segment]:
"""跨书空检索兜底:每本书取最先的非空开头段,保证 AI 至少有所依据。"""
out: list[Segment] = []
for b in books:
result = await db.execute(
select(Segment)
.where(Segment.book_id == b.id)
.order_by(Segment.order_idx)
.limit(top_k)
)
for s in result.scalars().all():
if (s.text or "").strip():
out.append(s)
break
return out[:top_k]
async def search_all_books(
db: AsyncSession,
query: str,
top_k: int = 12,
books: list[Book] | None = None,
) -> list[Segment]:
"""跨书混合检索(全局「第二大脑」用):关键词跨书一次 SQL + 每书向量余弦 → RRF → rerank。
与 search_segments 的区别:
- 不调用 ensure_embeddings(全局检索不阻塞向量生成,缺向量的书靠关键词兜底);
- 向量部分逐书扫描后合并(query embedding 只算一次)。
"""
tokens = _tokenize(query)
if books is None:
result = await db.execute(select(Book).order_by(Book.created_at))
books = list(result.scalars().all())
book_ids = [b.id for b in books]
if not book_ids:
return []
# 1. 关键词候选(一次 SQL 跨全部书)
kw_candidates = await _keyword_candidates(db, book_ids, tokens, top_k * 4)
# 2. 向量候选(每书全量余弦,合并)
vec_candidates: list[Segment] = []
if tokens:
qvecs = await embed_texts([query])
qvec = qvecs[0] if qvecs else None
if qvec:
for bid in book_ids:
mat, vec_segments = await load_vectors(db, bid)
if mat is not None and len(mat) > 0:
idxs = vector_search(qvec, mat, top_k * 2)
vec_candidates.extend(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_all(db, books, top_k)
# 去重(跨书向量合并可能撞段)
seen: set[str] = set()
out: list[Segment] = []
for s in merged:
if s.id in seen:
continue
seen.add(s.id)
out.append(s)
return out[: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],
# 跨书检索时由调用方在段上临时标注 _book_title,供前端显示出处
"book_title": getattr(seg, "_book_title", None),
})
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)