1. 从一份考核办法 PDF 说起:为什么要自己搭 RAG 问答
大模型本身存在训练时效问题,对最新数据以及企业内部数据库无法实现很好的问答。你手里有一份几十页的《客户经理考核办法》PDF,直接丢给通用对话模型问「基础薪点最低占比是多少」,它要么答不上来,要么一本正经地编一个数字给你。RAG(检索增强生成)解决的正是这件事:把私有文档切片、向量化、存进向量库,用户提问时先检索出相关片段,再把「检索内容 + 用户提问」一起交给大模型生成答案。
这篇要落地的是一个完整的智能文档问答助手:用 Streamlit 做交互界面,用 Chroma 做向量检索,通过 TaoToken 统一 Key/API 通道接入模型,并设计一套可复现的评测流程。适合谁?适合已经会用 Python、想把自己手头的制度文档、产品手册、技术规范变成「能问答的知识库」的开发者。读完你能拿到:可复制的配置骨架、依赖清单、启动命令,以及检索命中率与回答质量的量化验证动作。
我试过把同一份 PDF 分别喂给纯对话模型和这套 RAG 流程,前者在「综合多段题」上几乎全军覆没,后者能把分散在第七章、第八章的条款拼起来回答。差别就在检索这一层。
2. 整体架构与 TaoToken 前置准备
2.1 数据流拆解
整条链路分两个阶段。入库阶段:选择文件 → 文件处理 → 文档切片 → 存入关系库 → 向量化 → 存入 Chroma。问答阶段分两种模式:普通问答模式是「用户提问 → LLM 生成答案 → 回复」;知识库问答模式是「用户提问 → 检索向量库 → 返回检索内容 + 用户提问 → LLM → 回复」。
工具选型上,文档分割用递归字符分割(RecursiveCharacterTextSplitter),按\n\n → \n → 句号 → 逗号 → 空格的优先级逐层切割,保证句子不被粗暴截断;向量化用本地开源嵌入模型,完全离线、免费无调用限额;向量库用 Chroma,零部署、API 极简,自动存储文档加元数据;关系库用 MySQL 配合 SQLAlchemy 存原文档和分片;问答模型走 TaoToken 的统一通道;前端用 Streamlit,不用学 JS/HTML,Python 直接写页面,一键启动。
2.2 为什么用 TaoToken 统一通道
模型调用这块最容易踩的坑是:今天用这家、明天换那家,每换一次就要改 base_url、改鉴权头、改参数名。TaoToken 提供统一的 Key 和 API 通道,兼容 OpenAI 调用方式,你只需要维护一份配置,切换模型时改一个 model 字段即可。对做评测尤其重要——评测要跑几十上百条用例,通道稳定、计费透明,才能保证结果可复现。
先拿到访问凭证。打开控制台创建 API Key:
控制台入口:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
创建完 Key 后,接入文档在这里,里面有各语言的调用示例和参数说明:
接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
API 的基础地址是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 OpenAI SDK 的base_url使用。如果你打算长期跑编码类、Agent 类任务,可以了解下 Coding Plan,额度模型更适合高频调用:
Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
想先验证模型通不通、回答风格合不合适,可以直接在模型对话页面试几条:
模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite
3. 可复制的配置骨架与依赖清单
3.1 目录结构
rag-assistant/ ├── app.py # Streamlit 入口 ├── config/ │ └── config.py # 配置类 ├── core/ │ ├── rag_system.py # RAG 主流程 │ ├── vector_store.py # Chroma 封装 │ ├── database.py # MySQL 封装 │ └── splitter.py # 文档切片 ├── eval/ │ ├── benchmark.json # 评测数据集 │ └── ragas_eval.py # 评测脚本 ├── requirements.txt └── .env3.2 配置文件骨架
把敏感信息放.env,代码里只读占位符。下面这份config.py是骨架,API 地址和 Key 都留了占位:
# config/config.py import os from dotenv import load_dotenv load_dotenv() class Config: # ---- TaoToken 统一通道 ---- # 基础地址固定为 https://taotoken.net/api,不要加斜杠后缀 taotoken_base_url: str = os.getenv("TAOTOKEN_BASE_URL", "https://taotoken.net/api") taotoken_api_key: str = os.getenv("TAOTOKEN_API_KEY", "sk-你的Key占位") llm_model: str = os.getenv("LLM_MODEL", "qwen3.6-max-preview") # ---- 向量与切片 ---- embedding_model: str = "dengcao/Qwen3-Embedding-0.6B:Q8_0" chunk_size: int = 500 chunk_overlap: int = 80 top_k: int = 5 # ---- Chroma ---- chroma_persist_dir: str = "./chroma_db" collection_name: str = "doc_qa" # ---- MySQL ---- mysql_url: str = os.getenv( "MYSQL_URL", "mysql+pymysql://root:password@127.0.0.1:3306/rag_db?charset=utf8mb4" )对应的.env:
TAOTOKEN_BASE_URL=https://taotoken.net/api TAOTOKEN_API_KEY=sk-替换成你自己的Key LLM_MODEL=qwen3.6-max-preview MYSQL_URL=mysql+pymysql://root:password@127.0.0.1:3306/rag_db?charset=utf8mb43.3 依赖清单
streamlit>=1.32.0 chromadb>=0.4.24 langchain>=0.2.0 langchain-community>=0.2.0 langchain-openai>=0.1.0 langchain-ollama>=0.1.0 sqlalchemy>=2.0.0 pymysql>=1.1.0 pypdf>=4.0.0 python-dotenv>=1.0.0 datasets>=2.18.0 ragas>=0.1.7 pandas>=2.0.0安装:
pip install -r requirements.txt3.4 文档切片与向量入库
切片是检索质量的地基。chunk_size太小会丢上下文,太大则检索精度下降。制度类文档建议 400–600 字符,重叠 60–100 字符:
# core/splitter.py from langchain.text_splitter import RecursiveCharacterTextSplitter def build_splitter(chunk_size: int = 500, chunk_overlap: int = 80): return RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=["\n\n", "\n", "。", ";", ",", " ", ""], length_function=len, )向量库封装,注意 Chroma 的持久化目录要固定,否则每次重启都重建:
# core/vector_store.py import chromadb from langchain_ollama import OllamaEmbeddings class VectorStore: def __init__(self, config): self.client = chromadb.PersistentClient(path=config.chroma_persist_dir) self.collection = self.client.get_or_create_collection( name=config.collection_name, metadata={"hnsw:space": "cosine"}, ) self.embedding = OllamaEmbeddings(model=config.embedding_model) def add_documents(self, doc_id: str, chunks: list[str]): vectors = self.embedding.embed_documents(chunks) ids = [f"{doc_id}_{i}" for i in range(len(chunks))] self.collection.add( ids=ids, embeddings=vectors, documents=chunks, metadatas=[{"doc_id": doc_id} for _ in chunks], ) def query(self, question: str, top_k: int = 5): q_vec = self.embedding.embed_query(question) res = self.collection.query(query_embeddings=[q_vec], n_results=top_k) return res["documents"][0] if res["documents"] else []3.5 通过 TaoToken 调用问答模型
这是整篇最关键的一段。用langchain-openai的ChatOpenAI,把base_url指向 TaoToken 的 API 地址,api_key用你的 Key:
# core/rag_system.py from langchain_openai import ChatOpenAI def build_llm(config): return ChatOpenAI( api_key=config.taotoken_api_key, base_url=config.taotoken_base_url, # https://taotoken.net/api model=config.llm_model, temperature=0, max_tokens=2048, timeout=30, )知识库问答的核心逻辑:先检索,再拼 prompt,最后生成:
PROMPT_TEMPLATE = """你是一个严谨的文档问答助手。请严格依据下面提供的上下文回答问题。 如果上下文中没有相关信息,直接回答「本考核办法未提及该相关规定,暂无对应制度说明」,不要编造。 上下文: {context} 用户问题:{question} """ def chat_with_documents(self, question, session_id, doc_ids): contexts = self.vector_store.query(question, top_k=self.config.top_k) context_text = "\n\n".join(contexts) prompt = PROMPT_TEMPLATE.format(context=context_text, question=question) answer = self.llm.invoke(prompt).content return answer, contexts4. 启动与验证请求
4.1 启动命令
# 1. 启动本地嵌入服务(Ollama) ollama serve ollama pull dengcao/Qwen3-Embedding-0.6B:Q8_0 # 2. 启动 Streamlit streamlit run app.py --server.port 8501浏览器打开http://localhost:8501,上传 PDF,等待切片入库完成,切到「知识库问答模式」提问。
4.2 验证请求是否打通
在写完整评测前,先用一段最小脚本确认 TaoToken 通道可用:
from langchain_openai import ChatOpenAI llm = ChatOpenAI( api_key="sk-你的Key", base_url="https://taotoken.net/api", model="qwen3.6-max-preview", temperature=0, ) resp = llm.invoke("用一句话说明什么是RAG") print(resp.content)能正常打印回答,说明 Key、地址、模型名三者都对上了。如果报 401,检查 Key 是否复制完整;报 404,检查base_url是否误加了/v1或结尾斜杠。
4.3 检索命中率验证
问答质量的上限由检索决定。先单独验证检索层,不掺入生成:
def check_retrieval(question, expected_keyword, top_k=5): docs = vector_store.query(question, top_k=top_k) hit = any(expected_keyword in d for d in docs) print(f"问题:{question}") print(f"命中关键词「{expected_keyword}」:{hit}") for i, d in enumerate(docs, 1): print(f" [{i}] {d[:60]}...") return hit check_retrieval("基础薪点最低占比是多少", "40%")实测下来,单段基础题和口语模糊题的命中率接近 100%,但超长复合题(一个问题里堆了四五个子问题)经常一条都命中不了——因为整句向量化后语义被稀释了。解决办法是查询改写:把复合问题拆成子问题分别检索,再合并上下文。
5. 评测流程:用 RAGAS 量化回答质量
5.1 四大指标
评测覆盖四个维度,满分 1 分,越高越好:
| 指标 | 核心含义 |
|---|---|
| context_precision | 检索到的文档里,和当前问题真正相关的占比 |
| context_recall | 标准答案的关键要点,有多少被检索文档覆盖 |
| faithfulness | 模型回答是否完全基于检索上下文,无编造 |
| answer_relevancy | 最终回答是否精准贴合用户原始提问 |
5.2 Benchmark 数据集结构
数据集设计为标准化 JSON,每条样本包含提问、文档溯源标准答案、参考上下文、题型、难度五个字段:
{ "提问": "客户经理个人业绩考核包含哪三类业务指标?", "文档溯源标准答案": "个人业绩考核指标分为储蓄季日均、季有效净增发卡量、季净增个贷余额三项。", "参考上下文": ["第四章个人业绩考核标准第八条……实行季度考核。"], "题型": "单段基础题", "难度": "简单" }题型要覆盖全:单段基础题、综合多段题、噪声干扰题、口语模糊题、歧义模糊题、易混淆区分题、超长复合压测题、反向否定题、无答案幻觉测试题。难度分简单、中等、困难三档。所有标准答案必须截取文档原文,杜绝主观答案。
5.3 评测脚本
# eval/ragas_eval.py import json import pandas as pd from datasets import Dataset from ragas import evaluate from ragas.metrics import ( answer_relevancy, faithfulness, context_recall, context_precision ) from langchain_openai import ChatOpenAI from langchain_ollama import OllamaEmbeddings def load_cases(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) def run_eval(cases, rag_system, config): questions, answers, contexts, ground_truths = [], [], [], [] types, levels = [], [] for case in cases: q = case["提问"] ans, docs = rag_system.chat_with_documents(q, "eval_session", [1]) questions.append(q) answers.append(ans) contexts.append([d for d in docs]) ground_truths.append(case["文档溯源标准答案"]) types.append(case.get("题型", "")) levels.append(case.get("难度", "")) ds = Dataset.from_dict({ "question": questions, "answer": answers, "contexts": contexts, "ground_truth": ground_truths, }) llm = ChatOpenAI( api_key=config.taotoken_api_key, base_url=config.taotoken_base_url, model=config.llm_model, temperature=0, ) emb = OllamaEmbeddings(model=config.embedding_model) result = evaluate( dataset=ds, metrics=[context_precision, context_recall, faithfulness, answer_relevancy], llm=llm, embeddings=emb, ) df = result.to_pandas() df["题型"] = types df["难度"] = levels df.to_csv("ragas_result.csv", index=False, encoding="utf-8-sig") return df运行:
python eval/ragas_eval.py5.4 结果解读
跑完 20 条用例后,统计汇总大致呈现这样的形态:context_recall 均值约 0.98,中位数 1.0,说明检索覆盖能力极强,绝大多数样本都能召回完整相关上下文;faithfulness 均值约 0.96,幻觉整体可控;answer_relevancy 均值约 0.84,存在两极分化;context_precision 均值约 0.72,标准差最大,是波动最剧烈的一项。
按题型看,无答案幻觉测试题的 context_precision 达到 1.0,说明系统面对知识库没有的问题时能精准识别、不检索无关内容;噪声干扰题、口语模糊题的 faithfulness 和 answer_relevancy 都接近 1.0。表现最差的是超长复合压测题,context_precision 直接掉到 0,faithfulness 也只有 0.43——一个问题里堆了五个子问题,整句检索完全失效。易混淆区分题、歧义模糊题的 context_precision 也都低于 0.2。
6. 常见报错与排查
6.1 401 Unauthorized
Key 没读到或复制不全。检查.env是否被load_dotenv()正确加载,打印config.taotoken_api_key[:8]确认前缀。注意 Key 里不要混入空格或换行。
6.2 404 Not Found
base_url写错了。正确值是https://taotoken.net/api,不要写成https://taotoken.net/api/v1,也不要加结尾斜杠。SDK 内部会自己拼接路径。
6.3 Chroma 检索结果为空
三种可能:入库时 embedding 和查询时 embedding 用了不同模型;collection 名字对不上;持久化目录被清空。排查时先打印collection.count(),如果是 0 说明根本没入库成功。
6.4 RAGAS 评测报 KeyError
RAGAS 对数据集字段名敏感,必须是question、answer、contexts、ground_truth这四个。contexts必须是 list of list,每条样本一个列表,不能是扁平字符串列表。
6.5 超长复合题检索全挂
这是架构层面的问题,不是 bug。整句向量化后语义被稀释,检索不到任何相关片段。解法是查询改写:在检索前先用 LLM 把复合问题拆成若干子问题,分别检索后合并去重,再交给生成模型。这一步能显著拉高 context_precision。
6.6 评测结果不可复现
temperature没设成 0,或者评测脚本里 session 复用了导致上下文串味。评测时每条用例用独立 session,模型温度固定为 0。
7. 继续往下走
把检索层和生成层分开评测,是这套流程最有价值的地方。检索差就优化切片粒度和查询改写,生成差就调 prompt 和模型。两者混在一起调,你永远不知道问题出在哪一层。
如果你要跑更大规模的评测,或者把 RAG 接进日常编码、Agent 工作流,建议用 Coding Plan 的额度模型,高频调用下成本更可控:
Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
需要新建更多 Key 做多环境隔离(开发/评测/生产各一个),在控制台直接创建:
API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
接入细节和参数说明随时查文档:
接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
最后留一个我踩过的坑:Chroma 的hnsw:space一定要在建 collection 时指定为cosine,默认是 L2 距离,对归一化后的文本向量来说,cosine 的排序结果更符合语义相似度直觉。建完再改就得重建整个库。