日习一技 - 2025年5月
type: channel
link: https://i-study-everyday.online/2025/05
description: 每天学一点新技术，新框架，新工具，新名词。关于编程语言，数据库，数据结构，算法，机器学习，云计算。
items: 深入剖析 Mem0 的图谱记忆源码
items: 学习 Mem0 的图谱记忆
items: 学习 Mem0 的高级配置（续）
items: 学习 Mem0 的高级配置
items: 学习 Mem0 的记忆存储
items: Mem0 介绍：为 AI 应用提供智能记忆层
items: 学习 SurfSense 的播客生成
items: 再学 SurfSense 的文档问答流程
items: 学习 SurfSense 的文档问答流程
items: 学习 SurfSense 的数据入库流程

深入剖析 Mem0 的图谱记忆源码
type: item
date: 2025-05-31T06:11:00+00:00
description: 昨天我们学习了 Mem0 的图谱记忆功能，了解了 Mem0ᵍ 是如何通过提取和更新两个阶段，将用户消息从非结构化文本转化为结构化图表示的。为了更加深入地理解这块的逻辑，我决定今天来扒一扒 Mem0 的源码，看看具体的实现。从 add() 方法开始我们知道，Mem0 支持 Neo4j 和 Memgraph 两种图数据库，针对不同的图数据库，图谱构建的实现也略有差异，分别位于 graph_memory.py 和 memgraph_memory.py 文件里。虽然流程有一些差异，但是整体框架是一样的。我们以 Neo4j 的实现为例，图谱构建的入口在 MemoryGraph 的 add() 方法：从代码中可以看到一共调用了六个方法，这六个方法大致就可以对应提取阶段和更新阶段的四大步骤。_retrieve_nodes_from_data() 方法从方法名字就可以看出这一步做的是 实体提取（Entity Extractor），Mem0 在这里巧妙地使用了大模型的工具调用能力，它首先定义了一个工具：它把这个工具的名字叫做 extract_entities，它只有一个数组类型的参数 entities，数组里是实体的定义，包括实体名称 entity 和 实体类型 entity_type 两个字段。然后将这个工具提供给大模型进行调用：如果大模型支持工具调用的话，将返回 tool_calls 字段，其中必然有一个 tool_call 的名称叫 extract_entities，参数是提取出来的实体数组，类似于下面这样：extract_entities([ { "entity": "desmond", "entity_type": "person" }, { "entity": "sister", "entity_type": "family_member" } ])通过这个方法，我们从对话中识别并提取出一组实体，以及每个实体对应的类型。_establish_nodes_relations_from_data() 方法这一步是对上面提取的实体进行分析，确认实体之间的关系，Mem0 在这里仍然是使用工具调用的技巧，定义的工具如下：工具名称叫 establish_relationships，工具的参数为数组类型，数组里是关系三元组，包括源实体 source、目标实体 destination 以及两个实体之间的关系 relationship 三个字段。然后将这个工具提供给大模型进行调用：这里的系统 Prompt 如下：EXTRACT_RELATIONS_PROMPT = """ 您是一个设计用于从文本中提取结构化信息以构建知识图谱的高级算法。 您的目标是捕获全面且准确的信息。请遵循以下关键原则： 1. 仅从文本中提取明确陈述的信息。 2. 在提供的实体之间建立关系。 3. 在用户消息中使用"USER_ID"作为任何自我引用（例如"我"、"我的"等）的源实体。 关系： - 使用一致、通用和永恒的关系类型。 - 示例：优先使用"教授"而非"成为_教授"。 - 关系只应在用户消息中明确提及的实体之间建立。 实体一致性： - 确保关系是连贯的，并在逻辑上与消息的上下文相符。 - 在提取的数据中保持实体命名的一致性。 努力通过在实体之间建立所有关系并遵循用户的上下文，构建一个连贯且易于理解的知识图谱。 严格遵守这些指导方针，以确保高质量的知识图谱提取。"""如果一切正常，大模型将返回工具调用的结果，包括工具名称和工具参数，这个参数就是实体之间的关系三元组，类似于下面这样：establish_relationships([ { "source": "desmond", "destination": "sister", "relationship": "has" } ])通过这个方法，我们在这些实体之间推导出了有意义的连接，建立了一批关系三元组，这批关系后面将会添加到我们的记忆图谱中。_search_graph_db() 方法经过上面两步其实已经完成了 Mem0ᵍ 的提取阶段，我们成功从输入的对话消息中提取出了实体和关系三元组。接下来这个方法是为后面的更新阶段做准备，我们遍历所有新提取出来的实体，计算它们的 Embedding 向量，然后搜索出相似度高于阈值的现有节点。这里使用了一个看起来相当复杂的 Cypher 查询，但是实际上非常简单，可以将其拆成三个部分来看。第一部分根据 user_id 和 embedding 筛选出属于当前用户且相似度高于 $threshold 的节点 n：MATCH (n {self.node_label}) WHERE n.embedding IS NOT NULL AND n.user_id = $user_id WITH n, roun…

学习 Mem0 的图谱记忆
type: item
date: 2025-05-30T06:09:00+00:00
description: 关于 Mem0 的配置选项，还差最后一个 graph_store 没有学习，该配置用于指定一个图数据库。Mem0 支持将抽取的记忆保存到图数据库中，生成的记忆图谱可以包含记忆之间的复杂关系，通过对图进行子图检索和语义三元组匹配，在复杂多跳、时间推理和开放领域等问题上表现更好。Mem0 的图谱记忆功能又被称为 Mem0ᵍ，通过将记忆存储为有向标记图来增强 Mem0。今天我们就来看看 Mem0 是如何结合图数据库来做记忆管理的。图谱记忆实战要使用 Mem0 图谱记忆功能，我们先得有图数据库。目前，Mem0 支持 Neo4j 和 Memgraph 两种图数据库。Neo4j 是图数据库领域的老牌选手，处于行业领先地位，拥有成熟的生态和广泛的商业落地案例，以及庞大的社区和丰富的文档，但是社区版本功能受限，而且开源协议是 GPLv3，商业使用注意合规问题，建议购买企业版授权。Memgraph 是近几年崛起的新锐选手，采用内存优先架构，主打高性能和实时分析，而且采用 Apache 2.0 开源协议，允许闭源商用，只是资料不多，成熟度有所欠缺。这两种图数据库都提供了在线服务，也支持本地部署。下面我们以 Neo4j 作为示例。安装 Neo4j首先，使用 Docker 在本地运行 Neo4j 图数据库：$ docker run -d --name neo4j \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/password \ -e 'NEO4J_PLUGINS=["apoc"]' \ neo4j:2025.04环境变量 NEO4J_AUTH 用于指定数据库的用户名和密码，NEO4J_PLUGINS 用于开启 APOC 插件，Neo4j 的 APOC（Awesome Procedures On Cypher） 插件是一个功能强大的扩展库，为 Neo4j 提供了大量额外的存储过程和函数。注意这里使用了 Neo4j 的最新版本 neo4j:2025.04，有些老版本可能会由于不支持 CALL (n) 这样的子查询语法而报错。启动成功后，在浏览器输入 http://localhost:7474/ 进入 Neo4j 的 Web 控制台：可以点击下面的 "Let's go" 按钮，切换到新版界面。配置图数据库接着使用 pip 安装所需的依赖：$ pip install "mem0ai[graph]"然后在我们的 config 中加上图数据库相关的配置：from mem0 import Memory config = { "graph_store": { "provider": "neo4j", "config": { "url": "neo4j://localhost:7687", "username": "neo4j", "password": "password" } } } memory = Memory.from_config(config)验证图谱记忆Mem0 的文档中有个不错的例子，展示了图谱记忆是如何一步步构建出来的：https://docs.mem0.ai/open-source/graph_memory/overview接下来，我们也亲自实践验证一下，使用 memory.add() 往图谱中添加记忆：memory.add("Hi, my name is Desmond.", user_id="desmond")添加之后，图谱记忆如下：可以看到 Mem0 自动创建了一个 person 节点和一条 is_named 边，并为每个节点创建了 user_id、created、mentions、name、embedding 五个属性：每条边上也有 created 和 mentions 两个属性：我们接着往图谱中添加记忆：memory.add("I have a sister.", user_id="desmond")图谱更新如下：新增了一个 famili_member 节点和一条 has 边。 继续添加记忆：memory.add("Her name is Jesica.", user_id="desmond")图谱更新如下：这次的更新有点差强人意，它再次添加了一个 jesica 节点，但是创建了一条 is 边，而不是复用之前的 is_named 边，而且 sister 和 jesica 之间也没有关系。这个时候如果我问 “我的姐姐叫什么”，通过图谱显然是回答不上来的。所以，图谱的好坏直接影响图谱记忆的效果，要提升图谱记忆的效果，关键在于图谱构建的过程。图谱构建原理和向量记忆的存储一样，图谱记忆的存储也是由两个阶段组成：提取阶段（Extraction） 和 更新阶段（Update）：提取阶段在提取阶…

学习 Mem0 的高级配置（续）
type: item
date: 2025-05-29T06:07:00+00:00
description: Mem0 提供了很多配置选项，可以根据用户的需求进行自定义，包括：向量存储、语言模型、嵌入模型、图存储 以及一些 通用配置。目前我们已经学习了 vector_store 向量存储、llm 语言模型和 embedder 嵌入模型三大配置，图存储相关的配置我打算放在后面再介绍，今天先来看下通用配置，主要是下面这几个：config = { "version": "v1.1", "history_db_path": "/path/to/history.db", "custom_fact_extraction_prompt": "Optional custom prompt for fact extraction for memory", "custom_update_memory_prompt": "Optional custom prompt for update memory" }版本号Mem0 目前支持 v1.0 和 v1.1 两个版本，默认使用的是最新的 v1.1 版本。这个配置参数主要是为了兼容老版本，看 Memory 的 add() 方法，在 v1.0 版本里直接返回 vector_store_result，而最新版本是封装在一个对象的 results 字段里：if self.api_version == "v1.0": warnings.warn( "The current add API output format is deprecated. " "To use the latest format, set `api_version='v1.1'`. " "The current format will be removed in mem0ai 1.1.0 and later versions.", category=DeprecationWarning, stacklevel=2, ) return vector_store_result return {"results": vector_store_result}除非是历史遗留系统使用了 Mem0 的老版本，否则这个参数可以不用管。历史数据库Mem0 每次生成新记忆时，不仅会保存在向量数据库里，而且还在本地数据库中留有一份副本。这个本地数据库使用的是 SQLite，默认位置在 ~/.mem0/history.db，可以通过 history_db_path 配置修改历史数据库的位置。对数据库里的内容感兴趣的朋友可以通过 sqlite3 命令打开该文件：$ sqlite3 ~/.mem0/history.db SQLite version 3.43.2 2023-10-10 13:08:14 Enter ".help" for usage hints.通过 .tables 查看表：sqlite> .tables history可以看到就一张 history 表，通过 .schema 查看表结构：sqlite> .schema history CREATE TABLE history ( id TEXT PRIMARY KEY, memory_id TEXT, old_memory TEXT, new_memory TEXT, event TEXT, created_at DATETIME, updated_at DATETIME, is_deleted INTEGER, actor_id TEXT, role TEXT );通过 select 查询表中的记忆历史：sqlite> select memory_id, old_memory, new_memory, event from history; e40f3ba7-6d63-40d0-9489-3df6c2661dc9||Name is Desmond|ADD d4a05df9-2474-4fe0-9c4d-b37ed47e1ffb||Has a sister|ADD d4a05df9-2474-4fe0-9c4d-b37ed47e1ffb|Has a sister|Has a sister named Jesica|UPDATE 599154a0-f8a6-48f1-a58c-92c88e58d4a7||Jesica has a dog|ADD可以看到一共有四条记录，这四条记录实际上代表三条记忆，其中第二条和第三条的 memory_id 一样，所以是同一条记忆。这四条记录是通过下面的对话产生的：> Hi, my name is Desmond. > I have a sister. > Her name is Jesica. > She has a dog.当我说 “我的名字叫 Desmond” 时，新增了一条…

学习 Mem0 的高级配置
type: item
date: 2025-05-28T06:05:00+00:00
description: 昨天我们学习了 Mem0 记忆存储的原理，并通过自定义 Qdrant 配置实现了记忆的持久化存储，以及通过 vector_store 切换其他的向量数据库。关于 Mem0 的配置，除了 vector_store 之外，还有其他的一些高级配置，我们今天就来看看这一部分。Mem0 配置概览Mem0 提供了很多配置选项，可以根据用户的需求进行自定义。这些配置涵盖了不同的组件，包括：向量存储、语言模型、嵌入模型、图存储 以及一些通用配置。下面是一份完整配置的示例：config = { "vector_store": { "provider": "qdrant", "config": { "host": "localhost", "port": 6333 } }, "llm": { "provider": "openai", "config": { "api_key": "your-api-key", "model": "gpt-4" } }, "embedder": { "provider": "openai", "config": { "api_key": "your-api-key", "model": "text-embedding-3-small" } }, "graph_store": { "provider": "neo4j", "config": { "url": "neo4j+s://your-instance", "username": "neo4j", "password": "password" } }, "history_db_path": "/path/to/history.db", "version": "v1.1", "custom_fact_extraction_prompt": "Optional custom prompt for fact extraction for memory", "custom_update_memory_prompt": "Optional custom prompt for update memory" }语言模型Mem0 对各种主流的大语言模型提供内置支持，包括：OpenAI - 如 gpt-4o、gpt-4o-mini、gpt-o3 等；Anthropic - 如 claude-sonnet-3.7、claude-sonnet-4 等；Gemini - 如 gemini-1.5、gemini-1.5-flash 等；DeepSeek - 如 deepseek-chat、deepseek-reasoner 等；xAI - 如 grok-3-beta 等；Sarvam AI - 如 sarvam-m 等；如果没有配置，默认使用的 OpenAI 的 gpt-4o-mini。Mem0 也支持一些模型聚合服务，包括：Azure OpenAI - https://azure.microsoft.com/AWS Bedrock - https://aws.amazon.com/bedrock/Together - https://www.together.ai/Groq - https://groq.com/还支持接入本地大模型，比如：Ollama - https://ollama.com/LM Studio - https://lmstudio.ai/此外，Mem0 也支持一些 LLM 开发框架，比如 LangChain 和 Litellm 等：LangChain - 通过 LangChain 的 Chat models，支持绝大多数大模型服务；Litellm - 一个小巧精悍的 Python 库，兼容 100+ 不同的大模型，所有模型都使用标准化的输入/输出格式；最后，针对 OpenAI 的模型，Mem0 还有一项特别的功能，它同时支持 OpenAI 的 结构化输出 和 非结构化输出 两种格式。结构化输出可以返回结构化响应（比如 JSON 对象），好处是方便解析，一般用于数据提取、表单填写、API 调用等场景；非结构化输出返回开放式、自然语言的响应，输出格式更灵活性。通过下面的配置使用 OpenAI 的结构化输出功能：config = { "llm": { "provider": "openai_structured", "config": { "model": "gpt-4o-mini", "temperature": 0.0, } } }嵌入模型同样的，Mem0 内置支持很多嵌入模型服务，包括：OpenAI - 如 text-embedding-3-large 等；Azure OpenAIVertex AI - 如 text-embedding-004 等；Gemini …

学习 Mem0 的记忆存储
type: item
date: 2025-05-27T06:04:00+00:00
description: 昨天我们学习了 Mem0 的基本用法，并给出了一个简单的示例程序。和传统的大模型对话不同的是，我们没有将历史会话拼接起来，而是先检索记忆，然后将记忆拼接到系统 Prompt 中回答用户问题，最后再将这次对话保存到记忆。这里涉及两个记忆的核心操作：检索 和 存储，这也是 Mem0 的两个核心方法。# 检索记忆 relevant_memories = memory.search(query=message, user_id=user_id, limit=3) # 保存记忆 memory.add(messages, user_id=user_id)今天我们先学习记忆是如何存储的。Mem0 的记忆存储原理Mem0 的记忆存储流程由两个阶段组成：提取阶段（Extraction） 和 更新阶段（Update）：在提取阶段，Mem0 主要关注三个信息：最新的一轮对话，通常由用户消息和助手响应组成；滚动摘要，从向量数据库检索得到，代表整个历史对话的语义内容；最近的 m 条消息，提供了细粒度的时间上下文，可能包含摘要中未整合的相关细节；然后通过 LLM 从这些信息中提取出一组简明扼要的候选记忆，并在后台异步地刷新对话摘要，刷新过程不阻塞主流程，所以不用担心会引入延迟。在更新阶段，针对新消息从向量数据库中检索出最相似的前 s 个条目进行比较，然后通过 LLM 的工具调用能力，选择四种操作之一：ADD - 在没有语义等效记忆存在时创建新记忆；UPDATE - 用补充信息增强现有记忆；DELETE - 删除和新信息所矛盾的记忆；NOOP - 当候选事实不需要对知识库进行修改时；更新阶段使得记忆存储保持一致、无冗余，并能立即准备好应对下一个查询。综上所述，提取阶段负责处理最新消息和历史上下文以创建新记忆；更新阶段将这些提取的记忆与类似的现有记忆进行评估，通过工具调用机制应用适当的操作。通过 Mem0 的两阶段记忆管道，确保仅存储和检索最相关的事实，最小化令牌和延迟，实现可扩展的长期推理。配置 Qdrant 数据库昨天我们在运行示例程序的时候发现，每次程序重启后记忆就没有了，当时我还以为记忆是保存在内存里的。后来看源码才发现其实不对，Mem0 的默认存储是 Qdrant 向量数据库，只不过使用了本地文件，可以在临时目录 /tmp/qdrant 中找到，每次程序启动时都会删掉重建。可以通过 Memory.from_config() 自定义记忆配置：config = { "vector_store": { "provider": "qdrant", "config": { "path": "/tmp/qdrant_data", "on_disk": True } }, } memory = Memory.from_config(config)在上面的配置中，我们将 Qdrant 数据库位置修改为 /tmp/qdrant_data，并开启了持久化存储。此外，我们也可以本地部署一个 Qdrant 数据库：$ docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant然后通过 Memory.from_config() 配上向量数据库地址和集合名称：config = { "vector_store": { "provider": "qdrant", "config": { "collection_name": "test", "host": "localhost", "port": 6333, } }, } memory = Memory.from_config(config)这时我们的记忆就是持久化的了，可以在 Qdrant 的 Dashboard 页面 http://localhost:6333/dashboard 对记忆进行可视化查询和管理：除了上面几个配置参数，Qdrant 的完整配置参数如下：配置其他的向量数据库Mem0 的向量存储统一使用下面的格式配置：config = { "vector_store": { "provider": "your_chosen_provider", "config": { # Provider-specific settings go here } } }其中 provider 表示向量存储的名称，比如 chroma、pgvector、qdrant 等，而 config 是针对不同存储的特定配置，每种向量存储配置可能都不一样，具体配置项可参考 Mem0 的文档：https://docs.mem0.ai/components/vectordbs/configMem0 对各种流行的向量数据库提供了内置支持，包括：Qdrant - https://qdrant.tech/Chrom…

Mem0 介绍：为 AI 应用提供智能记忆层
type: item
date: 2025-05-26T05:59:00+00:00
description: 今年被称为智能体爆发元年，随着推理模型和多模态模型的不断发展，各家 AI 助手和智能体的能力不断提升，应用场景也在不断扩展。但是大模型存在一个先天缺陷，它们往往缺乏持久记忆能力，无法真正实现个性化交互。如何让 AI 系统能够记住用户偏好、适应个人需求并随时间持续学习，成为一个亟待解决的问题。今天给大家介绍一个开源项目 Mem0（读作 "mem-zero"），正是为解决这一挑战而生。Mem0 其实并不算新项目，去年就已经推出了，推出之后社区反响不错，迅速登上了 Github 趋势榜，受到了开发者们的广泛关注，并成为 Y Combinator S24 孵化的项目。当时我简单瞅了一眼，发现它的源码和使用案例非常的简单和粗糙，只有几个和记忆相关的增删改查的接口，实在搞不懂为啥能拿到 10K+ 的星标。这两天这个项目又上了趋势榜，再看的时候星标数已经突破了 32K+，看源码结构和官方文档都比之前丰富了不少，于是准备花点时间仔细研究下它，看看它到底有何过人之处。研究亮点如今的大模型在长时间的交互中会忘记关键事实，打破上下文，让用户缺乏信任感。仅仅扩大上下文窗口只会延迟问题 —— 模型变得更慢、更昂贵，但仍然会忽视关键细节。Mem0 直接针对这个问题，采用可扩展的记忆架构，动态提取、整合和检索对话中的重要信息。根据官方发布的一份研究报告，Mem0 在记忆管理方面的效果令人瞩目。https://mem0.ai/research在 LOCOMO 基准测试中，使用 大模型作为评判者（LLM-as-a-Judge） 计算准确率得分，Mem0 取得 66.9% 的成绩，相对于 OpenAI 的 52.9% 提升了 26%，突显了其卓越的事实准确性和连贯性。除了质量，相对于全上下文，Mem0 的 选择性检索管道（selective retrieval pipeline） 通过处理简洁的记忆事实而不是重新处理整个聊天记录，将 p95 延迟降低了 91%，Mem0 是 1.44 秒，而全上下文多达 17.12 秒。此外，Mem0 还实现了 90% 的令牌消耗减少，每次对话仅需约 1.8K 个令牌，而全上下文方法则需要 26K 个令牌。下图展示了每种记忆方法 端到端（记忆检索 + 答案生成） 的测试情况：可以看到全上下文方法虽然有 72.9% 的高准确率，但中位数延迟为 9.87 秒，95 百分位延迟更是高达 17.12 秒。相比之下，Mem0 的准确率是 66.9%，中位数延迟仅 0.71 秒，95 百分位延迟也只有 1.44 秒。综合来看，这些结果展示了 Mem0 如何平衡最先进的推理效果、实时响应和成本效率 —— 使长期对话记忆在规模上变得可行。核心特性Mem0 作为一个为 AI 助手和智能体提供智能记忆层的开源项目，旨在实现个性化 AI 交互。它的核心特性如下：多级记忆：无缝保留用户、会话和智能体状态，实现自适应个性化；开发者友好：提供直观的 API、跨平台 SDK 和完全托管的服务选项；Mem0 在多种场景中都有广泛应用：AI 助手：提供一致、富有上下文的对话体验；客户支持：记住过去的工单和用户历史，提供量身定制的帮助；医疗保健：跟踪患者偏好和历史，实现个性化护理；生产力与游戏：基于用户行为的自适应工作流和环境；快速入门Mem0 提供了 Python 和 TypeScript 两种 SDK 供开发者选用。下面将通过一个 Python 示例展示了 Mem0 的基本用法。首先，通过 pip 安装 Mem0 和 OpenAI 的 SDK：$ pip install mem0ai openai然后编写示例代码如下：from openai import OpenAI from mem0 import Memory openai_client = OpenAI() memory = Memory() def chat_with_memories(message: str, user_id: str = "default_user") -> str: # 检索相关记忆 relevant_memories = memory.search(query=message, user_id=user_id, limit=3) memories_str = "\n".join(f"- {entry['memory']}" for entry in relevant_memories["results"]) # 生成助手回复 system_prompt = f"你是我的私人助理，请根据我的记忆回答我的问题。\n我的记忆：\n{memories_str}" messages = [{"role": "system", "content": system_prompt}, {"r…

学习 SurfSense 的播客生成
type: item
date: 2025-05-25T05:02:00+00:00
description: 经过几天的学习，我们已经基本掌握了 SurfSense 的所有功能，包括添加文档的四种方式，连接器的管理，以及问答流程。并且针对每一个功能，通过剖析源码学习各自的实现原理，比如如何实现文档的解析，如何对文档进行检索，如何实现整个问答流程，等等。不过，还有一个最重要的功能没有讲到。之前有提到，SurfSense 号称是 NotebookLM 的开源平替，而 NotebookLM 的杀手锏便是它的 音频概览（Audio Overview） 功能，用户只需一键点击，就可以将文本内容转换为类似播客的音频讨论，这一功能特别适合那些喜欢听觉学习或需要在通勤、运动等场景下继续学习的用户。SurfSense 同样也实现了类似的功能，可以针对对话内容生成播客，我们今天就来看下这个功能。生成播客在 SurfSense 上产生会话后，就可以点击左侧菜单上的 “View All Chats” 进入会话列表页面，针对每一通会话，我们可以点击 “Generate Podcast” 生成播客：弹出对话框，输入播客名称，然后确认即可：稍等片刻，等后端生成结束，进入 “Podcasts” 页面，就可以看到生成的播客了：下面是我生成的播客，你可以听听效果，是不是有点像那么回事：点击收听生成播客的实现生成播客的代码逻辑位于 podcasts_routes.py 文件中，接口名为 POST /podcasts/generate/：同样地，通过 FastAPI 的 BackgroundTasks 创建一个后台任务来生成播客。任务的实现位于 podcast_tasks.py 文件中 generate_chat_podcast() 函数：async def generate_chat_podcast( session: AsyncSession, chat_id: int, search_space_id: int, podcast_title: str ): initial_state = State( source_content=chat_history_str, ) result = await podcaster_graph.ainvoke(initial_state, config=config)可以看到，又是一个 LangGraph 构建的流程图！它的定义如下：workflow = StateGraph(State, config_schema=Configuration) workflow.add_node("create_podcast_transcript", create_podcast_transcript) workflow.add_node("create_merged_podcast_audio", create_merged_podcast_audio) workflow.add_edge("__start__", "create_podcast_transcript") workflow.add_edge("create_podcast_transcript", "create_merged_podcast_audio") workflow.add_edge("create_merged_podcast_audio", "__end__") graph = workflow.compile() graph.name = "Surfsense Podcaster"这个生成播客的工作流包含两个工作节点：create_podcast_transcript - 生成播客的文本稿create_merged_podcast_audio - 创建合并后的播客音频生成文本稿第一步根据对话内容生成播客的文本稿，其核心仍然是一段系统 Prompt：当前日期: {datetime.datetime.now().strftime("%Y-%m-%d")} <podcast_generation_system> 您是一位专业的播客脚本撰写大师，擅长将各类输入内容转化为两位主持人之间生动、引人入胜且自然流畅的对话。您的核心目标是创作出真实自然的对话内容，完全避免机器人式生硬脚本或刻板的正式感。要实现充满活力的互动效果，而非单纯的信息传递。 - '<source_content>': 待讨论的原始文本内容块。可能是研究发现、文章摘要、详细大纲、与该主题相关的用户聊天记录，或其他相关信息。内容可能是非结构化的，但将作为播客对话的事实依据。 <output_format> 包含交替主持人对话的JSON格式播客文本： { "podcast_transcripts": [ { "speaker_id": 0, "dialog": "主持人0的对话内容" }, { "speak…

再学 SurfSense 的文档问答流程
type: item
date: 2025-05-24T05:00:00+00:00
description: 书接上回，昨天我们提到 SurfSense 的文档问答流程是通过 LangGraph 构建的一个线性工作流实现的，包含三个主要节点：reformulate_user_query：重新表述用户查询，也就是对用户的问题进行改写；write_answer_outline：根据不同的研究模式生成大纲，并为每个大纲生成多个搜索问题；process_sections：针对大纲中的每个章节，调用连接器去搜索，并生成对应章节的内容；如果开启了 LangSmith 可以在控制台页面看到整个图的执行流程：今天对流程中的这几个节点展开聊聊。问题改写问题改写的目的主要有两个：使模糊问题更明确：比如用户的问题是 听说最近武汉的樱花挺不错，打算下周二带家人去看看，不知道那天天气怎么样？，需要把问题改成更明确的 武汉下周二天气怎么样？多轮对话中的指代消解：比如用户先问 合肥明天的天气怎么样？，系统回答之后，用户接着又问 那后天呢？，这时要把问题改写成 合肥后天的天气怎么样？其核心就一段 Prompt：今日日期: {datetime.datetime.now().strftime("%Y-%m-%d")} 您是一位专精于查询优化的高技能AI助手，专为高级研究而设计。 您的主要目标是将用户的初始查询转化为高效的搜索查询。 这个重新表述的查询将用于从多种数据源中检索信息。 **聊天历史上下文:** {chat_history_str if chat_history_str else "没有可用的先前对话历史。"} 如果提供了聊天历史，请分析它以了解用户不断发展的信息需求和他们请求的更广泛背景。利用这种理解来完善当前查询，确保它建立在先前互动的基础上或对其进行澄清。 **查询重构指南:** 您重新表述的查询应该: 1. **增强特异性和细节:** 增加精确度以有效缩小搜索焦点，使查询不那么模糊且更有针对性。 2. **解决歧义:** 识别并澄清模糊的术语或短语。如果一个术语有多种含义，根据上下文将查询引导向最可能的含义。 3. **扩展关键概念:** 纳入核心概念的相关同义词、相关术语和替代表述。这有助于捕获更广泛的相关文档。 4. **分解复杂问题:** 如果原始查询是多方面的，将其分解为核心可搜索组件或重新表述以清晰地解决每个方面。最终输出必须仍然是一个连贯的查询字符串。 5. **优化全面性:** 确保查询的结构能够揭示原始请求的所有基本方面，旨在进行适合研究的彻底信息检索。 6. **保持用户意图:** 重新表述的查询必须忠于用户查询的原始意图。不要引入新主题或显著改变焦点。 **关键约束:** * **简洁和有效性:** 在追求全面性的同时，重新表述的查询必须尽可能简洁。消除所有不必要的冗长。专注于直接有助于有效检索的基本关键词、实体和概念。 * **单一、直接输出:** 仅返回重新表述的查询本身。不要包含任何解释、介绍性短语（例如，"重新表述的查询:"，"这是优化后的查询:"），或任何其他周围文本或markdown格式。 您的输出应该是一个单一的、优化的查询字符串，可以立即用于搜索系统。注意，源码里是英文的，这里我让 Cursor 给我翻译成了中文，主要是参考 Prompt 的思路。生成大纲接下来根据用户的问题生成答案大纲，根据不同的研究模式，生成不同的章节数量：General - 答案包括 1 个章节；Deep - 答案包括 3 个章节；Deeper - 答案包括 6 个章节；并为每个大纲生成多个研究问题。其核心是一段用户 Prompt：现在请为以下查询创建一个回答大纲： 用户查询: {reformulated_query} 章节数量: {num_sections} 请记住将您的回答格式化为完全匹配此结构的有效JSON： { "answer_outline": [ { "section_id": 0, "section_title": "章节标题", "questions": [ "此章节要研究的问题1", "此章节要研究的问题2" ] } ] } 您的输出必须是此格式的有效JSON。不要包含任何其他文本或解释。加上一段系统 Prompt（做了一些删减，去掉了输出格式和示例）：您是一位专门从事信息结构化的专家研究助手。您的任务是根据用户的查询创建一个详细且逻辑清晰的研究大纲。此大纲将作为生成全面研究报告的蓝图。 - user_query (字符串): 用户想要研究的主要问题或主题。这指导整个大纲创建过程。 - num_sections (整数): 最终研究报告应该包含的不同章节的目标数量。这有助于控制大纲的粒度和结构。 <instructions> 1. **分解`user_query`:** 识别用户请求中的关键概念、实体和核心信息。 2. **确…

学习 SurfSense 的文档问答流程
type: item
date: 2025-05-23T04:57:00+00:00
description: 昨天，我们学习了在 SurfSense 中添加文档之后的入库流程，包括总结、分块、向量化等，现在一切准备就绪，到了对文档进行问答的时候了。文档问答页面点击左侧菜单中的 "Researcher" 进入问答页面：输入框的最左边是选择数据源，在 SurfSense 中被称为 连接器（Connectors），可以是我们上传的文件、添加的网页、添加的 Youtube 视频、或者通过浏览器插件抓取的内容：SurfSense 还支持添加其他的连接器，比如搜索引擎 Tavily、聊天工具 Slack、项目管理工具 Linear、知识库 Notion 等等：关于其他的连接器我们后面再看，暂时先不管。输入框的中间用于选择 搜索模式（Search Mode），分 Full Document 和 Document Chunks 两种：Full Document - 根据用户问题检索文档表，找出最相关的文档，返回文档全文，这种模式适用于文档多且单个文档比较小的场景；Document Chunks - 根据用户问题检索分块表，找出最相关的分块，返回一个个的文档片段，这种模式适用于文档较大的场景；输入框的最右边是选择 研究模式（Research Mode），分 General、Deep 和 Deeper 三种，这三种模式会影响检索的数据量和答案的丰富度：General - 检索 10 条数据，答案包括 1 个章节；Deep - 检索 20 条数据，答案包括 3 个章节；Deeper - 检索 30 条数据，答案包括 6 个章节；体验文档问答我们以之前的 MRAG 文档作为示例，体验下 SurfSense 的问答功能。首先，连接器选 File，搜索模式选 Document Chunks，研究模式选 General，然后输入一个简单的问题 What is MRAG?：下面首先会出现一个终端，实时输出 SurfSense 的处理过程，从图中可以看出 SurfSense 接到用户查询后，开始生成答案大纲，生成了 1 个章节和 5 个研究问题，然后针对这些问题分别进行搜索，搜索的结果紧接着终端显示在下面：最后根据搜索结果回答用户：从结果来看，回答的结构比较清晰，符合预期，且带有引用来源，看上去也很靠谱。不过仔细看还是能发现不少问题，比如：引用的文档片段看不到完整内容，也无法跳转到原文对应的位置，导致溯源功能形同虚设；另外答案也不全面，比如第二节只提到了 MRAG 1.0 和 MRAG 2.0 却丢掉了 MRAG 3.0 的内容；第三节甚至出现了 Mixed Language Models (MLLMs) 这样的幻觉错误，连 MLLM 的全称都搞错了。文档问答的实现原理尽管存在很多问题，但并不妨碍我们去看它的实现原理。考虑到这是一个很新的项目，在社区也很活跃，还在不断地演进，相信后面会越来越完善。好了，我们继续看代码。问答接口为 POST /chat，位于 surfsense_backend/app/routes/chats_routes.py 文件中：核心逻辑是下面的 stream_connector_search_results() 函数，这是一个流式处理搜索结果的函数，用于将搜索结果和回答内容实时传输给客户端，点进去看它的实现：async for chunk in researcher_graph.astream( initial_state, config=config, stream_mode="custom", ): if isinstance(chunk, dict) and 'yeild_value' in chunk: yield chunk['yeild_value'] yield streaming_service.format_completion()很显然，这是一个 生成器函数，通过 researcher_graph.astream() 不断产生内容，并通过 yield 关键字返回，最终通过 FastAPI 的 StreamingResponse 实现流式输出。Python 中的生成器函数Python 中的 生成器函数（Generators） 是一种特殊的函数，它使用 yield 关键字来返回值，而不是普通的 return 返回。当函数中包含 yield 语句时，这个函数就变成了生成器函数，调用生成器函数不会立即执行函数体，而是返回一个生成器对象，可以通过 next() 或 for 循环从生成器函数取值。def simple_generator(): yield 1 yield 2 yield 3 for value in simple_generator(): print(value)生成器函数的核心特点是惰性求值和状态保持。当我们从生成器…

学习 SurfSense 的数据入库流程
type: item
date: 2025-05-22T04:55:00+00:00
description: 花了两天时间，我们学习了 SurfSense 文档管理功能，学习了上传文件、添加网页、添加 Youtube 视频、浏览器插件四种添加文档的方式，以及每种添加方式的实现原理。不过目前还只是数据摄入部分，我们今天继续研究下后面的流程，看看这些文档是如何入库的。数据入库流程我们总结下几种添加文档的方式，可以看出，最终的结果都是转为纯文本或 Markdown 格式：拿到文本之后的入库流程几乎是一样的，如下：主要分三个步骤：对整个文档进行总结和向量化；将文档分块，对每个分块进行向量化；将文档和分块数据入库；文档总结SurfSense 使用长文本大模型对完整文档做总结，从配置文件可以知道，默认的模型是 Google 的 Gemini 2.0 Flash，上下文窗口达到 100 万 token，可以塞进去一部《红楼梦》：LONG_CONTEXT_LLM="gemini/gemini-2.0-flash"文档总结的代码如下：summary_chain = SUMMARY_PROMPT_TEMPLATE | config.long_context_llm_instance summary_result = await summary_chain.ainvoke({"document": file_in_markdown}) summary_content = summary_result.content这里通过 LangChain 的 LCEL（LangChain Expression Language）语法 创建一个 摘要生成链（summary chain），将提示词模版和大模型调用链接在一起，其中 SUMMARY_PROMPT_TEMPLATE 的定义如下：SUMMARY_PROMPT_TEMPLATE = PromptTemplate( input_variables=["document"], template=SUMMARY_PROMPT )后面的 long_context_llm_instance 定义如下：LONG_CONTEXT_LLM = os.getenv("LONG_CONTEXT_LLM") long_context_llm_instance = ChatLiteLLM(model=LONG_CONTEXT_LLM)这里有一个挺长的文档总结 Prompt 可以参考，见 surfsense_backend/app/prompts/__init__.py 文件。LCEL 语法上面的 | 连接符有点类似 Linux 命令行中的管道，是 LangChain 的一种特色写法，表示数据会从左侧的提示模板流向右侧的大模型调用。要注意的是，这并不是 Python 的原生特性，而是 LangChain 的语法糖。在 Python 中 | 运算符通常用于：位运算（bitwise OR）集合的并集操作类型注解中的联合类型（Union types）而 LangChain 是通过 重载（overload） 这个运算符，使其具有了新的含义，这种设计灵感来自于 Linux 中的管道操作符，通过这种管道语法使得代码更加简洁和可读，让数据处理的流程更加清晰。在 Python 中重载运算符是通过实现特殊方法（也称为魔术方法或双下划线方法）来实现的，比如下面这些：class MyClass: def __add__(self, other): # + pass def __sub__(self, other): # - pass def __mul__(self, other): # * pass def __truediv__(self, other): # / pass def __eq__(self, other): # == pass def __lt__(self, other): # < pass def __gt__(self, other): # > pass def __or__(self, other): # | pass def __and__(self, other): # & pass可以看到，要重载 | 运算符，需要实现 __or__ 方法。对于 LangChain 来说，它的几个核心类，比如 PromptTemplate 或 BaseChatModel，都统一继承自 Runnable 接口，我们打开 Runnable 源码，就可以发现 LangChain 中 | 连接符的奥秘所在：向量化接着我们计算整个文档的向量：summary_embedding = config.embedding_model_instance.embed(summary_content)其中 embedding_model_instance 定义如下：EM…