目标:
解析题目时:
AI会参考:
数学公式
解题方法
知识点定义
再生成答案。
例如:
输入:
ini
解方程 3x + 5 = 11
系统流程:
题目
↓
识别知识点:一元一次方程
↓
检索知识库
↓
AI生成解析
第一步:准备知识库
在 backend 创建:
bash
backend/data/math_knowledge.json
写入:
css
[ { "name": "一元一次方程", "description": "只含有一个未知数,且未知数最高次数为1的方程。", "method": "移项、合并同类项、系数化为1", "formula": "ax + b = 0" }, { "name": "二元一次方程组", "description": "含有两个未知数的一次方程组。", "method": "代入法、加减消元法" }, { "name": "勾股定理", "description": "直角三角形三边关系", "formula": "a² + b² = c²" }, { "name": "一次函数", "description": "函数形式 y = kx + b", "method": "求斜率和截距" }]
第二步:新增 RAG 服务
新增文件:
bash
backend/app/rag_service.py
代码:
python
import json
import os
from typing import List, Dict
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DATA_PATH = os.path.join(BASE_DIR, "data", "math_knowledge.json")
def load_knowledge_base() -> List[Dict]:
if not os.path.exists(DATA_PATH):
return []
with open(DATA_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def score_item(question: str, item: Dict) -> int:
score = 0
text = f"{item.get('title', '')} {' '.join(item.get('keywords', []))} {item.get('content', '')}"
for kw in item.get("keywords", []):
if kw and kw in question:
score += 5
for ch in set(question):
if ch.strip() and ch in text:
score += 1
return score
def retrieve_knowledge(question: str, top_k: int = 3) -> List[Dict]:
kb = load_knowledge_base()
scored = []
for item in kb:
score = score_item(question, item)
if score > 0:
scored.append((score, item))
scored.sort(key=lambda x: x[0], reverse=True)
return [item for _, item in scored[:top_k]]
def build_context(question: str) -> str:
items = retrieve_knowledge(question, top_k=3)
if not items:
return ""
parts = []
for idx, item in enumerate(items, start=1):
parts.append(
f"知识片段{idx}:\n"
f"标题:{item['title']}\n"
f"内容:{item['content']}\n"
)
return "\n".join(parts)
def get_knowledge_titles(question: str) -> List[str]:
items = retrieve_knowledge(question, top_k=3)
return [item["title"] for item in items]
第三步:改造 AI解析
打开:
bash
backend/app/llm_service.py
python
import os
import json
from openai import OpenAI
from dotenv import load_dotenv
from app.rag_service import build_context
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL")
MODEL = os.getenv("OPENAI_MODEL", "moonshot-v1-8k")
client = OpenAI(
api_key=OPENAI_API_KEY,
base_url=OPENAI_BASE_URL,
)
SYSTEM_PROMPT = """
你是一位专业的初中数学辅导老师。
请严格返回 JSON,格式如下:
{
"answer": "最终答案",
"steps": ["步骤1", "步骤2", "步骤3"],
"knowledge_points": ["知识点1", "知识点2"],
"similar_question": "一道类似的新题目"
}
要求:
1. 只返回 JSON,不要返回 markdown,不要加 ```json
2. steps 必须清晰易懂,适合初中学生
3. knowledge_points 只返回核心知识点
4. similar_question 必须和原题难度接近
5. 如果题目不清晰,也要尽量给出合理提示,并保持 JSON 格式
"""
def solve_math_question(question: str):
if not OPENAI_API_KEY:
raise ValueError("未读取到 OPENAI_API_KEY")
if not OPENAI_BASE_URL:
raise ValueError("未读取到 OPENAI_BASE_URL")
if not MODEL:
raise ValueError("未读取到 OPENAI_MODEL")
context = build_context(question)
user_content = f"题目:{question}"
if context:
user_content += f"\n\n以下是可参考的知识库内容,请优先基于这些内容解答:\n{context}"
resp = client.chat.completions.create(
model=MODEL,
temperature=0.3,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
)
content = (resp.choices[0].message.content or "").strip()
if content.startswith("```json"):
content = content.removeprefix("```json").strip()
if content.startswith("```"):
content = content.removeprefix("```").strip()
if content.endswith("```"):
content = content.removesuffix("```").strip()
if not content:
raise ValueError("模型返回为空")
try:
data = json.loads(content)
except Exception:
raise ValueError(f"模型返回的不是合法 JSON:{content}")
for key in ["answer", "steps", "knowledge_points", "similar_question"]:
if key not in data:
raise ValueError(f"模型返回缺少字段 {key}:{data}")
if not isinstance(data["steps"], list):
raise ValueError(f"steps 不是数组:{data}")
if not isinstance(data["knowledge_points"], list):
raise ValueError(f"knowledge_points 不是数组:{data}")
return data
这样 AI 会参考知识库。
测试下 没问题

更进一步 RAG向量数据库版
先做一个本地可跑、无需单独起 Docker 的版本:
- 向量库:Qdrant 本地模式
- 向量模型:bge-small-zh-v1.5
- 知识库文件:继续用你现有
backend/data/math_knowledge.json - 目标:把现在的"关键词匹配版 RAG"升级成"语义检索版 RAG"
1)安装依赖
在 backend 目录执行:
pip install qdrant-client sentence-transformers
2)新增 backend/app/rag_service.py
ini
import json
import os
from typing import List, Dict
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from sentence_transformers import SentenceTransformer
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DATA_PATH = os.path.join(BASE_DIR, "data", "math_knowledge.json")
QDRANT_PATH = os.path.join(BASE_DIR, "qdrant_data")
COLLECTION_NAME = "math_knowledge"
_model = None
_client = None
def get_embedding_model():
global _model
if _model is None:
_model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
return _model
def get_qdrant_client():
global _client
if _client is None:
_client = QdrantClient(path=QDRANT_PATH)
return _client
def load_knowledge_base() -> List[Dict]:
if not os.path.exists(DATA_PATH):
return []
with open(DATA_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def build_doc_text(item: Dict) -> str:
parts = [
f"标题:{item.get('title', '')}",
f"关键词:{'、'.join(item.get('keywords', []))}",
f"内容:{item.get('content', '')}",
]
return "\n".join(parts)
def ensure_collection():
client = get_qdrant_client()
model = get_embedding_model()
vector_size = model.get_sentence_embedding_dimension()
collections = client.get_collections().collections
exists = any(c.name == COLLECTION_NAME for c in collections)
if not exists:
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE),
)
def rebuild_index():
client = get_qdrant_client()
model = get_embedding_model()
ensure_collection()
kb = load_knowledge_base()
client.delete_collection(collection_name=COLLECTION_NAME)
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(
size=model.get_sentence_embedding_dimension(),
distance=Distance.COSINE,
),
)
if not kb:
return {"count": 0}
points = []
docs = [build_doc_text(item) for item in kb]
vectors = model.encode(docs, normalize_embeddings=True).tolist()
for idx, (item, vector, doc_text) in enumerate(zip(kb, vectors, docs), start=1):
points.append(
PointStruct(
id=idx,
vector=vector,
payload={
"title": item.get("title", ""),
"keywords": item.get("keywords", []),
"content": item.get("content", ""),
"doc_text": doc_text,
},
)
)
client.upsert(collection_name=COLLECTION_NAME, points=points)
return {"count": len(points)}
def ensure_index_ready():
ensure_collection()
client = get_qdrant_client()
count_result = client.count(collection_name=COLLECTION_NAME, exact=True)
if count_result.count == 0:
rebuild_index()
def retrieve_knowledge(question: str, top_k: int = 3):
ensure_index_ready()
client = get_qdrant_client()
model = get_embedding_model()
query_vector = model.encode(question, normalize_embeddings=True).tolist()
results = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
limit=top_k,
with_payload=True,
)
items = []
for item in results.points:
payload = item.payload or {}
items.append(
{
"title": payload.get("title", ""),
"keywords": payload.get("keywords", []),
"content": payload.get("content", ""),
"score": float(item.score),
}
)
return items
def build_context(question: str, top_k: int = 3) -> str:
items = retrieve_knowledge(question, top_k=top_k)
if not items:
return ""
parts = []
for idx, item in enumerate(items, start=1):
parts.append(
f"知识片段{idx}:\n"
f"标题:{item['title']}\n"
f"关键词:{'、'.join(item.get('keywords', []))}\n"
f"内容:{item['content']}\n"
f"相关度:{item['score']:.4f}\n"
)
return "\n".join(parts)
def get_knowledge_titles(question: str, top_k: int = 3) -> List[str]:
items = retrieve_knowledge(question, top_k=top_k)
return [item["title"] for item in items if item.get("title")]
3)修改 backend/app/llm_service.py
如果你前面已经加过 build_context,这里只需要确认 import 和函数内容。
3.1 import 部分确认有这一行
javascript
from app.rag_service import build_context, get_knowledge_titles
3.2 替换 solve_math_question
scss
def solve_math_question(question: str):
if not OPENAI_API_KEY:
raise ValueError("未读取到 OPENAI_API_KEY")
if not OPENAI_BASE_URL:
raise ValueError("未读取到 OPENAI_BASE_URL")
if not MODEL:
raise ValueError("未读取到 OPENAI_MODEL")
context = build_context(question, top_k=3)
matched_knowledge = get_knowledge_titles(question, top_k=3)
user_content = f"题目:{question}"
if context:
user_content += f"\n\n以下是从数学知识库中检索到的参考内容,请优先基于这些内容解答:\n{context}"
resp = client.chat.completions.create(
model=MODEL,
temperature=0.3,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
)
content = (resp.choices[0].message.content or "").strip()
if content.startswith("```json"):
content = content.removeprefix("```json").strip()
if content.startswith("```"):
content = content.removeprefix("```").strip()
if content.endswith("```"):
content = content.removesuffix("```").strip()
if not content:
raise ValueError("模型返回为空")
try:
data = json.loads(content)
except Exception:
raise ValueError(f"模型返回的不是合法 JSON:{content}")
for key in ["answer", "steps", "knowledge_points", "similar_question"]:
if key not in data:
raise ValueError(f"模型返回缺少字段 {key}:{data}")
if not isinstance(data["steps"], list):
raise ValueError(f"steps 不是数组:{data}")
if not isinstance(data["knowledge_points"], list):
raise ValueError(f"knowledge_points 不是数组:{data}")
data["matched_knowledge"] = matched_knowledge
return data
4)修改 backend/app/main.py
4.1 import 新增
javascript
from app.rag_service import rebuild_index, ensure_index_ready
4.2 补初始化
scss
ensure_index_ready()
4.3 新增一个"重建向量索引"接口
加到 main.py 里:
python
@app.post("/api/rag/rebuild")
def rebuild_rag_index():
try:
result = rebuild_index()
return {
"message": "RAG 向量索引重建成功",
"count": result["count"],
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
5)修改知识库文件 backend/data/math_knowledge.json
如果你现在内容比较简单使用这个向量检索的版本:
css
[ { "title": "一元一次方程基础", "keywords": ["方程", "一元一次方程", "解方程", "移项", "合并同类项", "系数化为1"],
"content": "一元一次方程是只含有一个未知数,并且未知数的次数是1的方程。常见解题步骤包括:移项、合并同类项、把未知数系数化为1。标准形式常见为 ax + b = 0。"
},
{
"title": "二元一次方程组",
"keywords": ["二元一次方程组", "代入消元", "加减消元", "消元法"],
"content": "二元一次方程组通常有两个未知数,常用解法有代入消元法和加减消元法。先消去一个未知数,再求另一个未知数,最后代回验证。"
},
{
"title": "分数加减法",
"keywords": ["分数", "通分", "分数加减", "约分"],
"content": "分数加减法的核心步骤是先通分,再将分子进行加减,最后如果结果可以约分,需要进行约分。"
},
{
"title": "一次函数基础",
"keywords": ["一次函数", "y=kx+b", "斜率", "截距", "函数图像"],
"content": "一次函数通常表示为 y = kx + b。k 表示斜率,b 表示与 y 轴的交点。判断函数增减性主要看 k 的正负。"
},
{
"title": "勾股定理",
"keywords": ["勾股定理", "直角三角形", "平方和", "斜边"],
"content": "在直角三角形中,两条直角边的平方和等于斜边的平方,即 a² + b² = c²。常用于求边长、判断三角形类型。"
},
{
"title": "三角形内角和",
"keywords": ["三角形", "内角和", "180度", "几何"],
"content": "任意三角形的内角和都等于180度。求角度时常结合已知角、外角性质和等腰三角形性质一起使用。"
}
]
6)前端加一个"重建知识库索引"按钮
6.1 修改 frontend/src/api/math.ts
新增:
javascript
export function rebuildRagIndex() {
return request.post('/api/rag/rebuild')
}
6.2 修改 src/components/StudentBar.vue
script import 不用改
template 里,在"导出练习单"按钮后面再加一个:
kotlin
<button class="retry-btn" @click="$emit('rebuild-rag')">
重建知识库
</button>
emits
arduino
(e: 'rebuild-rag'): void
6.3 修改 src/App.vue
import 补上
rebuildRagIndex,
template 中 StudentBar 补一个事件
ini
@rebuild-rag="handleRebuildRag"
script 里新增方法
typescript
const handleRebuildRag = async () => {
try {
const { data } = await rebuildRagIndex()
alert(data?.message || '知识库重建成功')
} catch (error: any) {
console.error('重建知识库失败:', error)
alert(error?.response?.data?.detail || '重建知识库失败')
}
}
7)现在你要执行的命令
在 backend 目录:
lua
pip install qdrant-client sentence-transformers
uvicorn app.main:app --reload --port 8000
8)怎么验证已经是"向量版 RAG"
你可以测试这些题:
测试 1
ini
解方程 3x + 5 = 11
应该命中:
- 一元一次方程基础
测试 2
在直角三角形中,两条直角边分别是3和4,斜边是多少?
应该命中:
- 勾股定理
测试 3
ini
已知一次函数 y = 2x + 1,求 x = 3 时 y 的值
应该命中:
- 一次函数基础
9)和你之前版本的区别
之前你是:
关键词匹配
现在变成:
语义向量检索

功能正常 !