AI图鉴

AI图鉴首页 › AI 资讯 › Python 智能体开发实战 05:一问教材内容它就编?先把答案从你自己的资料里

Python 智能体开发实战 05:一问教材内容它就编?先把答案从你自己的资料里检索出来

来源:掘金 · 发布时间:10-31 · 由 AI图鉴每小时自动聚合

小E 上线第三天,课程顾问来找我,语气不太友好。 一位学员在 App 里问小E:"启明英语的口语课一共有几级?"小E 回得又快又顺:"一共六级,每级 12 课时,建议从一级开始逐级报读。"学员信了,直

小E 上线第三天,课程顾问来找我,语气不太友好。

一位学员在 App 里问小E:"启明英语的口语课一共有几级?"小E 回得又快又顺:"一共六级,每级 12 课时,建议从一级开始逐级报读。"学员信了,直接来问四级连报的优惠。顾问只好花十分钟解释:我们只有四级,每级 16 课时,而且没有"逐级报读"这个规定。

同一天下午,另一个学员问退费规则。小E 答:"开课 7 天内可全额退,超过按剩余课时的 80% 退。"这条更危险——80% 这个数字是我们制度里没有的,制度里写的是 5% 手续费。

我把这两段对话拉出来看了一遍,确认问题不在提示词:第 2 篇那套人格模板、第 4 篇那套结构化输出,都老老实实装上了。问题出在更前面一层——这两个问题的答案,根本不在模型见过任何一条语料里,它只能编。而且它编得语法通顺、语气自信、还自带细节,比真话更像真话。

这一篇就解决这件事。

一、先扫盲:RAG 到底在干什么

1. 为什么不能把资料直接贴进 prompt

第一反应都很自然:把口语课说明和退费制度贴到 system prompt 里,不就行了?

我自己试过,两个坎过不去。

第一个坎:装不下。 一份完整的教材动辄几十万字,一份制度汇编加上历史版本能到十几万字。一个模型的上下文窗口是按 token 算的,中文一个字差不多一个 token,十几万字就是十几万 token,很多模型的窗口根本放不下,放得下的那几个也贵得离谱。

第二个坎:贴了也不一定用。 就算装得下,把 10 万字资料和一句提问一起发过去,模型要在 10 万字里自己找那 50 个字。找错了不稀奇,找对了也花了不少输入 token——每一次提问都要付一遍这 10 万字的钱。

所以思路要反过来:不是把整本书都给模型,而是每次只把和这个问题相关的两三段给模型。

2. RAG 的四段

这个"先翻资料再回答"的套路叫 RAG(Retrieval-Augmented Generation,检索增强生成)。LangChain 官方文档把索引阶段拆成四步,和我们的四段一一对应:

- 切片:把整份资料切成一小段一小段。切法决定后面一切,这一篇的一半篇幅都在讲它。

- 向量化:把每一段变成一个向量——一串数字。意思相近的段落,向量也相近。

- 召回:把提问也变成向量,算它和所有片段的相似度,取最像的几条。

- 带着片段回答:把这几段塞进提示词,让模型照着答,并标出每条结论来自哪一段。

一句话记住它:RAG 不是让模型记住,是让它开卷考。 模型的脑子我们换不掉,但考卷旁边放哪几本书,是我们说了算的。

3. 两个必须先说清楚的事

第一,embedding 接口到底哪家有,请以官方文档为准。 我写这篇的时候专门去查了,也劝你别凭印象断言"某家有没有 embedding 接口"——模型名都会退役,服务列表更会变。所以本工程干脆不依赖任何厂商的 embedding 服务,用一个自己写的确定性实现把链路跑通,要做语义检索时再换成真货。这不是回避问题,是把"依赖谁"变成一个可以随时替换的决定。

第二,RAG 不能保证模型不编。 它只能保证:召回不到东西的时候,你有机会让模型闭嘴。真正把"编"堵死的是两个动作——召回为空就拒答,以及每条结论必须带出处。这两个动作是本篇的硬规则,后面会用测试钉死。

二、工程长什么样

05-RAG检索/

├── docs/ # 小E 唯一能引用的知识范围

│ ├── 01-口语课分级说明.md

│ ├── 02-退费与转班制度.md

│ └── 03-助教小E使用手册.md

├── src/

│ ├── settings.py # 复用第 1 篇

│ ├── llm.py # 复用第 1 篇

│ ├── loader.py # 切片:读 md → 按标题粗分 → 按标点细切

│ ├── embeddings.py # 向量化:HashingEmbeddings + 余弦相似度

│ ├── index.py # 索引:建库、召回、算分

│ ├── rag.py # 回答:召回 → 过滤 → 拼资料 → 提问

│ └── references.py # 溯源:[编号] 映射回文件和小节

├── tests/ # 40 个用例,全程离线

└── demo.py

settings.py 和 llm.py 与前几篇完全一致。新增五个模块,每个只干一件事。

三、切片:先按标题粗分,再按标点细切(loader.py)

切片这一步我最先踩的坑,是想一步到位——拿整篇文档按 200 字一刀切下去。

切出来的东西是这样的:"……单笔最低收取 50 元。示例:学员实付 4800 元报了口"+"语课一级(总课时 16),上了 4 课时后申请退费。"上一段的下半句接下一段的上半句,检索到之后人读不懂,模型也容易串。

所以这里改成两步:先按 markdown 标题粗分,再在每节内部细切。

HEADING_RE = re.compile(r"^#{1,6}\s+(.+)$")

def split_by_heading(text: str) -> list[tuple[str, str]]:

"""按 markdown 标题粗分,返回 [(标题, 正文)]。

标题本身不进正文:

标题是"导航信息",是给人扫一眼用的,

而这一段讲什么,正文里自己会说。

把标题留进正文,每个片段都顶着一行 "# 启明英语 · ...",

宝贵的 chunk_size 就被这十几个字白白吃掉了。

"""

sections: list[tuple[str, str]] = []

title = ""

body: list[str] = []

for line in text.splitlines():

match = HEADING_RE.match(line)

if match:

if title or body: # 遇到新标题,先把上一节收掉

sections.append((title, "\n".join(body).strip()))

title = match.group(1).strip()

body = []

else:

body.append(line)

if title or body:

sections.append((title, "\n".join(body).strip()))

return [item for item in sections if item[1]]

粗分完,每一节已经是"只讲一件事"的正文了。细切用的是经典的递归分隔符算法:先试最粗的分隔符,切完还有超长的片,就换下一级分隔符再切。

DEFAULT_SEPARATORS: tuple[str, ...] = ("\n\n", "\n", "。", ",", " ")

def _recursive_split(text: str, separators, chunk_size: int) -> list[str]:

"""递归切,直到每一片都不超过 chunk_size。"""

separator, rest = _pick_separator(text, separators)

pieces: list[str] = []

for part in _split_keep_separator(text, separator):

if len(part) <= chunk_size:

pieces.append(part)

elif rest:

pieces.extend(_recursive_split(part, rest, chunk_size))

else:

pieces.append(part) # 切到最细还是长,只能整片留下

return pieces

两个参数的取舍,我说说我最后怎么定的:

- chunk_size = 160。给中文定的。小了,一个问答对被劈成两半,单独看哪一半都答不出问题;大了,一个片段塞进三四个知识点,召回时"最像的那条"其实只对了三分之一。

- chunk_overlap = 40。块与块之间留 40 字的重叠,目的是接得上——"问:口语课一共有几级?"和"答:四级"如果正好被切在两边,没有重叠就永远召不回同一片。

还有一行必须强调:中文要自己把句号逗号加进分隔符列表。框架默认那套 ["\n\n", "\n", " "] 是为英文设计的,遇到整段没有换行的中文,会被按单字硬切。

说明一下:RecursiveCharacterTextSplitter 在独立包 langchain-text-splitters 里(已列入 requirements.txt)。我为了让整套代码在没有外网的机器上也能跑通,按同样的算法写了一份零依赖实现放在 loader.py 里,装上那个包之后把 split_text() 换成 splitter.split_text() 即可,下游一行不用改。

每个片段都会带上出处信息,这是后面溯源的全部基础:

chunks.append(Document(

page_content=piece,

metadata={"source": path.name, "heading": heading, "chunk": index},

))

三份 md 切完是 21 个片段,平均 91 字。

四、向量化:一个离线、可复现的 Embeddings(embeddings.py)

这一层是我全篇最想讲清楚的设计点。

LangChain 把"把文字变向量"这件事抽象成了 Embeddings 接口,只要实现两个方法就能接入所有检索组件:

class HashingEmbeddings(Embeddings):

"""哈希版 Embeddings:确定性、离线、零依赖。"""

def __init__(self, dim: int = DEFAULT_DIM, max_n: int = 2) -> None:

self.dim = dim

self.max_n = max_n

def embed_documents(self, texts: list[str]) -> list[list[float]]:

"""批量向量化,索引阶段用。"""

return [embed_one(text, dim=self.dim, max_n=self.max_n) for text in texts]

def embed_query(self, text: str) -> list[float]:

"""单条向量化,查询阶段用。"""

return embed_one(text, dim=self.dim, max_n=self.max_n)

它的原理是哈希技巧:把文字拆成字符 n-gram,每个 n-gram 哈希到向量的某一维上累加,最后 L2 归一化。不需要训练、不需要词表、不需要联网,同一段文字永远得到同一个向量。

为什么要费劲写这个?因为我需要一个能写测试的向量化器。任何依赖外部服务的组件,测试结果都受服务端影响:模型升级、服务抖动、配额用完,测试就红了,而你什么都没改。这一篇有 40 个用例要断言"召回的第 1 条就是那一段",前提是向量化必须可复现。

写它的过程中我踩了一个很隐蔽的坑:第一版用的是 Python 内置的 hash()。本地跑得好好的,换台机器召回结果全变——内置 hash() 对字符串的结果受 PYTHONHASHSEED 影响,同一个字符串换个进程就换个值。后来换成了 hashlib.blake2b,才把"可复现"三个字坐实。

它的边界也要说清楚:它只认字面重叠,不认语义。"退费"和"退款"在它眼里是两个词。真要上线,请换成真正的语义向量服务——而换成真货时代码长这样:

store = build_index(chunks, embeddings=你自己的Embeddings())

index.py、rag.py、references.py 一行都不用动。因为它们只依赖 Embeddings 这个抽象,不依赖任何具体实现。这就是依赖倒置:高层不依赖低层,两边都依赖抽象。 这一条是我认为 RAG 工程里最值得提前做的决定,没有之一。

五、索引:InMemoryVectorStore 与一个 numpy 的坑(index.py)

存储用的是 LangChain 自带的 InMemoryVectorStore,几十个片段的规模完全够:

def build_index(chunks: list[Document], embeddings: Embeddings | None = None) -> InMemoryVectorStore:

"""用片段建一个内存向量库。"""

store = InMemoryVectorStore(embedding=embeddings or HashingEmbeddings())

store.add_documents(chunks)

return store

真正花时间的是召回。我一开始直接调 store.similarity_search_with_score(),本地直接抛 ImportError: cosine_similarity requires numpy——它内部那个余弦函数硬依赖 numpy,而我们这台机器装不了。

片段总共 21 个,纯 Python 算一遍余弦根本不费事,于是我干脆自己排:

def _rank(store, query: str, k: int):

embedding = store.embeddings.embed_query(query)

scored = []

for order, record in enumerate(store.store.values()):

score = cosine_similarity(embedding, record["vector"])

document = Document(id=record["id"], page_content=record["text"],

metadata=dict(record["metadata"]))

scored.append((score, order, document))

scored.sort(key=lambda row: (-row[0], row[1])) # 分数相同按入库顺序

return scored[:k]

排序键里那个 order 别省。只按 -score 排,遇到两条分数相同的片段,不同环境可能给出不同顺序,[1][2][3] 的编号就会漂——今天 [1] 是课程文件,明天变成制度文件,学员点开原文发现对不上。带上入库顺序做第二键,结果永远稳定,断言才敢写死编号。

片段规模上万之后,这一层就该换成带 ANN 索引的向量库了。也正因为检索逻辑集中在这一个文件里,到时候要改的只有这里。

六、回答:召回不到就直接拒答(rag.py)

本篇的硬规则只有一条,写在最前面:

召回不到任何片段,就直接拒答,不许让模型自由发挥。

def answer(question: str, store, llm: BaseChatModel,

k: int = DEFAULT_K, min_score: float = MIN_SCORE) -> RAGAnswer:

"""问一句,答一句,顺带把出处带回来。"""

scored = search_with_score(store, question, k=k)

hits = [doc for doc, score in scored if score >= min_score]

# 硬规则:一条都没剩下,就地拒答,模型这次连出场机会都没有

if not hits:

return RAGAnswer(question=question, text=REFUSAL_TEXT, hits=(), refused=True)

response = llm.invoke(build_messages(question, hits))

return RAGAnswer(question=question, text=response.content,

hits=tuple(hits), refused=False)

这条规则最难的不是写,是忍。模型明明能编出"六级,每级 12 课时",你却要让它说"资料里没写"。但对内部资料问答来说,编造的伤害远大于不回答:学员拿着"六级"去找顾问,顾问还得花三分钟解释那是模型编的。

实现上真正要紧的是那行阈值过滤。向量库有个很危险的特性:它永远会返回 k 条结果,哪怕最像的那条也只有 0.03 分。不过滤,就等于告诉模型"总有几条资料给你,你随便说点什么"。

MIN_SCORE = 0.25

这个 0.25 不是拍脑袋,是拿三份资料一格一格试出来的:三个正常问题,最差的命中片段是 0.268;而"雅思保过班多少钱"这种资料里根本没有的提问,最像的片段也只有 0.191。阈值就卡在这两个数中间。这个数字必须自己拿真实语料测,抄别人的一定不准——换了语料、换了向量化方法,分数分布就全变了。

拼提示词也有讲究:资料放 human,规矩放 system。

SYSTEM_PROMPT = """你是「启明英语」的 AI 助教小E,回答学员关于课程与制度的问题。

规矩:

1. 只使用下面给出的参考资料作答,资料里没有的绝对不许补充。

2. 每一条涉及课程或制度的结论后面都要标出处编号,格式是 [1]、[2]。

3. 涉及金额必须写出计算公式,不能只给一个数字。

4. 回答控制在 6 句以内,用中文。

如果参考资料不足以回答,直接说:这件事资料里没写,我不能替你们编,请转人工。"""

第 2 篇讲过,模型对 system 的服从度远高于 user,所以"必须标出处"这种每次都要生效的规矩放 system;资料每次都不一样,放 human。混在一起,改资料就得动人格。

拒答文案我也改过好几版,最后定型是这句:"这件事资料里没写,我不能替你们编,请转人工。" 早期版本写过"很抱歉,可能没有找到相关信息",结果学员回一句"那大概呢",对话继续往下编。缓冲词会让拒绝听起来像商量。

七、溯源:[编号] 必须能落回真实片段(references.py)

标了出处,不等于有出处。

模型完全可能写出"共六级[3]"——本次恰好召回过 3 条资料,[3] 是存在的,但那条资料里根本没提级别。更狠的是写 [7],本次压根只给了 3 条。这两种错格式工整、语气自信,看起来比没标出处的回答还专业。

所以需要一道质检:把回答里的 [数字] 全抽出来,逐个对照本次召回的片段表。

REF_RE = re.compile(r"\[(\d+)\]")

def check_refs(text: str, hits: list[Document], require: bool = True) -> RefReport:

"""给一条回答做出处体检。"""

numbers = extract_refs(text)

maximum = len(hits)

problems: list[str] = []

for number in numbers: # 情况一:编了不存在的编号

if not 1 <= number <= maximum:

problems.append(f"引用了不存在的编号 [{number}],本次只提供了 1~{maximum}")

if require and not numbers: # 情况二:有资料却一条不标

problems.append("通篇没有标注任何出处编号")

return RefReport(refs=resolve_refs(text, hits), problems=tuple(problems))

require=False 那个参数是给拒答用的。拒答本来就不该带出处,如果不关掉这条检查,质检就会变成逼着模型给"资料里没写"也编一个出处。

映射上的编号会展开成一张表:编号、文件、小节、片段预览。学员点 [1],看到的就是课程文件里的"一、级别总数与定位"那一节;客服核对也只需要这一张表。

八、跑起来:真实输出

python demo.py,三个问题对应三种结局。演示用的是离线假模型(把模型"本来会说的话"预置好),检索部分是真实计算,换真模型时除了答案文本其余全部不变。

问题一,能答上的:

问题:启明英语的口语课一共有几级?

召回 3 条,阈值 0.25:

[1] +0.531 命中

01-口语课分级说明.md · 一、级别总数与定位

[2] +0.399 命中

01-口语课分级说明.md · 四、常见问题

[3] +0.268 命中

03-助教小E使用手册.md · 一、小E 的能力范围

回答:口语课一共分为四级:入门级、进阶级、流利级、商务级[1]。

每一级 16 课时,每课时 45 分钟,八周结课[2]。

引用来源:

编号 文件 小节 片段预览

[1] 01-口语课分级说明.md 一、级别总数与定位 启明英语的口语课一共分为四级,从低到高分别是入门…

[2] 01-口语课分级说明.md 四、常见问题 问:口语课一共有几级? 答:四级,分别是入门级、…

问题二,必须拒答的:

问题:你们的雅思保过班多少钱?

召回 3 条,阈值 0.25:

[1] +0.191 低于阈值,丢弃

[2] +0.189 低于阈值,丢弃

[3] +0.176 低于阈值,丢弃

回答:这件事资料里没写,我不能替你们编,请转人工。

召回片段:0 条(全部低于阈值,未向模型提问)

模型有没有被问到:没有——三条候选全在阈值以下,直接返回拒答文案

注意最后一行:模型这次根本没被调用。不是"让它答完再判断要不要采用"——那样它照样编,钱也照样花。

问题三,跨两份资料的:

问题:我报了口语课三级,学费 4800,上了 4 课时不想学了,能退多少钱?

[1] +0.526 命中 02-退费与转班制度.md · 三、退费计算口径

[2] +0.402 命中 01-口语课分级说明.md · 三、学费标准

[3] +0.377 命中 03-助教小E使用手册.md · 一、小E 的能力范围

回答:口语课三级单报学费 4800 元[2]。退费按剩余课时算:

可退金额 = 实付学费 ×(剩余课时 ÷ 总课时)− 手续费[1],

手续费为可退金额的 5%,单笔最低 50 元[1]。

你上了 4 课时、总课时 16,可退金额 = 4800 × 12 ÷ 16 − 4800 × 12 ÷ 16 × 5% = 3420 元[1]。

教材费每级 280 元,不退[2]。

答案一半在制度文件、一半在课程文件,两条出处都标了。这就是"开卷考"和"凭记忆"的差别——它不是记住了 3420 这个数,是把两段材料摆在桌面上算出来的。

demo 最后还加演了一段:我硬塞了一句"口语课一共六级,每级 12 课时[7]",质检立刻报——

出处校验:发现问题

✗ 引用了不存在的编号 [7],本次只提供了 1~3

处理方式:这条回答不返回给学员,记录进待复核队列

九、测试:40 个用例全绿

tests/test_embeddings.py::test_vector_has_fixed_dimension PASSED

tests/test_embeddings.py::test_vector_is_reproducible_across_embedders PASSED

tests/test_embeddings.py::test_vector_is_normalized PASSED

tests/test_embeddings.py::test_cosine_rejects_mismatched_dimension PASSED

tests/test_embeddings.py::test_batch_matches_single PASSED

tests/test_embeddings.py::test_punctuation_and_case_are_ignored PASSED

tests/test_embeddings.py::test_empty_text_gives_zero_vector PASSED

tests/test_index.py::test_default_embeddings_is_hashing PASSED

tests/test_index.py::test_custom_embeddings_is_really_used PASSED

tests/test_index.py::test_swapping_embeddings_keeps_retrieval_identical PASSED

tests/test_index.py::test_k_is_respected PASSED

tests/test_index.py::test_scores_are_sorted_descending PASSED

tests/test_index.py::test_top_hit_is_the_right_document PASSED

tests/test_index.py::test_refund_question_hits_refund_document PASSED

tests/test_index.py::test_empty_index_returns_nothing PASSED

tests/test_index.py::test_search_keeps_metadata PASSED

tests/test_loader.py::test_load_documents_finds_all_md_files PASSED

tests/test_loader.py::test_every_chunk_carries_source_and_heading PASSED

tests/test_loader.py::test_split_by_heading_separates_sections PASSED

tests/test_loader.py::test_heading_line_is_not_in_body PASSED

tests/test_loader.py::test_split_loses_no_visible_text PASSED

tests/test_loader.py::test_overlap_carries_tail_into_next_chunk PASSED

tests/test_loader.py::test_bigger_chunk_size_gives_fewer_pieces PASSED

tests/test_loader.py::test_overlap_must_be_smaller_than_chunk_size PASSED

tests/test_loader.py::test_default_parameters_are_consistent PASSED

tests/test_rag.py::test_answer_returns_text_and_hits PASSED

tests/test_rag.py::test_empty_index_refuses PASSED

tests/test_rag.py::test_irrelevant_question_refuses PASSED

tests/test_rag.py::test_model_is_not_called_when_refused PASSED

tests/test_rag.py::test_retrieved_text_reaches_the_model PASSED

tests/test_rag.py::test_system_prompt_is_sent_as_system PASSED

tests/test_rag.py::test_reference_numbers_are_continuous_from_one PASSED

tests/test_rag.py::test_min_score_is_a_real_switch PASSED

tests/test_references.py::test_extract_refs_finds_numbers PASSED

tests/test_references.py::test_refs_map_back_to_file_and_heading PASSED

tests/test_references.py::test_excerpt_is_shor

本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。

相关 AI 工具

相关免费羊毛

同源资讯

阅读原文(juejin.cn)

标题、摘要与正文均来自该信源的公开页面,版权归原作者所有;本站自动聚合与翻译,仅作出处跳转。

← 返回 AI 资讯列表AI工具图鉴首页

返回 AI图鉴首页 · 提交工具 · 商务合作 · 关于本站 · 603 款 AI 工具分类导航 · 每日 AI 热门项目与动态