Xây dựng hệ thống tìm kiếm ngữ nghĩa và RAG cho transcript dài có trích dẫn timestamp
Một bản transcript dài 60 phút có thể chứa hàng nghìn câu nói, nhiều người tham gia, hàng chục chủ đề và rất nhiều thông tin chỉ xuất hiện đúng một lần.
Sau khi chuyển audio hoặc video thành văn bản, chúng ta có thể dùng Ctrl + F để tìm một từ khóa chính xác. Tuy nhiên, cách này nhanh chóng bộc lộ hạn chế.
Người dùng có thể nhớ ý nghĩa của câu nói nhưng không nhớ từ chính xác đã được sử dụng.
Ví dụ, họ muốn tìm:
Phần nào trong cuộc họp nói về việc trì hoãn ngày phát hành?
Nhưng transcript có thể sử dụng những cách diễn đạt khác:
- “Chúng ta nên dời bản beta sang tuần sau.”
- “Ngày 18 có vẻ an toàn hơn.”
- “Đội marketing chưa sẵn sàng cho thứ Sáu.”
- “Hãy hoãn thông báo cho tới khi kiểm thử xong.”
Tìm kiếm từ khóa trì hoãn có thể không trả về kết quả nào, mặc dù nội dung cần tìm tồn tại trong transcript.
Đây là lúc semantic search và Retrieval-Augmented Generation, hay RAG, trở nên hữu ích.
Trong bài viết này, chúng ta sẽ thiết kế một pipeline cho phép:
- Lưu transcript theo từng đoạn có timestamp
- Tạo embedding cho nội dung
- Tìm kiếm theo ý nghĩa thay vì từ khóa chính xác
- Trả về đoạn audio hoặc video liên quan
- Yêu cầu LLM trả lời chỉ từ bằng chứng đã truy xuất
- Gắn timestamp vào từng kết luận
- Giảm nguy cơ mô hình tạo câu trả lời không tồn tại trong nguồn
Mục tiêu không chỉ là “chat với transcript”.
Mục tiêu là xây dựng một hệ thống mà mọi câu trả lời đều có thể quay lại nguồn để kiểm tra.
1. Kiến trúc tổng thể
Một pipeline cơ bản có thể được chia thành sáu bước:
Media file
↓
Timestamped transcript
↓
Normalize and segment
↓
Create embeddings
↓
Store in vector database
↓
Retrieve evidence
↓
Generate an answer with citations
Dữ liệu đầu vào có thể là cuộc họp, podcast, bài giảng, phỏng vấn hoặc video hướng dẫn.
Với audio, một quy trình MP3 to Transcript có thể tạo văn bản kèm timestamp để chúng ta tiếp tục xử lý.
Với screen recording, webinar hoặc video demo, trang MP4 to Text phù hợp hơn vì khi kiểm tra kết quả, chúng ta có thể cần cả lời nói và ngữ cảnh đang hiển thị trên màn hình.
Điểm quan trọng là transcript đầu vào phải giữ được ít nhất các trường sau:
type TranscriptSegment = {
id: string;
startMs: number;
endMs: number;
speaker: string | null;
text: string;
};
Nếu bỏ timestamp ở bước đầu, chúng ta sẽ rất khó liên kết câu trả lời cuối cùng với đoạn recording tương ứng.
2. Vì sao không nên embedding toàn bộ transcript một lần?
Giả sử transcript có 20.000 từ.
Nếu tạo một embedding duy nhất cho toàn bộ tài liệu, vector đó sẽ đại diện cho quá nhiều chủ đề cùng lúc. Khi người dùng hỏi một câu cụ thể, hệ thống không thể xác định chính xác phần nào trong recording chứa câu trả lời.
Ngoài ra, việc đưa toàn bộ transcript vào LLM cho mỗi truy vấn sẽ:
- Tốn nhiều token
- Tăng độ trễ
- Đưa vào nhiều nội dung không liên quan
- Khiến mô hình dễ bỏ sót chi tiết ở giữa
- Làm timestamp trích dẫn trở nên mơ hồ
Giải pháp là chia transcript thành các chunk nhỏ hơn.
Tuy nhiên, chunking transcript không nên chỉ dựa vào số ký tự.
3. Ba chiến lược chunking phổ biến
3.1. Fixed-size chunking
Cách đơn giản nhất là chia theo số token.
Ví dụ:
const MAX_TOKENS = 400;
const OVERLAP_TOKENS = 60;
Ưu điểm:
- Dễ triển khai
- Kích thước đầu vào ổn định
- Dễ kiểm soát chi phí embedding
Nhược điểm:
- Có thể cắt giữa một câu
- Có thể tách câu hỏi khỏi câu trả lời
- Có thể tách một quyết định khỏi phần xác nhận sau đó
- Không tôn trọng ranh giới người nói hoặc chủ đề
Fixed-size chunking phù hợp cho prototype, nhưng thường chưa đủ tốt cho transcript hội thoại.
3.2. Speaker-turn chunking
Cách thứ hai là gom các lượt nói liên tiếp.
Ví dụ:
{
"start_ms": 125000,
"end_ms": 171000,
"speakers": ["Product Manager", "Developer"],
"text": "..."
}
Một chunk có thể gồm:
- Một câu hỏi
- Một câu trả lời
- Một lượt phản hồi ngắn
- Phần xác nhận cuối cùng
Cách này giữ ngữ cảnh hội thoại tốt hơn.
Tuy nhiên, nếu một người nói liên tục trong 10 phút, chunk vẫn có thể quá dài. Vì vậy cần kết hợp giới hạn token.
3.3. Topic-aware chunking
Cách tốt hơn là phát hiện điểm thay đổi chủ đề.
Một chunk nên kết thúc khi:
- Agenda chuyển sang mục mới
- Từ khóa và thực thể thay đổi rõ rệt
- Có khoảng dừng dài
- Người điều phối tuyên bố chuyển chủ đề
- Cuộc thảo luận kết thúc bằng quyết định
- Một câu hỏi mới không liên quan bắt đầu
Topic-aware chunking có thể được triển khai bằng:
- Quy tắc dựa trên timestamp và speaker
- So sánh cosine similarity giữa các cửa sổ câu
- Một mô hình nhỏ dùng để phân loại ranh giới chủ đề
- LLM chạy ở chế độ batch để đề xuất section boundaries
Trong thực tế, giải pháp tốt thường là hybrid:
Speaker boundaries
+ maximum token limit
+ semantic topic boundary
+ small overlap
4. Đừng làm mất timestamp khi gộp chunk
Khi nhiều segment được gộp thành một chunk, cần giữ lại phạm vi thời gian và các segment gốc.
type TranscriptChunk = {
id: string;
documentId: string;
startMs: number;
endMs: number;
text: string;
speakerIds: string[];
segmentIds: string[];
tokenCount: number;
};
Ví dụ hàm gộp đơn giản:
function createChunk(
documentId: string,
segments: TranscriptSegment[]
): TranscriptChunk {
if (segments.length === 0) {
throw new Error("Cannot create a chunk from an empty segment list");
}
return {
id: crypto.randomUUID(),
documentId,
startMs: segments[0].startMs,
endMs: segments[segments.length - 1].endMs,
text: segments
.map((segment) => {
const speaker = segment.speaker ?? "Unknown speaker";
return `${speaker}: ${segment.text}`;
})
.join("\n"),
speakerIds: [
...new Set(
segments
.map((segment) => segment.speaker)
.filter((speaker): speaker is string => Boolean(speaker))
)
],
segmentIds: segments.map((segment) => segment.id),
tokenCount: estimateTokenCount(
segments.map((segment) => segment.text).join(" ")
)
};
}
Timestamp của chunk không thay thế timestamp của từng segment.
Chunk dùng để retrieval. Segment dùng để phát lại chính xác và hiển thị bằng chứng chi tiết.
5. Overlap cần đủ dùng, không nên quá lớn
Overlap giúp tránh mất ngữ cảnh ở biên chunk.
Ví dụ:
Chunk A: 10:00–15:00
Chunk B: 14:30–19:30
Ba mươi giây lặp lại có thể giữ câu hỏi ở cuối chunk A cùng câu trả lời ở đầu chunk B.
Nhưng overlap quá lớn sẽ gây ra:
- Nhiều kết quả gần như giống nhau
- Chi phí embedding cao hơn
- LLM nhận cùng một bằng chứng nhiều lần
- Kết quả tổng hợp bị lặp
Một chiến lược thực tế là:
- Giới hạn overlap theo token
- Không cắt giữa câu
- Ưu tiên bắt đầu overlap từ lượt nói trước đó
- Lưu
source_segment_idsđể nhận diện phần trùng
Khi retrieval trả về nhiều chunk có cùng segment, chúng ta có thể deduplicate trước khi gửi vào LLM.
6. Chuẩn hóa transcript trước khi embedding
Embedding một transcript chưa được làm sạch có thể làm giảm chất lượng retrieval.
Một số bước chuẩn hóa hữu ích:
Loại bỏ nội dung không có ý nghĩa tìm kiếm
Ví dụ:
[Music]
[Silence]
[Background noise]
Không phải lúc nào cũng cần xóa. Nếu âm thanh đó có ý nghĩa với use case, hãy giữ lại dưới dạng metadata thay vì trộn vào phần text chính.
Chuẩn hóa speaker label
Không nên để cùng một người xuất hiện dưới nhiều tên:
Speaker 1
John
John Smith
J. Smith
Có thể map tất cả về một ID:
{
"speaker_id": "spk_01",
"display_name": "John Smith"
}
Sửa thực thể quan trọng
Ưu tiên kiểm tra:
- Tên người
- Tên công ty
- Tên sản phẩm
- Số tiền
- Ngày tháng
- Từ viết tắt
- Thuật ngữ kỹ thuật
Nếu tên sản phẩm bị transcribe sai ở mọi nơi, semantic search và câu trả lời RAG đều sẽ bị ảnh hưởng.
Giữ nguyên phủ định
Không được làm mất các từ như:
- không
- chưa
- không thể
- không đồng ý
- không được duyệt
Ví dụ:
“We should deploy on Friday.”
và:
“We should not deploy on Friday.”
có embedding gần nhau về chủ đề nhưng ý nghĩa vận hành hoàn toàn trái ngược.
Vì vậy retrieval theo vector phải luôn đi kèm bước đọc bằng chứng, không được xem similarity cao là câu trả lời cuối cùng.
7. Tạo embedding và lưu metadata
Mỗi chunk cần một vector embedding và metadata đủ để lọc.
Một record có thể có dạng:
type VectorRecord = {
id: string;
vector: number[];
metadata: {
documentId: string;
chunkId: string;
startMs: number;
endMs: number;
speakers: string[];
language: string;
recordingDate: string;
projectId?: string;
customerId?: string;
accessScope: string[];
};
text: string;
};
Metadata giúp thực hiện hybrid filtering.
Ví dụ người dùng hỏi:
Trong cuộc họp dự án Alpha tuần trước, ai chịu trách nhiệm kiểm tra export?
Không nên tìm trên toàn bộ transcript của mọi dự án.
Trước tiên, lọc:
{
"project_id": "alpha",
"recording_date": {
"$gte": "2026-07-27",
"$lte": "2026-08-03"
}
}
Sau đó mới chạy vector similarity search.
Điều này cải thiện cả độ chính xác và quyền riêng tư.
8. Semantic search không nên đứng một mình
Vector search tốt khi truy vấn và nội dung sử dụng từ khác nhau nhưng có ý nghĩa gần nhau.
Keyword search tốt khi cần tìm:
- Mã đơn hàng
- Tên hàm
- Số phiên bản
- Địa chỉ email
- Ticket ID
- Tên sản phẩm hiếm
- Chuỗi lỗi chính xác
Một hệ thống tốt nên dùng hybrid search:
final_score =
semantic_score * 0.65
+ keyword_score * 0.25
+ metadata_score * 0.10
Tỷ lệ trên chỉ là ví dụ. Cần đánh giá bằng dữ liệu thực tế.
Pipeline có thể là:
async function hybridSearch(
query: string,
filters: SearchFilters
): Promise<SearchResult[]> {
const [semanticResults, keywordResults] = await Promise.all([
vectorSearch(query, filters),
fullTextSearch(query, filters)
]);
return mergeAndRerank({
semanticResults,
keywordResults,
limit: 12
});
}
Sau bước merge, một reranker có thể đánh giá lại mức độ liên quan giữa câu hỏi và từng chunk.
9. Query rewriting cho câu hỏi hội thoại
Người dùng thường hỏi theo cách không đủ thông tin:
Còn deadline thì sao?
Nếu đây là câu thứ ba trong cuộc hội thoại, hệ thống phải biết “deadline” đang nói về nhiệm vụ nào.
Query rewriting có thể chuyển câu hỏi thành:
What deadline was confirmed for preparing the customer notification email
in the Product Alpha launch meeting?
Prompt rewrite nên giới hạn rõ:
Rewrite the latest user question into a standalone retrieval query.
Use only context already present in the conversation.
Do not answer the question.
Do not add names, dates, projects, or facts that were not provided.
Conversation:
[CHAT HISTORY]
Latest question:
[QUESTION]
Return the standalone query only.
Không nên dùng câu trả lời trước của LLM làm sự thật. Chỉ nên lấy thực thể đã được người dùng hoặc nguồn xác nhận.
10. Retrieval cần trả về bằng chứng, không phải chỉ text
Mỗi kết quả nên bao gồm:
{
"chunk_id": "chunk_28",
"score": 0.87,
"start_time": "00:31:42",
"end_time": "00:33:05",
"speakers": [
"Product Manager",
"Marketing Lead"
],
"text": "..."
}
Trong UI, kết quả có thể hiển thị:
31:42–33:05 · Product Manager, Marketing Lead
The launch date remains August 18, but the customer email must be reviewed
by Legal before August 15.
Khi người dùng nhấn timestamp, player chuyển thẳng tới 31:42.
Đây là điểm khác biệt giữa một chatbot thông thường và một hệ thống transcript có thể kiểm chứng.
11. Prompt RAG phải giới hạn nguồn trả lời
Một prompt tổng hợp có thể viết như sau:
You answer questions about a recorded conversation.
Use only the evidence provided below.
Rules:
1. Do not use outside knowledge.
2. Do not invent decisions, people, deadlines, or reasons.
3. Keep proposals separate from confirmed decisions.
4. If sources disagree, describe the disagreement.
5. If the evidence is insufficient, say so clearly.
6. Add a timestamp citation after every factual claim.
7. Preserve uncertainty found in the source.
Question:
[USER QUESTION]
Evidence:
[SOURCE CHUNKS]
Return:
- Direct answer
- Supporting details
- Uncertainty or disagreement
- Timestamp citations
Một câu trả lời tốt có thể là:
The team confirmed August 18 as the target launch date. [31:42–32:04]
The customer notification email must be reviewed by Legal before August 15,
but the transcript does not identify who will submit it for review.
[32:18–33:05]
Một câu trả lời không tốt là:
The marketing manager will send the email by August 15.
nếu transcript không nói rõ người phụ trách.
12. Trích dẫn phải gắn với claim cụ thể
Không nên chỉ đặt một danh sách timestamp ở cuối câu trả lời.
Ví dụ kém:
The launch was delayed, Legal needs to review the email, and the developer
will fix the export bug.
Sources: 12:30, 31:42, 44:05
Người dùng không biết timestamp nào hỗ trợ claim nào.
Ví dụ tốt hơn:
The launch date was moved to August 18. [31:42–32:04]
Legal review is required before the customer email is sent.
[32:18–32:49]
The export issue was assigned to the backend developer.
[44:05–44:31]
Trong dữ liệu trả về từ API, citation nên được cấu trúc:
type AnswerClaim = {
text: string;
citations: Array<{
documentId: string;
segmentId: string;
startMs: number;
endMs: number;
}>;
};
UI có thể render từng citation thành nút playback.
13. Phát hiện câu trả lời không có đủ bằng chứng
Một hệ thống đáng tin cậy phải có khả năng không trả lời.
Trước khi synthesis, có thể chạy một bước evidence sufficiency:
Evaluate whether the retrieved evidence is sufficient to answer the question.
Return:
- sufficient
- partially_sufficient
- insufficient
Check:
- Is the requested entity present?
- Is the requested action or decision explicit?
- Is the speaker identifiable?
- Is the date or deadline explicit?
- Do sources agree?
- Does the answer require information outside the transcript?
Do not answer the original question.
Nếu kết quả là insufficient, UI có thể trả về:
The retrieved transcript discusses the launch deadline, but it does not
identify a confirmed owner for the task.
Relevant section: 32:18–33:05
Đây là kết quả tốt hơn một câu trả lời được tạo ra chỉ để lấp đầy khoảng trống.
14. Phân quyền phải được áp dụng trước retrieval
Transcript có thể chứa dữ liệu nhạy cảm.
Không được retrieve dữ liệu rồi mới quyết định người dùng có quyền xem hay không.
Access filter phải nằm trong truy vấn:
const results = await vectorStore.search({
vector: queryEmbedding,
filter: {
organizationId: currentUser.organizationId,
accessScope: {
$contains: currentUser.role
}
},
limit: 20
});
Cần kiểm soát ít nhất:
- Organization
- Project
- Recording owner
- Shared workspace
- User role
- Explicit share permission
- Retention status
- Deleted status
Một embedding database cũng là nơi lưu dữ liệu dẫn xuất từ transcript. Xóa recording nhưng giữ chunk và vector có thể khiến nội dung vẫn tiếp tục xuất hiện trong search.
Quy trình delete nên bao gồm:
Original media
Transcript segments
Transcript chunks
Embeddings
Cached answers
Generated summaries
Search index entries
Share links
15. Đồng bộ khi transcript được chỉnh sửa
Người dùng có thể sửa tên, câu chữ hoặc speaker label sau khi embedding đã được tạo.
Nếu không cập nhật vector index, semantic search sẽ tiếp tục sử dụng phiên bản cũ.
Có thể dùng version:
type TranscriptVersion = {
transcriptId: string;
version: number;
updatedAt: string;
};
Mỗi vector record lưu:
{
"transcript_version": 12
}
Khi một segment thay đổi:
- Xác định các chunk chứa segment đó
- Tạo lại text của chunk
- Tạo embedding mới
- Upsert vào vector database
- Xóa cache liên quan
- Đánh dấu index version đã đồng bộ
Không cần re-embed toàn bộ transcript nếu chỉ một khu vực thay đổi.
16. Cache cần bao gồm phiên bản nguồn
Một cache key chỉ dựa trên câu hỏi là chưa đủ.
hash(question)
Câu trả lời có thể trở nên cũ sau khi transcript được chỉnh sửa.
Cache key tốt hơn:
hash(
normalized_query
+ transcript_id
+ transcript_version
+ access_scope
+ retrieval_config_version
+ prompt_version
)
Khi nguồn hoặc prompt thay đổi, cache tự động không còn hợp lệ.
17. Đánh giá retrieval trước khi đánh giá LLM
Nếu câu trả lời sai, lỗi có thể nằm ở nhiều nơi:
- Transcript sai
- Chunking sai
- Embedding không phù hợp
- Metadata filter sai
- Retrieval bỏ sót chunk quan trọng
- Reranker xếp sai thứ tự
- Prompt tổng hợp sai
- Citation mapping sai
Đừng chỉ thay prompt ngay lập tức.
Trước tiên cần đo retrieval.
Một tập kiểm thử có thể có dạng:
{
"question": "Ngày phát hành cuối cùng được xác nhận là ngày nào?",
"expected_segment_ids": [
"seg_483",
"seg_484"
],
"expected_answer": "August 18",
"must_not_include": [
"August 11"
]
}
Các metric hữu ích:
Recall@K
Trong top K kết quả có chứa segment đúng hay không?
Recall@5 = relevant queries retrieved within top 5 / total queries
Mean Reciprocal Rank
Segment đúng xuất hiện ở vị trí bao nhiêu?
Citation precision
Citation có thực sự hỗ trợ claim hay không?
Answer faithfulness
Mọi claim có xuất hiện trong bằng chứng hay không?
Abstention accuracy
Hệ thống có từ chối đúng khi không đủ dữ liệu không?
Một câu trả lời mượt mà không có giá trị nếu retrieval không lấy được nguồn đúng.
18. Tạo bộ câu hỏi kiểm thử từ tình huống thực
Không nên chỉ kiểm thử những câu dễ.
Bộ evaluation cần có:
Exact lookup
What error code was mentioned?
Semantic lookup
Why did the team decide not to release on Friday?
Multi-hop question
Which unresolved dependency affects the launch date, and who raised it?
Negative question
Did the team approve a mobile application?
trong khi transcript chỉ thảo luận nhưng không phê duyệt.
Contradiction question
Một người đề xuất ngày 11, người khác sửa thành ngày 18.
Missing-information question
Who owns the Legal review?
khi transcript không nêu người phụ trách.
Access-control test
Người dùng thuộc dự án A không được retrieve transcript của dự án B.
Đây là những trường hợp dễ làm lộ điểm yếu của hệ thống.
19. API response nên tách answer và evidence
Một response có thể được thiết kế như sau:
type TranscriptAnswerResponse = {
answer: {
text: string;
confidence: "high" | "medium" | "low";
status:
| "answered"
| "partially_answered"
| "insufficient_evidence";
};
claims: AnswerClaim[];
sources: Array<{
segmentId: string;
startMs: number;
endMs: number;
speaker: string | null;
text: string;
}>;
retrieval: {
query: string;
resultCount: number;
transcriptVersion: number;
};
};
Frontend có thể hiển thị:
- Câu trả lời chính
- Claim và citation
- Nút phát tại timestamp
- Đoạn transcript liên quan
- Mức độ chắc chắn
- Cảnh báo khi nguồn không đủ
Không nên chỉ trả về một chuỗi Markdown duy nhất nếu hệ thống cần khả năng kiểm tra và tương tác.
20. Pipeline triển khai tối thiểu
Một phiên bản đầu tiên có thể được xây theo thứ tự sau:
1. Store timestamped transcript segments
2. Create 300–500 token chunks with overlap
3. Generate embeddings
4. Store vectors with document and access metadata
5. Implement top-K semantic retrieval
6. Add full-text keyword search
7. Merge and rerank results
8. Generate answers using retrieved chunks only
9. Require timestamp citations
10. Add a player jump for every citation
11. Build a small evaluation dataset
12. Add transcript versioning and re-indexing
Không cần triển khai mọi tính năng nâng cao ngay từ đầu.
Điều quan trọng nhất của phiên bản đầu là:
- Retrieval tìm đúng đoạn
- Câu trả lời không vượt quá nguồn
- Citation mở đúng timestamp
- Dữ liệu không vượt qua ranh giới quyền truy cập
- Transcript chỉnh sửa được đồng bộ vào index
Kết luận
Xây dựng RAG cho transcript không chỉ là đưa văn bản vào vector database rồi kết nối với một LLM.
Transcript có những đặc điểm riêng:
- Nội dung diễn ra theo thời gian
- Ý nghĩa phụ thuộc vào speaker
- Quyết định có thể được sửa ở phần sau
- Một câu có thể cần ngữ cảnh hình ảnh
- Từ phủ định có thể thay đổi hoàn toàn kết luận
- Người dùng cần quay lại recording để kiểm tra
- Dữ liệu thường chứa thông tin riêng tư
Vì vậy, một hệ thống đáng tin cậy cần giữ timestamp xuyên suốt toàn bộ pipeline.
Chunk phục vụ retrieval.
Segment phục vụ playback và citation.
Vector search giúp tìm theo ý nghĩa.
Keyword search tìm các thực thể chính xác.
Metadata giới hạn phạm vi dữ liệu.
Reranker cải thiện thứ tự kết quả.
LLM chỉ tổng hợp từ bằng chứng đã truy xuất.
Cuối cùng, mỗi claim phải dẫn người dùng quay lại đúng phần của recording.
Khi thiết kế theo hướng này, transcript không còn là một tài liệu dài chỉ có thể đọc từ đầu đến cuối.
Nó trở thành một nguồn dữ liệu có thể tìm kiếm, truy vấn, kiểm chứng và tích hợp vào các workflow kỹ thuật khác mà không đánh mất mối liên hệ với lời nói ban đầu.
All rights reserved